From c9bc3d347e5df255195db4d2cdb83ec899c9dccd Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 17 Jun 2025 13:05:14 +1000 Subject: [PATCH] refactor --- .../BranchManagement/DatabaseDiffPanel.tsx | 75 ++----- .../EdgeFunctionsDiffPanel.tsx | 192 +++--------------- .../layouts/AppLayout/BranchDropdown.tsx | 50 ++--- .../layouts/PageLayout/PageLayout.tsx | 4 +- apps/studio/components/ui/AsyncState.tsx | 34 ++++ apps/studio/components/ui/DiffViewer.tsx | 48 +++++ .../studio/hooks/misc/useEdgeFunctionsDiff.ts | 162 +++++++++++++++ apps/studio/pages/project/[ref]/merge.tsx | 7 + 8 files changed, 325 insertions(+), 247 deletions(-) create mode 100644 apps/studio/components/ui/AsyncState.tsx create mode 100644 apps/studio/components/ui/DiffViewer.tsx create mode 100644 apps/studio/hooks/misc/useEdgeFunctionsDiff.ts diff --git a/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx index a049085cd47..d2c44a00a79 100644 --- a/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx @@ -1,8 +1,9 @@ -import { DiffEditor } from '@monaco-editor/react' -import { Database, Wind, Loader2 } from 'lucide-react' -import { editor as monacoEditor } from 'monaco-editor' import Link from 'next/link' -import { Card, CardContent, CardHeader, CardTitle, Skeleton } from 'ui' +import { Database, Wind } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from 'ui' + +import DiffViewer from 'components/ui/DiffViewer' +import { Loading, ErrorState, EmptyState } from 'components/ui/AsyncState' interface DatabaseDiffPanelProps { diffContent?: string @@ -17,64 +18,21 @@ const DatabaseDiffPanel = ({ diffContent, isLoading, error, - showRefreshButton = false, currentBranchRef, onRefresh, }: DatabaseDiffPanelProps) => { - // Monaco editor options for diff display - const defaultOptions: monacoEditor.IStandaloneDiffEditorConstructionOptions = { - readOnly: true, - renderSideBySide: false, - minimap: { enabled: false }, - wordWrap: 'on', - lineNumbers: 'on', - folding: false, - padding: { top: 16, bottom: 16 }, - lineNumbersMinChars: 3, - fontSize: 13, - scrollBeyondLastLine: false, - } + if (isLoading) return - if (isLoading) { - return - } - - if (error) { - const isConnectionError = - error.message?.includes('ECONNREFUSED') || error.message?.includes('Failed to diff branch') - - return ( -
-
-

Error loading branch diff

-
- {isConnectionError ? ( -
-

Unable to connect to the branch database. This could be because:

-
    -
  • The branch database is not ready yet
  • -
  • The branch is in an inactive state
  • -
  • There's a temporary networking issue
  • -
-

Please try again in a few minutes.

-
- ) : ( -

{error.message}

- )} -
- ) - } + if (error) return // Handle empty diff content if (!diffContent || diffContent.trim() === '') { return ( -
- -

No changes detected between branches

-

- Any changes to your database schema will be shown here for review -

