This commit is contained in:
Saxon Fletcher committed 2025-06-17 13:05:14 +10:00
1 parent 2077d7804c
commit c9bc3d347e
8 files changed
+325 -247

No files matched your search

@@ -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 <Loading />
if (isLoading) {
return <Skeleton className="h-64" />
}
if (error) {
const isConnectionError =
error.message?.includes('ECONNREFUSED') || error.message?.includes('Failed to diff branch')
return (
<div className="p-6 text-center">
<div className="text-red-500 mb-2">
<p>Error loading branch diff</p>
</div>
{isConnectionError ? (
<div className="text-sm text-gray-600 space-y-1">
<p>Unable to connect to the branch database. This could be because:</p>
<ul className="list-disc list-inside text-left max-w-md mx-auto">
<li>The branch database is not ready yet</li>
<li>The branch is in an inactive state</li>
<li>There's a temporary networking issue</li>
</ul>
<p className="mt-2">Please try again in a few minutes.</p>
</div>
) : (
<p className="text-sm text-gray-600">{error.message}</p>
)}
</div>
)
}
if (error) return <ErrorState message="Error loading branch diff" />
// Handle empty diff content
if (!diffContent || diffContent.trim() === '') {
return (
<div className="p-6 text-center">
<Wind size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">No changes detected between branches</h3>
<p className="text-sm text-foreground-light">
Any changes to your database schema will be shown here for review
</p>
</div>
<EmptyState
title="No changes detected between branches"
description="Any changes to your database schema will be shown here for review"
icon={Wind}
/>
)
}
@@ -92,14 +50,7 @@ const DatabaseDiffPanel = ({
</CardTitle>
</CardHeader>
<CardContent className="p-0 h-96">
<DiffEditor
theme="supabase"
language="sql"
height="100%"
original=""
modified={diffContent}
options={defaultOptions}
/>
<DiffViewer language="sql" original="" modified={diffContent} />
</CardContent>
</Card>
)
@@ -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 */}
<div className="flex-1 min-h-0">
<DiffEditor
theme="supabase"
<DiffViewer
language={language}
height="100%"
original={mainFile?.content || ''}
modified={currentFile?.content || ''}
options={defaultOptions}
/>
</div>
</div>
@@ -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<string, EdgeFunctionBodyData | undefined> = {}
currentBodiesQueries.forEach((q, idx) => {
if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data
})
const mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
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<string, EdgeFunctionBodyData | undefined> = {}
addedBodiesQueries.forEach((q, idx) => {
if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data
})
const removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
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 <Skeleton className="h-64" />
if (isCurrentFunctionsLoading || isMainFunctionsLoading || isLoading) {
return <Loading />
}
// If no changes and checking complete
if (!hasChanges) {
return (
<div className="p-6 text-center">
<Wind size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">No changes detected between branches</h3>
<p className="text-sm text-foreground-light">
Any changes to your edge functions will be shown here for review
</p>
</div>
<EmptyState
title="No changes detected between branches"
description="Any changes to your edge functions will be shown here for review"
icon={Wind}
/>
)
}
return (
<div className="space-y-6">
{added.length > 0 && (
{addedSlugs.length > 0 && (
<div>
<div className="space-y-4">
{addedSlugs.map((slug) => (
@@ -308,7 +180,7 @@ const EdgeFunctionsDiffPanel = ({
</div>
)}
{removed.length > 0 && (
{removedSlugs.length > 0 && (
<div>
<div className="space-y-4">
{removedSlugs.map((slug) => (
@@ -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 = () => {
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
{/* Show merge button only for non-main branches */}
{isBranch && selectedBranch && !selectedBranch.is_default && (
<>
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={() => {
setOpen(false)
router.push(`/project/${ref}/merge`)
}}
onClick={() => setOpen(false)}
>
<Link
href={`/project/${ref}/merge`}
className="w-full flex items-center gap-2"
>
<GitMerge size={14} strokeWidth={1.5} />
<p>Request a merge</p>
</Link>
</CommandItem_Shadcn_>
<CommandSeparator_Shadcn_ />
</>
)}
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={(e) => {
@@ -179,9 +159,33 @@ export const BranchDropdown = () => {
className="w-full flex items-center gap-2"
>
<ListTree size={14} strokeWidth={1.5} />
<p>Manage branches</p>
Manage branches
</Link>
</CommandItem_Shadcn_>
{/* Show merge button only for non-main branches, gated by feature flag */}
{gitlessBranching &&
isBranch &&
selectedBranch &&
!selectedBranch.is_default && (
<>
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={() => {
setOpen(false)
router.push(`/project/${ref}/merge`)
}}
onClick={() => setOpen(false)}
>
<Link
href={`/project/${ref}/merge`}
className="w-full flex items-center gap-2"
>
<GitMerge size={14} strokeWidth={1.5} />
Review changes
</Link>
</CommandItem_Shadcn_>
</>
)}
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
@@ -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
)}
+34
View File
@@ -0,0 +1,34 @@
import { Skeleton } from 'ui'
import { CircleAlert, LucideIcon, Wind } from 'lucide-react'
export const Loading = () => <Skeleton className="h-64" />
export const ErrorState = ({
message = 'An unexpected error has occurred',
}: {
message?: string
}) => (
<div className="p-6 text-center">
<CircleAlert size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">{message}</h3>
<p className="text-sm text-foreground-light">
Please try again in a few minutes and contact support if the problem persists.
</p>
</div>
)
export const EmptyState = ({
title,
description,
icon: Icon = Wind,
}: {
title: string
description: string
icon?: LucideIcon
}) => (
<div className="p-6 text-center">
<Icon size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">{title}</h3>
<p className="text-sm text-foreground-light">{description}</p>
</div>
)
+48
View File
@@ -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) => (
<DiffEditor
theme="supabase"
language={language}
height={height}
original={original}
modified={modified}
options={{ ...DEFAULT_OPTIONS, renderSideBySide: sideBySide }}
/>
)
export default DiffViewer
@@ -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<string, EdgeFunctionBodyData | undefined>
removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
currentBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
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<string, EdgeFunctionBodyData | undefined> = {}
currentBodiesQueries.forEach((q, idx) => {
if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data
})
const mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
mainBodiesQueries.forEach((q, idx) => {
if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data
})
const addedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
addedBodiesQueries.forEach((q, idx) => {
if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data
})
const removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
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
@@ -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)