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)