-
+ ) } @@ -92,14 +50,7 @@ const DatabaseDiffPanel = ({ - + ) diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index 811151ec2a9..44b7b02d0d2 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -1,15 +1,14 @@ -import { DiffEditor } from '@monaco-editor/react' -import { editor as monacoEditor } from 'monaco-editor' -import { - getEdgeFunctionBody, - type EdgeFunctionBodyData, -} from 'data/edge-functions/edge-function-body-query' import type { EdgeFunctionsData } from 'data/edge-functions/edge-functions-query' -import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' +import type { EdgeFunctionBodyData } from 'data/edge-functions/edge-function-body-query' + +import { Card, CardContent, CardHeader, CardTitle, cn } from 'ui' import { Code, Wind } from 'lucide-react' +import DiffViewer from 'components/ui/DiffViewer' +import { Loading, EmptyState } from 'components/ui/AsyncState' +import useEdgeFunctionsDiff from 'hooks/misc/useEdgeFunctionsDiff' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' -import { useQueries } from '@tanstack/react-query' +import { basename } from 'path' interface EdgeFunctionsDiffPanelProps { currentBranchFunctions?: EdgeFunctionsData @@ -28,7 +27,7 @@ interface FunctionDiffProps { } // Helper to canonicalize file identifiers to prevent mismatch due to differing root paths -const fileKey = (fullPath: string) => fullPath.split('/').pop() ?? fullPath +const fileKey = (fullPath: string) => basename(fullPath) const FunctionDiff = ({ functionSlug, @@ -36,20 +35,6 @@ const FunctionDiff = ({ mainBody, currentBranchRef, }: FunctionDiffProps) => { - // Monaco editor options for diff display - const defaultOptions: monacoEditor.IStandaloneDiffEditorConstructionOptions = { - readOnly: true, - renderSideBySide: false, - minimap: { enabled: false }, - wordWrap: 'on', - lineNumbers: 'on', - folding: false, - padding: { top: 16, bottom: 16 }, - lineNumbersMinChars: 3, - fontSize: 13, - scrollBeyondLastLine: false, - } - // Determine list of files with differences (by canonical key) const diffFileKeys = useMemo(() => { const keys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) @@ -126,13 +111,10 @@ const FunctionDiff = ({ {/* Diff viewer */}
-
@@ -149,150 +131,40 @@ const EdgeFunctionsDiffPanel = ({ currentBranchRef, mainBranchRef, }: EdgeFunctionsDiffPanelProps) => { - // Compare edge functions between branches for added/removed and potential overlap - const compareEdgeFunctions = () => { - if (!currentBranchFunctions || !mainBranchFunctions) { - return { added: [], removed: [], overlap: [] as typeof currentBranchFunctions } - } - - const currentFuncs = currentBranchFunctions || [] - const mainFuncs = mainBranchFunctions || [] - - const added = currentFuncs.filter( - (currentFunc) => !mainFuncs.find((mainFunc) => mainFunc.slug === currentFunc.slug) - ) - - const removed = mainFuncs.filter( - (mainFunc) => !currentFuncs.find((currentFunc) => currentFunc.slug === mainFunc.slug) - ) - - // Functions that exist in both branches are potential modifications - const overlap = currentFuncs.filter((currentFunc) => - mainFuncs.find((f) => f.slug === currentFunc.slug) - ) - - return { added, removed, overlap } - } - const { - added = [], - removed = [], - overlap = [], - } = useMemo(compareEdgeFunctions, [currentBranchFunctions, mainBranchFunctions]) - - // Fetch bodies for overlapping functions in both branches - const overlapSlugs = overlap.map((f) => f.slug) - - // FIRST_EDIT: introduce slug arrays for added and removed - const addedSlugs = added.map((f) => f.slug) - const removedSlugs = removed.map((f) => f.slug) - - const currentBodiesQueries = useQueries({ - queries: overlapSlugs.map((slug) => ({ - queryKey: ['edge-function-body', currentBranchRef, slug], - queryFn: ({ signal }: { signal?: AbortSignal }) => - getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), - enabled: !!currentBranchRef, - })), + addedSlugs, + removedSlugs, + modifiedSlugs, + addedBodiesMap, + removedBodiesMap, + currentBodiesMap, + mainBodiesMap, + isLoading, + hasChanges, + } = useEdgeFunctionsDiff({ + currentBranchFunctions, + mainBranchFunctions, + currentBranchRef, + mainBranchRef, }) - const mainBodiesQueries = useQueries({ - queries: overlapSlugs.map((slug) => ({ - queryKey: ['edge-function-body', mainBranchRef, slug], - queryFn: ({ signal }: { signal?: AbortSignal }) => - getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), - enabled: !!mainBranchRef, - })), - }) - - // SECOND_EDIT: fetch bodies for added and removed functions - const addedBodiesQueries = useQueries({ - queries: addedSlugs.map((slug) => ({ - queryKey: ['edge-function-body', currentBranchRef, slug], - queryFn: ({ signal }: { signal?: AbortSignal }) => - getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), - enabled: !!currentBranchRef, - })), - }) - - const removedBodiesQueries = useQueries({ - queries: removedSlugs.map((slug) => ({ - queryKey: ['edge-function-body', mainBranchRef, slug], - queryFn: ({ signal }: { signal?: AbortSignal }) => - getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), - enabled: !!mainBranchRef, - })), - }) - - // THIRD_EDIT: include added/removed loading status - const isBodiesLoading = - currentBodiesQueries.some((q) => q.isLoading) || - mainBodiesQueries.some((q) => q.isLoading) || - addedBodiesQueries.some((q) => q.isLoading) || - removedBodiesQueries.some((q) => q.isLoading) - - const currentBodiesMap: Record = {} - currentBodiesQueries.forEach((q, idx) => { - if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data - }) - - const mainBodiesMap: Record = {} - mainBodiesQueries.forEach((q, idx) => { - if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data - }) - - // FOURTH_EDIT: build bodies map for added and removed slugs - const addedBodiesMap: Record = {} - addedBodiesQueries.forEach((q, idx) => { - if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data - }) - - const removedBodiesMap: Record = {} - removedBodiesQueries.forEach((q, idx) => { - if (q.data) removedBodiesMap[removedSlugs[idx]] = q.data - }) - - // Determine which overlapping functions actually have modifications in any of their files - const modifiedSlugs = overlapSlugs.filter((slug) => { - const currentBody = currentBodiesMap[slug] - const mainBody = mainBodiesMap[slug] - if (!currentBody || !mainBody) return false - - const keys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) - - for (const key of keys) { - const currentFile = currentBody.find((f) => fileKey(f.name) === key) - const mainFile = mainBody.find((f) => fileKey(f.name) === key) - if ((currentFile?.content || '') !== (mainFile?.content || '')) { - return true - } - } - - return false - }) - - const hasChanges = added.length > 0 || removed.length > 0 || modifiedSlugs.length > 0 - - if (isCurrentFunctionsLoading || isMainFunctionsLoading || isBodiesLoading) { - return + if (isCurrentFunctionsLoading || isMainFunctionsLoading || isLoading) { + return } - // If no changes and checking complete if (!hasChanges) { return ( -
- -

No changes detected between branches

-

- Any changes to your edge functions will be shown here for review -

-
+ ) } return (
- {added.length > 0 && ( + {addedSlugs.length > 0 && (
{addedSlugs.map((slug) => ( @@ -308,7 +180,7 @@ const EdgeFunctionsDiffPanel = ({
)} - {removed.length > 0 && ( + {removedSlugs.length > 0 && (
{removedSlugs.map((slug) => ( diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index 7da328fa57c..6bfd7455009 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -32,6 +32,7 @@ import { cn, } from 'ui' import { sanitizeRoute } from './ProjectDropdown' +import { useFlag } from 'hooks/ui/useFlag' const BranchLink = ({ branch, @@ -73,6 +74,7 @@ const BranchLink = ({ export const BranchDropdown = () => { const router = useRouter() const { ref } = useParams() + const gitlessBranching = useFlag('gitlessBranching') const projectDetails = useSelectedProject() const isBranch = projectDetails?.parent_project_ref !== undefined @@ -144,28 +146,6 @@ export const BranchDropdown = () => { - {/* Show merge button only for non-main branches */} - {isBranch && selectedBranch && !selectedBranch.is_default && ( - <> - { - setOpen(false) - router.push(`/project/${ref}/merge`) - }} - onClick={() => setOpen(false)} - > - - -

Request a merge

- -
- - - )} { @@ -179,9 +159,33 @@ export const BranchDropdown = () => { className="w-full flex items-center gap-2" > -

Manage branches

+ Manage branches
+ {/* Show merge button only for non-main branches, gated by feature flag */} + {gitlessBranching && + isBranch && + selectedBranch && + !selectedBranch.is_default && ( + <> + { + setOpen(false) + router.push(`/project/${ref}/merge`) + }} + onClick={() => setOpen(false)} + > + + + Review changes + + + + )}
diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx index 2d71bd1d9ce..f725e915cb3 100644 --- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx +++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx @@ -81,8 +81,8 @@ export const PageLayout = ({ className={cn( 'w-full mx-auto', size === 'full' && - (isCompact ? 'max-w-none !px-6 border-b' : 'max-w-none p!x-8 border-b'), - isCompact ? 'pt-4' : 'pt-12', + (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 border-b'), + size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'), navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'), className )} diff --git a/apps/studio/components/ui/AsyncState.tsx b/apps/studio/components/ui/AsyncState.tsx new file mode 100644 index 00000000000..d50b2c52cc5 --- /dev/null +++ b/apps/studio/components/ui/AsyncState.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from 'ui' +import { CircleAlert, LucideIcon, Wind } from 'lucide-react' + +export const Loading = () => + +export const ErrorState = ({ + message = 'An unexpected error has occurred', +}: { + message?: string +}) => ( +
+ +

{message}

+

+ Please try again in a few minutes and contact support if the problem persists. +

+
+) + +export const EmptyState = ({ + title, + description, + icon: Icon = Wind, +}: { + title: string + description: string + icon?: LucideIcon +}) => ( +
+ +

{title}

+

{description}

+
+) diff --git a/apps/studio/components/ui/DiffViewer.tsx b/apps/studio/components/ui/DiffViewer.tsx new file mode 100644 index 00000000000..ae70313de0f --- /dev/null +++ b/apps/studio/components/ui/DiffViewer.tsx @@ -0,0 +1,48 @@ +import { DiffEditor } from '@monaco-editor/react' +import { editor as monacoEditor } from 'monaco-editor' + +interface DiffViewerProps { + /** Original/left hand side content (optional) */ + original?: string + /** Modified/right hand side content */ + modified: string | undefined + /** Language identifier understood by Monaco */ + language: string + /** Height for the editor container */ + height?: string | number + /** Whether to render diffs side-by-side */ + sideBySide?: boolean +} + +// Centralised set of options so all diff editors look the same +const DEFAULT_OPTIONS: monacoEditor.IStandaloneDiffEditorConstructionOptions = { + readOnly: true, + renderSideBySide: false, + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + folding: false, + padding: { top: 16, bottom: 16 }, + lineNumbersMinChars: 3, + fontSize: 13, + scrollBeyondLastLine: false, +} + +export const DiffViewer = ({ + original = '', + modified = '', + language, + height = '100%', + sideBySide = false, +}: DiffViewerProps) => ( + +) + +export default DiffViewer diff --git a/apps/studio/hooks/misc/useEdgeFunctionsDiff.ts b/apps/studio/hooks/misc/useEdgeFunctionsDiff.ts new file mode 100644 index 00000000000..2de0ce731a7 --- /dev/null +++ b/apps/studio/hooks/misc/useEdgeFunctionsDiff.ts @@ -0,0 +1,162 @@ +import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { + getEdgeFunctionBody, + type EdgeFunctionBodyData, +} from 'data/edge-functions/edge-function-body-query' +import type { EdgeFunctionsData } from 'data/edge-functions/edge-functions-query' +import { basename } from 'path' + +interface UseEdgeFunctionsDiffProps { + currentBranchFunctions?: EdgeFunctionsData + mainBranchFunctions?: EdgeFunctionsData + currentBranchRef?: string + mainBranchRef?: string +} + +export interface EdgeFunctionsDiffResult { + addedSlugs: string[] + removedSlugs: string[] + modifiedSlugs: string[] + addedBodiesMap: Record + removedBodiesMap: Record + currentBodiesMap: Record + mainBodiesMap: Record + isLoading: boolean + hasChanges: boolean +} + +// Small helper around path.basename but avoids importing the full Node path lib for the browser bundle +const fileKey = (fullPath: string) => basename(fullPath) + +export const useEdgeFunctionsDiff = ({ + currentBranchFunctions, + mainBranchFunctions, + currentBranchRef, + mainBranchRef, +}: UseEdgeFunctionsDiffProps): EdgeFunctionsDiffResult => { + // Identify added / removed / overlapping functions + const { + added = [], + removed = [], + overlap = [], + } = useMemo(() => { + if (!currentBranchFunctions || !mainBranchFunctions) { + return { added: [], removed: [], overlap: [] as typeof currentBranchFunctions } + } + + const currentFuncs = currentBranchFunctions ?? [] + const mainFuncs = mainBranchFunctions ?? [] + + const added = currentFuncs.filter((c) => !mainFuncs.find((m) => m.slug === c.slug)) + const removed = mainFuncs.filter((m) => !currentFuncs.find((c) => c.slug === m.slug)) + const overlap = currentFuncs.filter((c) => mainFuncs.find((m) => m.slug === c.slug)) + + return { added, removed, overlap } + }, [currentBranchFunctions, mainBranchFunctions]) + + const overlapSlugs = overlap.map((f) => f.slug) + const addedSlugs = added.map((f) => f.slug) + const removedSlugs = removed.map((f) => f.slug) + + // Fetch function bodies --------------------------------------------------- + const currentBodiesQueries = useQueries({ + queries: overlapSlugs.map((slug) => ({ + queryKey: ['edge-function-body', currentBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), + enabled: !!currentBranchRef, + })), + }) + + const mainBodiesQueries = useQueries({ + queries: overlapSlugs.map((slug) => ({ + queryKey: ['edge-function-body', mainBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), + enabled: !!mainBranchRef, + })), + }) + + const addedBodiesQueries = useQueries({ + queries: addedSlugs.map((slug) => ({ + queryKey: ['edge-function-body', currentBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), + enabled: !!currentBranchRef, + })), + }) + + const removedBodiesQueries = useQueries({ + queries: removedSlugs.map((slug) => ({ + queryKey: ['edge-function-body', mainBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), + enabled: !!mainBranchRef, + })), + }) + + // Flatten loading flags ---------------------------------------------------- + const isLoading = [ + ...currentBodiesQueries, + ...mainBodiesQueries, + ...addedBodiesQueries, + ...removedBodiesQueries, + ].some((q) => q.isLoading) + + // Build lookup maps -------------------------------------------------------- + const currentBodiesMap: Record = {} + currentBodiesQueries.forEach((q, idx) => { + if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data + }) + + const mainBodiesMap: Record = {} + mainBodiesQueries.forEach((q, idx) => { + if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data + }) + + const addedBodiesMap: Record = {} + addedBodiesQueries.forEach((q, idx) => { + if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data + }) + + const removedBodiesMap: Record = {} + removedBodiesQueries.forEach((q, idx) => { + if (q.data) removedBodiesMap[removedSlugs[idx]] = q.data + }) + + // Determine modified slugs ------------------------------------------------- + const modifiedSlugs = overlapSlugs.filter((slug) => { + const currentBody = currentBodiesMap[slug] + const mainBody = mainBodiesMap[slug] + if (!currentBody || !mainBody) return false + + const keys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) + + for (const key of keys) { + const currentFile = currentBody.find((f) => fileKey(f.name) === key) + const mainFile = mainBody.find((f) => fileKey(f.name) === key) + if ((currentFile?.content || '') !== (mainFile?.content || '')) { + return true + } + } + + return false + }) + + const hasChanges = addedSlugs.length > 0 || removedSlugs.length > 0 || modifiedSlugs.length > 0 + + return { + addedSlugs, + removedSlugs, + modifiedSlugs, + addedBodiesMap, + removedBodiesMap, + currentBodiesMap, + mainBodiesMap, + isLoading, + hasChanges, + } +} + +export default useEdgeFunctionsDiff diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx index 744b4658702..2d9f3d8665d 100644 --- a/apps/studio/pages/project/[ref]/merge.tsx +++ b/apps/studio/pages/project/[ref]/merge.tsx @@ -23,10 +23,17 @@ import { GitMerge } from 'lucide-react' import Link from 'next/link' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard' +import { useFlag } from 'hooks/ui/useFlag' const MergePage: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams() + + const gitlessBranching = useFlag('gitlessBranching') + if (!gitlessBranching) { + router.push(`/project/${ref}/branches`) + } + const project = useSelectedProject() const [isSubmitting, setIsSubmitting] = useState(false)