Review and merge branch (#36795)
* allow creating branching without git * update branching modals * add account connections * edit branch * copy * update copy * enable branch modal changes * add gitless branching flag * update account connections * merge page * merge experiment * update merge * update pull requests empty state * use diff query * branch diffing * diff query * Clean up * refinements to gitless branching * branching merge and status * link * branch function diffing * update styling * refactor * remove hook * error handling * move * remove enable branching modal * re-add github linker * abstract away enable and disable * toggle fixes * update logic to lean on connection status * update form logic * sheet layout * gitless flag * style and workflow updates * fix side panel size * fix duplicate onerror * copy changes * refetch * merge mutation copy * remove import * add cost * allow connection details on create * initial queries * push button * merge cleanup * Fix TS issues * Fix TS issues * Couple of clean ups * Revert hardcode in useFlag * Fix TS * layout issues and github check * refactor * refactor to use new field * cleanup * style * failed merge * error positioning * refactoring merge * workflow refactor * hook move * clarification with github integration * replace branch dropdown button * update repo picker * updates * remove modal * fix small nits * change defaults * clean up * disable if not gitless and no connection * clean up * always show workflow run id * optimistic * fix branch query * fix issues * fetch edge diff * confirm merge * update edge functions key --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com> Co-authored-by: Alaister Young <a@alaisteryoung.com>
This commit is contained in:
@@ -17,7 +17,7 @@ export function useBranchCommands() {
|
||||
|
||||
let { data: branches } = useBranchesQuery(
|
||||
{
|
||||
projectRef: selectedProject?.parentRef,
|
||||
projectRef: selectedProject?.parent_project_ref || selectedProject?.ref,
|
||||
},
|
||||
{ enabled: isBranchingEnabled }
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ export const BranchRow = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link href={`/project/${branch.project_ref}/branches`} title={branch.name}>
|
||||
<Link href={`/project/${branch.project_ref}`} title={branch.name}>
|
||||
{branch.name}
|
||||
</Link>
|
||||
</ButtonTooltip>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link'
|
||||
import { CircleAlert, Database, Wind } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from 'ui'
|
||||
|
||||
import DiffViewer from 'components/ui/DiffViewer'
|
||||
|
||||
interface DatabaseDiffPanelProps {
|
||||
diffContent?: string
|
||||
isLoading: boolean
|
||||
error?: any
|
||||
showRefreshButton?: boolean
|
||||
currentBranchRef?: string
|
||||
}
|
||||
|
||||
const DatabaseDiffPanel = ({
|
||||
diffContent,
|
||||
isLoading,
|
||||
error,
|
||||
currentBranchRef,
|
||||
}: DatabaseDiffPanelProps) => {
|
||||
if (isLoading) return <Skeleton className="h-64" />
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<CircleAlert size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
|
||||
<h3 className="mb-1">Error loading branch diff</h3>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Please try again in a few minutes and contact support if the problem persists.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Link
|
||||
href={`/project/${currentBranchRef}/database/schema`}
|
||||
className="text-foreground-light flex items-center gap-2"
|
||||
>
|
||||
<Database strokeWidth={1.5} size={16} />
|
||||
Schema Changes
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 h-96">
|
||||
<DiffViewer language="sql" original="" modified={diffContent} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatabaseDiffPanel
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Code, Wind } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import DiffViewer from 'components/ui/DiffViewer'
|
||||
import type { EdgeFunctionBodyData } from 'data/edge-functions/edge-function-body-query'
|
||||
import type {
|
||||
EdgeFunctionsDiffResult,
|
||||
FileInfo,
|
||||
FileStatus,
|
||||
} from 'hooks/branches/useEdgeFunctionsDiff'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
import { basename } from 'path'
|
||||
import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui'
|
||||
|
||||
interface EdgeFunctionsDiffPanelProps {
|
||||
diffResults: EdgeFunctionsDiffResult
|
||||
currentBranchRef?: string
|
||||
mainBranchRef?: string
|
||||
}
|
||||
|
||||
interface FunctionDiffProps {
|
||||
functionSlug: string
|
||||
currentBody: EdgeFunctionBodyData
|
||||
mainBody: EdgeFunctionBodyData
|
||||
currentBranchRef?: string
|
||||
fileInfos: FileInfo[]
|
||||
}
|
||||
|
||||
// Helper to canonicalize file identifiers to prevent mismatch due to differing root paths
|
||||
const fileKey = (fullPath: string) => basename(fullPath)
|
||||
|
||||
// Helper to get the status color for file indicators
|
||||
const getStatusColor = (status: FileStatus): string => {
|
||||
switch (status) {
|
||||
case 'added':
|
||||
return 'bg-brand'
|
||||
case 'removed':
|
||||
return 'bg-destructive'
|
||||
case 'modified':
|
||||
return 'bg-warning'
|
||||
case 'unchanged':
|
||||
return 'bg-muted'
|
||||
default:
|
||||
return 'bg-muted'
|
||||
}
|
||||
}
|
||||
|
||||
const FunctionDiff = ({
|
||||
functionSlug,
|
||||
currentBody,
|
||||
mainBody,
|
||||
currentBranchRef,
|
||||
fileInfos,
|
||||
}: FunctionDiffProps) => {
|
||||
// Get all file keys from fileInfos
|
||||
const allFileKeys = useMemo(() => fileInfos.map((info) => info.key), [fileInfos])
|
||||
|
||||
const [activeFileKey, setActiveFileKey] = useState<string | undefined>(() => allFileKeys[0])
|
||||
|
||||
// Keep active tab in sync when allFileKeys changes (e.g. data fetch completes)
|
||||
useEffect(() => {
|
||||
if (!activeFileKey || !allFileKeys.includes(activeFileKey)) {
|
||||
setActiveFileKey(allFileKeys[0])
|
||||
}
|
||||
}, [allFileKeys, activeFileKey])
|
||||
|
||||
const currentFile = currentBody.find((f) => fileKey(f.name) === activeFileKey)
|
||||
const mainFile = mainBody.find((f) => fileKey(f.name) === activeFileKey)
|
||||
|
||||
const language = useMemo(() => {
|
||||
if (!activeFileKey) return 'plaintext'
|
||||
if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) return 'typescript'
|
||||
if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) return 'javascript'
|
||||
if (activeFileKey.endsWith('.json')) return 'json'
|
||||
if (activeFileKey.endsWith('.sql')) return 'sql'
|
||||
return 'plaintext'
|
||||
}, [activeFileKey])
|
||||
|
||||
if (allFileKeys.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-0 px-4">
|
||||
{/* Function title */}
|
||||
<CardTitle>
|
||||
<Link
|
||||
href={`/project/${currentBranchRef}/functions/${functionSlug}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Code strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
{functionSlug}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
|
||||
{/* File list sidebar will be shown instead of top tabs */}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 h-96">
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Sidebar file list */}
|
||||
<div className="w-48 border-r bg-surface-200 flex flex-col overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{fileInfos.map((fileInfo) => (
|
||||
<li key={fileInfo.key} className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveFileKey(fileInfo.key)}
|
||||
className={cn(
|
||||
'flex-1 text-left text-xs px-4 py-2 flex items-center gap-2',
|
||||
activeFileKey === fileInfo.key
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'text-foreground-light hover:bg-surface-300'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-1 h-1 rounded-full flex-shrink-0',
|
||||
getStatusColor(fileInfo.status)
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{fileInfo.key}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Diff viewer */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<DiffViewer
|
||||
language={language}
|
||||
original={mainFile?.content || ''}
|
||||
modified={currentFile?.content || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const EdgeFunctionsDiffPanel = ({
|
||||
diffResults,
|
||||
currentBranchRef,
|
||||
mainBranchRef,
|
||||
}: EdgeFunctionsDiffPanelProps) => {
|
||||
if (diffResults.isLoading) {
|
||||
return <Skeleton className="h-64" />
|
||||
}
|
||||
|
||||
if (!diffResults.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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{diffResults.addedSlugs.length > 0 && (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{diffResults.addedSlugs.map((slug) => (
|
||||
<FunctionDiff
|
||||
key={slug}
|
||||
functionSlug={slug}
|
||||
currentBody={diffResults.addedBodiesMap[slug]!}
|
||||
mainBody={EMPTY_ARR}
|
||||
currentBranchRef={currentBranchRef}
|
||||
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffResults.removedSlugs.length > 0 && (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{diffResults.removedSlugs.map((slug) => (
|
||||
<FunctionDiff
|
||||
key={slug}
|
||||
functionSlug={slug}
|
||||
currentBody={EMPTY_ARR}
|
||||
mainBody={diffResults.removedBodiesMap[slug]!}
|
||||
currentBranchRef={mainBranchRef}
|
||||
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffResults.modifiedSlugs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{diffResults.modifiedSlugs.map((slug) => (
|
||||
<FunctionDiff
|
||||
key={slug}
|
||||
functionSlug={slug}
|
||||
currentBody={diffResults.currentBodiesMap[slug]!}
|
||||
mainBody={diffResults.mainBodiesMap[slug]!}
|
||||
currentBranchRef={currentBranchRef}
|
||||
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeFunctionsDiffPanel
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from 'ui'
|
||||
import { GitBranchIcon } from 'lucide-react'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
interface OutOfDateNoticeProps {
|
||||
isBranchOutOfDateMigrations: boolean
|
||||
missingMigrationsCount: number
|
||||
hasMissingFunctions: boolean
|
||||
missingFunctionsCount: number
|
||||
hasOutOfDateFunctions: boolean
|
||||
outOfDateFunctionsCount: number
|
||||
hasEdgeFunctionModifications: boolean
|
||||
modifiedFunctionsCount: number
|
||||
isPushing: boolean
|
||||
onPush: () => void
|
||||
}
|
||||
|
||||
export const OutOfDateNotice = ({
|
||||
isBranchOutOfDateMigrations,
|
||||
missingMigrationsCount,
|
||||
hasMissingFunctions,
|
||||
missingFunctionsCount,
|
||||
hasOutOfDateFunctions,
|
||||
outOfDateFunctionsCount,
|
||||
hasEdgeFunctionModifications,
|
||||
modifiedFunctionsCount,
|
||||
isPushing,
|
||||
onPush,
|
||||
}: OutOfDateNoticeProps) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const hasOutdatedMigrations = isBranchOutOfDateMigrations && missingMigrationsCount > 0
|
||||
|
||||
const getTitle = () => {
|
||||
if (hasOutdatedMigrations && (hasMissingFunctions || hasOutOfDateFunctions)) {
|
||||
return 'Your database schema and edge functions are out of date'
|
||||
} else if (hasOutdatedMigrations) {
|
||||
return 'Your database schema is out of date'
|
||||
} else if (hasMissingFunctions || hasOutOfDateFunctions) {
|
||||
return 'Your functions are out of date'
|
||||
}
|
||||
return 'Branch is out of date'
|
||||
}
|
||||
|
||||
const getDescription = () => {
|
||||
return 'Update this branch to get the latest changes from the production branch.'
|
||||
}
|
||||
|
||||
const handleUpdateClick = () => {
|
||||
onPush()
|
||||
}
|
||||
|
||||
const handleConfirmUpdate = () => {
|
||||
setIsDialogOpen(false)
|
||||
onPush()
|
||||
}
|
||||
|
||||
return (
|
||||
<Admonition type="warning" className="my-4">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{getTitle()}</h3>
|
||||
<p className="text-sm text-foreground-light">{getDescription()}</p>
|
||||
</div>
|
||||
|
||||
{hasEdgeFunctionModifications ? (
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
loading={isPushing}
|
||||
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isPushing ? 'Updating...' : 'Update branch'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update branch with modified functions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This branch has {modifiedFunctionsCount} modified edge function
|
||||
{modifiedFunctionsCount !== 1 ? 's' : ''} that will be overwritten when updating
|
||||
with the latest functions from the production branch. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmUpdate}>Update anyway</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
loading={isPushing}
|
||||
onClick={handleUpdateClick}
|
||||
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isPushing ? 'Updating...' : 'Update branch'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { CircleDotDashed, GitMerge, X } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
|
||||
|
||||
interface WorkflowRun {
|
||||
id: string
|
||||
status: string
|
||||
branch_id?: string
|
||||
check_run_id?: number | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
workdir?: string | null
|
||||
git_config?: unknown
|
||||
}
|
||||
|
||||
interface WorkflowLogsCardProps {
|
||||
workflowRun: WorkflowRun | null | undefined
|
||||
logs: string | undefined
|
||||
isLoading?: boolean
|
||||
onClose?: () => void
|
||||
// Override props for failed workflows
|
||||
overrideTitle?: string
|
||||
overrideDescription?: string
|
||||
overrideIcon?: React.ReactNode
|
||||
overrideAction?: React.ReactNode
|
||||
}
|
||||
|
||||
const WorkflowLogsCard = ({
|
||||
workflowRun,
|
||||
logs,
|
||||
isLoading = false,
|
||||
onClose,
|
||||
overrideTitle,
|
||||
overrideDescription,
|
||||
overrideIcon,
|
||||
overrideAction,
|
||||
}: WorkflowLogsCardProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom when logs change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && logs) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
const showSuccessIcon = workflowRun?.status === 'FUNCTIONS_DEPLOYED'
|
||||
const isFailed =
|
||||
workflowRun?.status && ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status)
|
||||
const isPolling =
|
||||
workflowRun?.status !== 'FUNCTIONS_DEPLOYED' &&
|
||||
(!workflowRun?.status ||
|
||||
!['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status))
|
||||
|
||||
const displayTitle =
|
||||
overrideTitle ||
|
||||
(isPolling
|
||||
? 'Processing...'
|
||||
: showSuccessIcon
|
||||
? 'Workflow completed successfully'
|
||||
: isFailed
|
||||
? 'Workflow failed'
|
||||
: 'Workflow completed')
|
||||
|
||||
const displayIcon =
|
||||
overrideIcon ||
|
||||
(isPolling ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<CircleDotDashed size={16} strokeWidth={1.5} className="text-warning" />
|
||||
</motion.div>
|
||||
) : showSuccessIcon ? (
|
||||
<GitMerge size={16} strokeWidth={1.5} className="text-brand" />
|
||||
) : null)
|
||||
|
||||
return (
|
||||
<Card className="bg-background overflow-hidden h-64 flex flex-col">
|
||||
<CardHeader className={showSuccessIcon ? 'text-brand' : isFailed ? 'text-destructive' : ''}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{displayIcon}
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">{displayTitle}</CardTitle>
|
||||
{overrideDescription && (
|
||||
<div className="text-sm text-foreground-light font-normal mt-0">
|
||||
{overrideDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{overrideAction}
|
||||
{onClose && (
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<X size={12} strokeWidth={1.5} />}
|
||||
onClick={onClose}
|
||||
className="h-5 w-5 p-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
ref={scrollRef}
|
||||
className="overflow-hidden border-0 overflow-y-auto relative p-0"
|
||||
>
|
||||
{/* sticky gradient overlay */}
|
||||
<div className="sticky top-0 -mb-8 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none z-10" />
|
||||
{logs ? (
|
||||
<pre className="p-6 text-xs text-foreground-light p-0 rounded">{logs}</pre>
|
||||
) : (
|
||||
<pre className="p-6 text-sm text-foreground-light rounded">
|
||||
{isLoading || isPolling ? 'Initializing workflow...' : 'Waiting for logs...'}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowLogsCard
|
||||
@@ -108,7 +108,7 @@ const SidePanelGitHubRepoLinker = ({ projectRef }: SidePanelGitHubRepoLinkerProp
|
||||
)
|
||||
|
||||
const { data: existingBranches } = useBranchesQuery(
|
||||
{ projectRef: selectedProject?.ref },
|
||||
{ projectRef: selectedProject?.parent_project_ref || selectedProject?.ref },
|
||||
{ enabled: !!selectedProject?.ref }
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
ListTree,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { sanitizeRoute } from './ProjectDropdown'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
|
||||
const BranchLink = ({
|
||||
branch,
|
||||
@@ -75,13 +76,18 @@ const BranchLink = ({
|
||||
export const BranchDropdown = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const gitlessBranching = useFlag('gitlessBranching')
|
||||
const projectDetails = useSelectedProject()
|
||||
const snap = useAppStateSnapshot()
|
||||
|
||||
const isBranch = projectDetails?.parent_project_ref !== undefined
|
||||
const projectRef =
|
||||
projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
|
||||
const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef })
|
||||
const projectRef = projectDetails?.parent_project_ref || ref
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) })
|
||||
|
||||
const isBranchingEnabled = projectDetails?.is_branch_enabled === true
|
||||
const selectedBranch = branches?.find((branch) => branch.project_ref === ref)
|
||||
@@ -153,6 +159,26 @@ export const BranchDropdown = () => {
|
||||
<p>Create branch</p>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
{gitlessBranching &&
|
||||
isBranchingEnabled &&
|
||||
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_>
|
||||
</>
|
||||
)}
|
||||
<CommandItem_Shadcn_
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={() => {
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
import { ScaffoldDescription, ScaffoldTitle } from '../Scaffold'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
title?: string | ReactNode
|
||||
subtitle?: string | ReactNode
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
label?: string
|
||||
|
||||
@@ -14,11 +14,12 @@ export interface NavigationItem {
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
badge?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
interface PageLayoutProps {
|
||||
children?: ReactNode
|
||||
title?: string
|
||||
title?: string | ReactNode
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
@@ -80,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
|
||||
)}
|
||||
@@ -102,38 +103,42 @@ export const PageLayout = ({
|
||||
{/* Navigation section */}
|
||||
{navigationItems.length > 0 && (
|
||||
<NavMenu className={cn(isCompact ? 'mt-2' : 'mt-4', size === 'full' && 'border-none')}>
|
||||
{navigationItems.map((item) => (
|
||||
<NavMenuItem key={item.label} active={router.asPath.split('?')[0] === item.href}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={
|
||||
item.href.includes('[ref]') && !!ref
|
||||
? item.href.replace('[ref]', ref)
|
||||
: item.href
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
router.asPath === item.href && 'text-foreground'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
{item.label}
|
||||
{item.badge && <Badge variant="default">{item.badge}</Badge>}
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={item.onClick}
|
||||
className={cn(router.pathname === item.href && 'text-foreground font-medium')}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.label}
|
||||
{item.badge && <Badge variant="default">{item.badge}</Badge>}
|
||||
</Button>
|
||||
)}
|
||||
</NavMenuItem>
|
||||
))}
|
||||
{navigationItems.map((item) => {
|
||||
const isActive =
|
||||
item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
|
||||
return (
|
||||
<NavMenuItem key={item.label} active={isActive}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={
|
||||
item.href.includes('[ref]') && !!ref
|
||||
? item.href.replace('[ref]', ref)
|
||||
: item.href
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
isActive && 'text-foreground'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
{item.label}
|
||||
{item.badge && <Badge variant="default">{item.badge}</Badge>}
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={item.onClick}
|
||||
className={cn(isActive && 'text-foreground font-medium')}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.label}
|
||||
{item.badge && <Badge variant="default">{item.badge}</Badge>}
|
||||
</Button>
|
||||
)}
|
||||
</NavMenuItem>
|
||||
)
|
||||
})}
|
||||
</NavMenu>
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
|
||||
48
apps/studio/components/ui/DiffViewer.tsx
Normal file
48
apps/studio/components/ui/DiffViewer.tsx
Normal 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
|
||||
56
apps/studio/data/branches/branch-diff-query.ts
Normal file
56
apps/studio/data/branches/branch-diff-query.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { branchKeys } from './keys'
|
||||
|
||||
export type BranchDiffVariables = {
|
||||
branchId: string
|
||||
projectRef: string
|
||||
includedSchemas?: string
|
||||
}
|
||||
|
||||
export async function getBranchDiff({
|
||||
branchId,
|
||||
includedSchemas,
|
||||
}: Pick<BranchDiffVariables, 'branchId' | 'includedSchemas'>) {
|
||||
const { data: diffData, error } = await get('/v1/branches/{branch_id}/diff', {
|
||||
params: {
|
||||
path: { branch_id: branchId },
|
||||
query: includedSchemas ? { included_schemas: includedSchemas } : undefined,
|
||||
},
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
parseAs: 'text',
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
// Handle empty object responses (when no diff exists)
|
||||
if (typeof diffData === 'object' && Object.keys(diffData).length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return diffData || ''
|
||||
}
|
||||
|
||||
type BranchDiffData = Awaited<ReturnType<typeof getBranchDiff>>
|
||||
|
||||
export const useBranchDiffQuery = (
|
||||
{ branchId, projectRef, includedSchemas = 'public' }: BranchDiffVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: Omit<UseQueryOptions<BranchDiffData, ResponseError>, 'queryKey' | 'queryFn'> = {}
|
||||
) =>
|
||||
useQuery<BranchDiffData, ResponseError>(
|
||||
branchKeys.diff(projectRef, branchId),
|
||||
() => getBranchDiff({ branchId, includedSchemas }),
|
||||
{
|
||||
enabled: enabled && typeof branchId !== 'undefined' && branchId !== '',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
92
apps/studio/data/branches/branch-merge-mutation.ts
Normal file
92
apps/studio/data/branches/branch-merge-mutation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { branchKeys } from './keys'
|
||||
import { getBranchDiff } from './branch-diff-query'
|
||||
import { upsertMigration } from '../database/migration-upsert-mutation'
|
||||
|
||||
export type BranchMergeVariables = {
|
||||
id: string
|
||||
branchProjectRef: string
|
||||
baseProjectRef: string
|
||||
migration_version?: string
|
||||
}
|
||||
|
||||
export async function mergeBranch({
|
||||
id,
|
||||
branchProjectRef,
|
||||
baseProjectRef,
|
||||
migration_version,
|
||||
}: BranchMergeVariables) {
|
||||
// Step 1: Get the diff output from the branch
|
||||
const diffContent = await getBranchDiff({ branchId: id, includedSchemas: 'public' })
|
||||
|
||||
let migrationCreated = false
|
||||
|
||||
// Step 2: If there are changes, create a migration before merging
|
||||
if (diffContent && diffContent.trim() !== '') {
|
||||
// Generate a descriptive migration name based on current timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
const migrationName = `branch_merge_${timestamp}`
|
||||
|
||||
await upsertMigration({
|
||||
projectRef: branchProjectRef,
|
||||
query: diffContent,
|
||||
name: migrationName,
|
||||
})
|
||||
|
||||
migrationCreated = true
|
||||
}
|
||||
|
||||
// Step 3: Call POST /v1/branches/id/merge to merge the branch
|
||||
const { data, error } = await post('/v1/branches/{branch_id}/merge', {
|
||||
params: { path: { branch_id: id } },
|
||||
body: { migration_version },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
migrationCreated,
|
||||
hadChanges: diffContent && diffContent.trim() !== '',
|
||||
workflowRunId: data?.workflow_run_id,
|
||||
}
|
||||
}
|
||||
|
||||
type BranchMergeData = Awaited<ReturnType<typeof mergeBranch>>
|
||||
|
||||
export const useBranchMergeMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<BranchMergeData, ResponseError, BranchMergeVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<BranchMergeData, ResponseError, BranchMergeVariables>(
|
||||
(vars) => mergeBranch(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { baseProjectRef } = variables
|
||||
await queryClient.invalidateQueries(branchKeys.list(baseProjectRef))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
let errorMessage = data.message || 'Unknown error occurred'
|
||||
|
||||
toast.error(`Failed to merge branch: ${errorMessage}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
52
apps/studio/data/branches/branch-push-mutation.ts
Normal file
52
apps/studio/data/branches/branch-push-mutation.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { branchKeys } from './keys'
|
||||
|
||||
export type BranchPushVariables = {
|
||||
id: string
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
export async function pushBranch({ id }: Pick<BranchPushVariables, 'id'>) {
|
||||
const { data, error } = await post('/v1/branches/{branch_id}/push', {
|
||||
params: { path: { branch_id: id } },
|
||||
body: {},
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type BranchPushData = Awaited<ReturnType<typeof pushBranch>>
|
||||
|
||||
export const useBranchPushMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<BranchPushData, ResponseError, BranchPushVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<BranchPushData, ResponseError, BranchPushVariables>(
|
||||
(vars) => pushBranch(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(branchKeys.list(projectRef))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to push branch: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2,4 +2,6 @@ export const branchKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const,
|
||||
detail: (projectRef: string | undefined, id: string | undefined) =>
|
||||
['projects', projectRef, 'branches', id] as const,
|
||||
diff: (projectRef: string | undefined, branchId: string | undefined) =>
|
||||
['projects', projectRef, 'branch', branchId, 'diff'] as const,
|
||||
}
|
||||
|
||||
70
apps/studio/data/database/migration-upsert-mutation.ts
Normal file
70
apps/studio/data/database/migration-upsert-mutation.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, put } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
export type MigrationUpsertVariables = {
|
||||
projectRef: string
|
||||
query: string
|
||||
name?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export async function upsertMigration({
|
||||
projectRef,
|
||||
query,
|
||||
name,
|
||||
idempotencyKey,
|
||||
}: MigrationUpsertVariables) {
|
||||
const headers: Record<string, string> = {}
|
||||
if (idempotencyKey) {
|
||||
headers['Idempotency-Key'] = idempotencyKey
|
||||
}
|
||||
|
||||
const body: { query: string; name?: string } = { query }
|
||||
if (name) {
|
||||
body.name = name
|
||||
}
|
||||
|
||||
const { data, error } = await put('/v1/projects/{ref}/database/migrations', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type MigrationUpsertData = Awaited<ReturnType<typeof upsertMigration>>
|
||||
|
||||
export const useMigrationUpsertMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<MigrationUpsertData, ResponseError, MigrationUpsertVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<MigrationUpsertData, ResponseError, MigrationUpsertVariables>(
|
||||
(vars) => upsertMigration(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(databaseKeys.migrations(projectRef))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to upsert migration: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -54,10 +54,8 @@ export async function getEdgeFunctionBody(
|
||||
const { files } = await parseResponse.json()
|
||||
return files as EdgeFunctionFile[]
|
||||
} catch (error) {
|
||||
console.error('Failed to parse edge function code:', error)
|
||||
throw new Error(
|
||||
'Failed to parse function code. The file may be corrupted or in an invalid format.'
|
||||
)
|
||||
handleError(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const workflowRunKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'workflow-runs'] as const,
|
||||
detail: (projectRef: string | undefined, workflowRunId: string | undefined) =>
|
||||
['projects', projectRef, 'workflow-run', workflowRunId] as const,
|
||||
}
|
||||
|
||||
52
apps/studio/data/workflow-runs/workflow-run-query.ts
Normal file
52
apps/studio/data/workflow-runs/workflow-run-query.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { workflowRunKeys } from './keys'
|
||||
|
||||
export type WorkflowRunVariables = {
|
||||
projectRef?: string
|
||||
workflowRunId?: string
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(
|
||||
{ workflowRunId }: WorkflowRunVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ logs: string; workflowRunId: string }> {
|
||||
if (!workflowRunId) throw new Error('workflowRunId is required')
|
||||
|
||||
// Use the logs endpoint which fetches workflow run status
|
||||
const { data, error } = await get('/platform/workflow-runs/{workflow_run_id}/logs', {
|
||||
params: {
|
||||
path: {
|
||||
workflow_run_id: workflowRunId,
|
||||
},
|
||||
},
|
||||
parseAs: 'text',
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
// Return an object with the logs and we'll extract status from headers if needed
|
||||
return { logs: data as string, workflowRunId }
|
||||
}
|
||||
|
||||
export type WorkflowRunData = { logs: string; workflowRunId: string }
|
||||
export type WorkflowRunError = ResponseError
|
||||
|
||||
export const useWorkflowRunQuery = <TData = WorkflowRunData>(
|
||||
{ projectRef, workflowRunId }: WorkflowRunVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<WorkflowRunData, WorkflowRunError, TData> = {}
|
||||
) =>
|
||||
useQuery<WorkflowRunData, WorkflowRunError, TData>(
|
||||
workflowRunKeys.detail(projectRef, workflowRunId),
|
||||
({ signal }) => getWorkflowRun({ workflowRunId }, signal),
|
||||
{
|
||||
enabled: enabled && typeof workflowRunId !== 'undefined',
|
||||
staleTime: 0,
|
||||
...options,
|
||||
}
|
||||
)
|
||||
240
apps/studio/hooks/branches/useBranchMergeDiff.ts
Normal file
240
apps/studio/hooks/branches/useBranchMergeDiff.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useBranchDiffQuery } from 'data/branches/branch-diff-query'
|
||||
import { useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { useEdgeFunctionsDiff, type EdgeFunctionsDiffResult } from './useEdgeFunctionsDiff'
|
||||
|
||||
interface UseBranchMergeDiffProps {
|
||||
branchId?: string
|
||||
currentBranchRef?: string
|
||||
parentProjectRef?: string
|
||||
currentBranchConnectionString?: string
|
||||
parentBranchConnectionString?: string
|
||||
currentBranchCreatedAt?: string
|
||||
}
|
||||
|
||||
export interface BranchMergeDiffResult {
|
||||
// Database diff
|
||||
diffContent: string | undefined
|
||||
isDatabaseDiffLoading: boolean
|
||||
isDatabaseDiffRefetching: boolean
|
||||
databaseDiffError: any
|
||||
refetchDatabaseDiff: () => void
|
||||
|
||||
// Edge functions diff
|
||||
edgeFunctionsDiff: EdgeFunctionsDiffResult
|
||||
|
||||
// Migrations
|
||||
currentBranchMigrations: any[] | undefined
|
||||
mainBranchMigrations: any[] | undefined
|
||||
refetchCurrentBranchMigrations: () => void
|
||||
refetchMainBranchMigrations: () => void
|
||||
|
||||
// Branch state
|
||||
isBranchOutOfDateMigrations: boolean
|
||||
hasEdgeFunctionModifications: boolean
|
||||
missingFunctionsCount: number
|
||||
hasMissingFunctions: boolean
|
||||
outOfDateFunctionsCount: number
|
||||
hasOutOfDateFunctions: boolean
|
||||
isBranchOutOfDateOverall: boolean
|
||||
missingMigrationsCount: number
|
||||
modifiedFunctionsCount: number
|
||||
|
||||
// Combined states
|
||||
isLoading: boolean
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
export const useBranchMergeDiff = ({
|
||||
branchId,
|
||||
currentBranchRef,
|
||||
parentProjectRef,
|
||||
currentBranchConnectionString,
|
||||
parentBranchConnectionString,
|
||||
currentBranchCreatedAt,
|
||||
}: UseBranchMergeDiffProps): BranchMergeDiffResult => {
|
||||
// Get database diff
|
||||
const {
|
||||
data: diffContent,
|
||||
isLoading: isDatabaseDiffLoading,
|
||||
isRefetching: isDatabaseDiffRefetching,
|
||||
error: databaseDiffError,
|
||||
refetch: refetchDatabaseDiff,
|
||||
} = useBranchDiffQuery(
|
||||
{
|
||||
branchId: branchId || '',
|
||||
projectRef: parentProjectRef || '',
|
||||
},
|
||||
{
|
||||
enabled: !!branchId && !!parentProjectRef,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
|
||||
// Get migrations for both current branch and main branch
|
||||
const { data: currentBranchMigrations, refetch: refetchCurrentBranchMigrations } =
|
||||
useMigrationsQuery(
|
||||
{
|
||||
projectRef: currentBranchRef,
|
||||
connectionString: currentBranchConnectionString,
|
||||
},
|
||||
{
|
||||
enabled: !!currentBranchRef,
|
||||
staleTime: 3000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: mainBranchMigrations, refetch: refetchMainBranchMigrations } = useMigrationsQuery(
|
||||
{
|
||||
projectRef: parentProjectRef,
|
||||
connectionString: parentBranchConnectionString,
|
||||
},
|
||||
{
|
||||
enabled: !!parentProjectRef,
|
||||
staleTime: 3000,
|
||||
}
|
||||
)
|
||||
|
||||
// Get edge functions diff
|
||||
const edgeFunctionsDiff = useEdgeFunctionsDiff({
|
||||
currentBranchRef,
|
||||
mainBranchRef: parentProjectRef,
|
||||
})
|
||||
|
||||
// Check if current branch is out of date with main branch (migrations)
|
||||
const isBranchOutOfDateMigrations = useMemo(() => {
|
||||
if (!currentBranchMigrations || !mainBranchMigrations) return false
|
||||
|
||||
// Get the latest migration version from main branch
|
||||
const latestMainMigration = mainBranchMigrations[0] // migrations are ordered by version desc
|
||||
if (!latestMainMigration) return false
|
||||
|
||||
// Check if current branch has this latest migration
|
||||
const hasLatestMigration = currentBranchMigrations.some(
|
||||
(migration) => migration.version === latestMainMigration.version
|
||||
)
|
||||
|
||||
return !hasLatestMigration
|
||||
}, [currentBranchMigrations, mainBranchMigrations])
|
||||
|
||||
// Check if main branch has functions that are newer than the current branch versions
|
||||
const outOfDateFunctionsCount = useMemo(() => {
|
||||
if (!edgeFunctionsDiff.modifiedSlugs.length) return 0
|
||||
|
||||
return edgeFunctionsDiff.modifiedSlugs.filter((slug) => {
|
||||
// Get both main and current branch function data
|
||||
const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug)
|
||||
const currentFunction = edgeFunctionsDiff.currentBranchFunctions?.find(
|
||||
(func) => func.slug === slug
|
||||
)
|
||||
|
||||
if (!mainFunction || !currentFunction) return false
|
||||
|
||||
// Compare updated_at timestamps - if main branch function is newer, it was modified after current branch
|
||||
const mainUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds
|
||||
const currentUpdatedAt = currentFunction.updated_at * 1000 // Convert to milliseconds
|
||||
|
||||
return mainUpdatedAt > currentUpdatedAt
|
||||
}).length
|
||||
}, [
|
||||
edgeFunctionsDiff.modifiedSlugs,
|
||||
edgeFunctionsDiff.mainBranchFunctions,
|
||||
edgeFunctionsDiff.currentBranchFunctions,
|
||||
])
|
||||
|
||||
// Check if main branch has functions that are newer than the current branch versions
|
||||
const hasOutOfDateFunctions = outOfDateFunctionsCount > 0
|
||||
|
||||
// Check if current branch has any edge function modifications (not additions/removals)
|
||||
// This only includes functions where the current branch version is newer than the main branch version
|
||||
const hasEdgeFunctionModifications =
|
||||
edgeFunctionsDiff.modifiedSlugs.length > outOfDateFunctionsCount
|
||||
|
||||
// Count of removed functions that were updated on main branch after this branch was created
|
||||
// Note: For removed functions, we use branch creation date since there's no current branch version to compare to
|
||||
const missingFunctionsCount = useMemo(() => {
|
||||
if (!currentBranchCreatedAt || !edgeFunctionsDiff.removedSlugs.length) return 0
|
||||
|
||||
const branchCreatedAt = new Date(currentBranchCreatedAt).getTime()
|
||||
|
||||
return edgeFunctionsDiff.removedSlugs.filter((slug) => {
|
||||
// Access main branch function data from the original functions list
|
||||
const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug)
|
||||
if (!mainFunction) return false
|
||||
|
||||
// Check if function was updated after branch creation
|
||||
const functionUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds
|
||||
return functionUpdatedAt > branchCreatedAt
|
||||
}).length
|
||||
}, [
|
||||
currentBranchCreatedAt,
|
||||
edgeFunctionsDiff.removedSlugs,
|
||||
edgeFunctionsDiff.mainBranchFunctions,
|
||||
])
|
||||
|
||||
// Check if main branch has functions removed from current branch that were updated after branch creation
|
||||
const hasMissingFunctions = missingFunctionsCount > 0
|
||||
|
||||
// Update overall out-of-date check to include newer removed functions and newer modified functions
|
||||
const isBranchOutOfDateOverall =
|
||||
isBranchOutOfDateMigrations || hasMissingFunctions || hasOutOfDateFunctions
|
||||
|
||||
// Get the count of migrations that the branch is missing
|
||||
const missingMigrationsCount = useMemo(() => {
|
||||
if (!currentBranchMigrations || !mainBranchMigrations || !isBranchOutOfDateMigrations) return 0
|
||||
|
||||
const currentVersions = new Set(currentBranchMigrations.map((m) => m.version))
|
||||
return mainBranchMigrations.filter((m) => !currentVersions.has(m.version)).length
|
||||
}, [currentBranchMigrations, mainBranchMigrations, isBranchOutOfDateMigrations])
|
||||
|
||||
// Get count of modified functions
|
||||
const modifiedFunctionsCount = edgeFunctionsDiff.modifiedSlugs.length
|
||||
|
||||
// Check if there are any changes (database or edge functions)
|
||||
const hasChanges = useMemo(() => {
|
||||
// Check database changes
|
||||
const hasDatabaseChanges = diffContent && diffContent.trim() !== ''
|
||||
|
||||
// Check edge function changes
|
||||
const hasEdgeFunctionChanges = edgeFunctionsDiff.hasChanges
|
||||
|
||||
return hasDatabaseChanges || hasEdgeFunctionChanges
|
||||
}, [diffContent, edgeFunctionsDiff.hasChanges])
|
||||
|
||||
const isLoading = isDatabaseDiffLoading || edgeFunctionsDiff.isLoading
|
||||
|
||||
return {
|
||||
// Database diff
|
||||
diffContent,
|
||||
isDatabaseDiffLoading,
|
||||
isDatabaseDiffRefetching,
|
||||
databaseDiffError,
|
||||
refetchDatabaseDiff,
|
||||
|
||||
// Edge functions diff
|
||||
edgeFunctionsDiff,
|
||||
|
||||
// Migrations
|
||||
currentBranchMigrations,
|
||||
mainBranchMigrations,
|
||||
refetchCurrentBranchMigrations,
|
||||
refetchMainBranchMigrations,
|
||||
|
||||
// Branch state
|
||||
isBranchOutOfDateMigrations,
|
||||
hasEdgeFunctionModifications,
|
||||
missingFunctionsCount,
|
||||
hasMissingFunctions,
|
||||
outOfDateFunctionsCount,
|
||||
hasOutOfDateFunctions,
|
||||
isBranchOutOfDateOverall,
|
||||
missingMigrationsCount,
|
||||
modifiedFunctionsCount,
|
||||
|
||||
// Combined states
|
||||
isLoading,
|
||||
hasChanges,
|
||||
}
|
||||
}
|
||||
291
apps/studio/hooks/branches/useEdgeFunctionsDiff.ts
Normal file
291
apps/studio/hooks/branches/useEdgeFunctionsDiff.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
||||
import { handleError } from 'data/fetchers'
|
||||
import {
|
||||
getEdgeFunctionBody,
|
||||
type EdgeFunctionBodyData,
|
||||
} from 'data/edge-functions/edge-function-body-query'
|
||||
import {
|
||||
useEdgeFunctionsQuery,
|
||||
type EdgeFunctionsData,
|
||||
} from 'data/edge-functions/edge-functions-query'
|
||||
import { basename } from 'path'
|
||||
import { edgeFunctionsKeys } from 'data/edge-functions/keys'
|
||||
|
||||
interface UseEdgeFunctionsDiffProps {
|
||||
currentBranchRef?: string
|
||||
mainBranchRef?: string
|
||||
}
|
||||
|
||||
export type FileStatus = 'added' | 'removed' | 'modified' | 'unchanged'
|
||||
|
||||
export interface FileInfo {
|
||||
key: string
|
||||
status: FileStatus
|
||||
}
|
||||
|
||||
export interface FunctionFileInfo {
|
||||
[functionSlug: string]: FileInfo[]
|
||||
}
|
||||
|
||||
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>
|
||||
functionFileInfo: FunctionFileInfo
|
||||
isLoading: boolean
|
||||
hasChanges: boolean
|
||||
refetchCurrentBranchFunctions: () => void
|
||||
refetchMainBranchFunctions: () => void
|
||||
currentBranchFunctions?: EdgeFunctionsData
|
||||
mainBranchFunctions?: EdgeFunctionsData
|
||||
clearDiffsOptimistically: () => void
|
||||
}
|
||||
|
||||
// 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 = ({
|
||||
currentBranchRef,
|
||||
mainBranchRef,
|
||||
}: UseEdgeFunctionsDiffProps): EdgeFunctionsDiffResult => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch edge functions for both branches
|
||||
const {
|
||||
data: currentBranchFunctions,
|
||||
isLoading: isCurrentFunctionsLoading,
|
||||
refetch: refetchCurrentBranchFunctions,
|
||||
} = useEdgeFunctionsQuery(
|
||||
{ projectRef: currentBranchRef },
|
||||
{
|
||||
enabled: !!currentBranchRef,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 30000, // 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: mainBranchFunctions,
|
||||
isLoading: isMainFunctionsLoading,
|
||||
refetch: refetchMainBranchFunctions,
|
||||
} = useEdgeFunctionsQuery(
|
||||
{ projectRef: mainBranchRef },
|
||||
{
|
||||
enabled: !!mainBranchRef,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 30000, // 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
// 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,
|
||||
refetchOnMount: 'always' as const,
|
||||
})),
|
||||
})
|
||||
|
||||
const mainBodiesQueries = useQueries({
|
||||
queries: overlapSlugs.map((slug) => ({
|
||||
queryKey: ['edge-function-body', mainBranchRef, slug],
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
|
||||
enabled: !!mainBranchRef,
|
||||
refetchOnMount: 'always' as const,
|
||||
})),
|
||||
})
|
||||
|
||||
const addedBodiesQueries = useQueries({
|
||||
queries: addedSlugs.map((slug) => ({
|
||||
queryKey: ['edge-function-body', currentBranchRef, slug],
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal),
|
||||
enabled: !!currentBranchRef,
|
||||
refetchOnMount: 'always' as const,
|
||||
})),
|
||||
})
|
||||
|
||||
const removedBodiesQueries = useQueries({
|
||||
queries: removedSlugs.map((slug) => ({
|
||||
queryKey: ['edge-function-body', mainBranchRef, slug],
|
||||
queryFn: ({ signal }: { signal?: AbortSignal }) =>
|
||||
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
|
||||
enabled: !!mainBranchRef,
|
||||
refetchOnMount: 'always' as const,
|
||||
})),
|
||||
})
|
||||
|
||||
// Flatten loading flags ----------------------------------------------------
|
||||
const isLoading =
|
||||
[
|
||||
...currentBodiesQueries,
|
||||
...mainBodiesQueries,
|
||||
...addedBodiesQueries,
|
||||
...removedBodiesQueries,
|
||||
].some((q) => q.isLoading) ||
|
||||
isCurrentFunctionsLoading ||
|
||||
isMainFunctionsLoading
|
||||
|
||||
// Aggregate errors across all queries and handle the first encountered error.
|
||||
const firstError = [
|
||||
...currentBodiesQueries,
|
||||
...mainBodiesQueries,
|
||||
...addedBodiesQueries,
|
||||
...removedBodiesQueries,
|
||||
].find((q) => q.error)?.error
|
||||
|
||||
if (firstError) {
|
||||
handleError(firstError)
|
||||
}
|
||||
|
||||
// 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 and build file info -----------------------------
|
||||
const modifiedSlugs: string[] = []
|
||||
const functionFileInfo: FunctionFileInfo = {}
|
||||
|
||||
// Process overlapping functions to determine modifications and file info
|
||||
overlapSlugs.forEach((slug) => {
|
||||
const currentBody = currentBodiesMap[slug]
|
||||
const mainBody = mainBodiesMap[slug]
|
||||
if (!currentBody || !mainBody) return
|
||||
|
||||
const allFileKeys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name)))
|
||||
const fileInfos: FileInfo[] = []
|
||||
let hasModifications = false
|
||||
|
||||
for (const key of allFileKeys) {
|
||||
const currentFile = currentBody.find((f) => fileKey(f.name) === key)
|
||||
const mainFile = mainBody.find((f) => fileKey(f.name) === key)
|
||||
|
||||
let status: FileStatus = 'unchanged'
|
||||
|
||||
if (!currentFile && mainFile) {
|
||||
status = 'removed'
|
||||
hasModifications = true
|
||||
} else if (currentFile && !mainFile) {
|
||||
status = 'added'
|
||||
hasModifications = true
|
||||
} else if (currentFile && mainFile && currentFile.content !== mainFile.content) {
|
||||
status = 'modified'
|
||||
hasModifications = true
|
||||
}
|
||||
|
||||
fileInfos.push({ key, status })
|
||||
}
|
||||
|
||||
if (hasModifications) {
|
||||
modifiedSlugs.push(slug)
|
||||
}
|
||||
|
||||
functionFileInfo[slug] = fileInfos
|
||||
})
|
||||
|
||||
// Add file info for added functions
|
||||
addedSlugs.forEach((slug) => {
|
||||
const body = addedBodiesMap[slug]
|
||||
if (body) {
|
||||
functionFileInfo[slug] = body.map((file) => ({
|
||||
key: fileKey(file.name),
|
||||
status: 'added' as FileStatus,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Add file info for removed functions
|
||||
removedSlugs.forEach((slug) => {
|
||||
const body = removedBodiesMap[slug]
|
||||
if (body) {
|
||||
functionFileInfo[slug] = body.map((file) => ({
|
||||
key: fileKey(file.name),
|
||||
status: 'removed' as FileStatus,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const hasChanges = addedSlugs.length > 0 || removedSlugs.length > 0 || modifiedSlugs.length > 0
|
||||
|
||||
const clearDiffsOptimistically = useCallback(() => {
|
||||
if (!currentBranchRef || !mainBranchFunctions) return
|
||||
|
||||
queryClient.setQueryData(edgeFunctionsKeys.list(currentBranchRef), mainBranchFunctions)
|
||||
|
||||
mainBranchFunctions.forEach((func) => {
|
||||
const mainBody = mainBodiesMap[func.slug]
|
||||
if (mainBody) {
|
||||
queryClient.setQueryData(['edge-function-body', currentBranchRef, func.slug], mainBody)
|
||||
}
|
||||
})
|
||||
}, [currentBranchRef, mainBranchFunctions, mainBodiesMap, queryClient])
|
||||
|
||||
return {
|
||||
addedSlugs,
|
||||
removedSlugs,
|
||||
modifiedSlugs,
|
||||
addedBodiesMap,
|
||||
removedBodiesMap,
|
||||
currentBodiesMap,
|
||||
mainBodiesMap,
|
||||
functionFileInfo,
|
||||
isLoading,
|
||||
hasChanges,
|
||||
refetchCurrentBranchFunctions,
|
||||
refetchMainBranchFunctions,
|
||||
currentBranchFunctions,
|
||||
mainBranchFunctions,
|
||||
clearDiffsOptimistically,
|
||||
}
|
||||
}
|
||||
|
||||
export default useEdgeFunctionsDiff
|
||||
66
apps/studio/hooks/branches/useWorkflowManagement.ts
Normal file
66
apps/studio/hooks/branches/useWorkflowManagement.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query'
|
||||
import { useWorkflowRunQuery } from 'data/workflow-runs/workflow-run-query'
|
||||
|
||||
interface UseWorkflowManagementProps {
|
||||
workflowRunId?: string
|
||||
projectRef?: string
|
||||
onWorkflowComplete?: (status: string) => void
|
||||
}
|
||||
|
||||
export const useWorkflowManagement = ({
|
||||
workflowRunId,
|
||||
projectRef,
|
||||
onWorkflowComplete,
|
||||
}: UseWorkflowManagementProps) => {
|
||||
const [hasRefetched, setHasRefetched] = useState(false)
|
||||
|
||||
// Get workflow runs and logs
|
||||
const { data: workflowRuns } = useWorkflowRunsQuery(
|
||||
{ projectRef },
|
||||
{
|
||||
enabled: Boolean(workflowRunId),
|
||||
refetchInterval: 3000,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: workflowRunLogs } = useWorkflowRunQuery(
|
||||
{ projectRef, workflowRunId },
|
||||
{
|
||||
refetchInterval: 2000,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
|
||||
// Find current workflow run
|
||||
const currentWorkflowRun = workflowRuns?.find((run) => run.id === workflowRunId)
|
||||
|
||||
// Handle workflow completion
|
||||
useEffect(() => {
|
||||
if (!currentWorkflowRun?.status || !workflowRunId) return
|
||||
|
||||
const isComplete = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(
|
||||
currentWorkflowRun.status
|
||||
)
|
||||
|
||||
// Only refetch once per workflow completion
|
||||
if (isComplete && !hasRefetched) {
|
||||
setHasRefetched(true)
|
||||
onWorkflowComplete?.(currentWorkflowRun.status)
|
||||
}
|
||||
}, [currentWorkflowRun?.status, workflowRunId, hasRefetched, onWorkflowComplete])
|
||||
|
||||
// Reset refetch flag when workflow ID changes
|
||||
useEffect(() => {
|
||||
setHasRefetched(false)
|
||||
}, [workflowRunId])
|
||||
|
||||
return {
|
||||
currentWorkflowRun,
|
||||
workflowRunLogs: workflowRunLogs?.logs,
|
||||
}
|
||||
}
|
||||
471
apps/studio/pages/project/[ref]/merge.tsx
Normal file
471
apps/studio/pages/project/[ref]/merge.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { useBranchesQuery } from 'data/branches/branches-query'
|
||||
import { useBranchMergeMutation } from 'data/branches/branch-merge-mutation'
|
||||
import { useBranchPushMutation } from 'data/branches/branch-push-mutation'
|
||||
import { useBranchMergeDiff } from 'hooks/branches/useBranchMergeDiff'
|
||||
import { useWorkflowManagement } from 'hooks/branches/useWorkflowManagement'
|
||||
import DatabaseDiffPanel from 'components/interfaces/BranchManagement/DatabaseDiffPanel'
|
||||
import EdgeFunctionsDiffPanel from 'components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
|
||||
import { OutOfDateNotice } from 'components/interfaces/BranchManagement/OutOfDateNotice'
|
||||
import { Badge, Button, NavMenu, NavMenuItem, cn, Alert } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { toast } from 'sonner'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import { GitBranchIcon, GitMerge, Shield, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
|
||||
const MergePage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const gitlessBranching = useFlag('gitlessBranching')
|
||||
|
||||
const project = useSelectedProject()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [workflowFinalStatus, setWorkflowFinalStatus] = useState<string | null>(null)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
const isBranch = project?.parent_project_ref !== undefined
|
||||
const parentProjectRef = project?.parent_project_ref
|
||||
|
||||
const parentProject = useProjectByRef(parentProjectRef)
|
||||
|
||||
const { data: branches } = useBranchesQuery(
|
||||
{ projectRef: parentProjectRef },
|
||||
{
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
const currentBranch = branches?.find((branch) => branch.project_ref === ref)
|
||||
const mainBranch = branches?.find((branch) => branch.is_default)
|
||||
|
||||
const {
|
||||
diffContent,
|
||||
isDatabaseDiffLoading,
|
||||
isDatabaseDiffRefetching,
|
||||
databaseDiffError: diffError,
|
||||
refetchDatabaseDiff: refetchDiff,
|
||||
edgeFunctionsDiff,
|
||||
isBranchOutOfDateMigrations,
|
||||
hasEdgeFunctionModifications,
|
||||
missingFunctionsCount,
|
||||
hasMissingFunctions,
|
||||
outOfDateFunctionsCount,
|
||||
hasOutOfDateFunctions,
|
||||
isBranchOutOfDateOverall,
|
||||
missingMigrationsCount,
|
||||
modifiedFunctionsCount,
|
||||
isLoading: isCombinedDiffLoading,
|
||||
hasChanges: combinedHasChanges,
|
||||
} = useBranchMergeDiff({
|
||||
branchId: currentBranch?.id,
|
||||
currentBranchRef: ref,
|
||||
parentProjectRef,
|
||||
currentBranchConnectionString: project?.connectionString || undefined,
|
||||
parentBranchConnectionString: (parentProject as any)?.connectionString || undefined,
|
||||
currentBranchCreatedAt: currentBranch?.created_at,
|
||||
})
|
||||
|
||||
const currentWorkflowRunId = router.query.workflow_run_id as string | undefined
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowFinalStatus(null)
|
||||
}, [currentWorkflowRunId])
|
||||
|
||||
const { currentWorkflowRun: currentBranchWorkflow, workflowRunLogs: currentBranchLogs } =
|
||||
useWorkflowManagement({
|
||||
workflowRunId: currentWorkflowRunId,
|
||||
projectRef: ref,
|
||||
onWorkflowComplete: (status) => {
|
||||
setWorkflowFinalStatus(status)
|
||||
refetchDiff()
|
||||
edgeFunctionsDiff.clearDiffsOptimistically()
|
||||
},
|
||||
})
|
||||
|
||||
const { currentWorkflowRun: parentBranchWorkflow, workflowRunLogs: parentBranchLogs } =
|
||||
useWorkflowManagement({
|
||||
workflowRunId: currentWorkflowRunId,
|
||||
projectRef: parentProjectRef,
|
||||
onWorkflowComplete: (status) => {
|
||||
setWorkflowFinalStatus(status)
|
||||
refetchDiff()
|
||||
edgeFunctionsDiff.clearDiffsOptimistically()
|
||||
},
|
||||
})
|
||||
|
||||
const currentWorkflowRun = currentBranchWorkflow || parentBranchWorkflow
|
||||
const workflowRunLogs = currentBranchLogs || parentBranchLogs
|
||||
|
||||
const hasCurrentWorkflowFailed = workflowFinalStatus
|
||||
? ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowFinalStatus)
|
||||
: currentWorkflowRun?.status &&
|
||||
['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(currentWorkflowRun.status)
|
||||
|
||||
const isWorkflowRunning =
|
||||
currentWorkflowRun?.status === 'RUNNING_MIGRATIONS' ||
|
||||
currentWorkflowRun?.status === 'CREATING_PROJECT'
|
||||
|
||||
const addWorkflowRun = useCallback(
|
||||
(workflowRunId: string) => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, workflow_run_id: workflowRunId },
|
||||
})
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const clearWorkflowRun = useCallback(() => {
|
||||
const { workflow_run_id, ...queryWithoutWorkflowId } = router.query
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: queryWithoutWorkflowId,
|
||||
})
|
||||
}, [router])
|
||||
|
||||
const { mutate: pushBranch, isLoading: isPushing } = useBranchPushMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success('Branch update initiated!')
|
||||
if (data?.workflow_run_id) {
|
||||
addWorkflowRun(data.workflow_run_id)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update branch: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: mergeBranch, isLoading: isMerging } = useBranchMergeMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsSubmitting(false)
|
||||
if (data.workflowRunId) {
|
||||
toast.success('Branch merge initiated!')
|
||||
addWorkflowRun(data.workflowRunId)
|
||||
} else {
|
||||
toast.info('No changes to merge')
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsSubmitting(false)
|
||||
toast.error(`Failed to merge branch: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handlePush = () => {
|
||||
if (!currentBranch?.id || !parentProjectRef) return
|
||||
pushBranch({
|
||||
id: currentBranch.id,
|
||||
projectRef: parentProjectRef,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMerge = () => {
|
||||
if (!currentBranch?.id || !parentProjectRef || !ref) return
|
||||
setIsSubmitting(true)
|
||||
mergeBranch({
|
||||
id: currentBranch.id,
|
||||
branchProjectRef: ref,
|
||||
baseProjectRef: parentProjectRef,
|
||||
migration_version: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleShowConfirmDialog = () => {
|
||||
setShowConfirmDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmMerge = () => {
|
||||
setShowConfirmDialog(false)
|
||||
handleMerge()
|
||||
}
|
||||
|
||||
const handleCancelMerge = () => {
|
||||
setShowConfirmDialog(false)
|
||||
}
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Branches',
|
||||
href: `/project/${parentProjectRef}/branches`,
|
||||
},
|
||||
],
|
||||
[parentProjectRef]
|
||||
)
|
||||
|
||||
const currentTab = (router.query.tab as string) || 'database'
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
const buildHref = (tab: string) => {
|
||||
const query: Record<string, string> = { tab }
|
||||
if (currentWorkflowRunId) query.workflow_run_id = currentWorkflowRunId
|
||||
const qs = new URLSearchParams(query).toString()
|
||||
return `/project/[ref]/merge?${qs}`
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Database',
|
||||
href: buildHref('database'),
|
||||
active: currentTab === 'database',
|
||||
},
|
||||
{
|
||||
label: 'Edge Functions',
|
||||
href: buildHref('edge-functions'),
|
||||
active: currentTab === 'edge-functions',
|
||||
},
|
||||
]
|
||||
}, [currentWorkflowRunId, currentTab])
|
||||
|
||||
if (!gitlessBranching) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ScaffoldContainer size="full">
|
||||
<div className="flex items-center flex-col justify-center w-full py-16">
|
||||
<ProductEmptyState title="Branch Merge - Coming Soon">
|
||||
<p className="text-sm text-foreground-light">
|
||||
The branch merge feature is currently in development and will be available soon.
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 !mt-4">
|
||||
<Button type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/docs/guides/platform/branching"
|
||||
>
|
||||
View the docs
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// If not on a preview branch or branch info unavailable, show notice
|
||||
if (!isBranch || !currentBranch) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ScaffoldContainer size="full">
|
||||
<div className="flex items-center flex-col justify-center w-full py-16">
|
||||
<ProductEmptyState title="Merge Request">
|
||||
<p className="text-sm text-foreground-light">
|
||||
This page is only available for preview branches.
|
||||
</p>
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const isMergeDisabled =
|
||||
!combinedHasChanges || isCombinedDiffLoading || isBranchOutOfDateOverall || isWorkflowRunning
|
||||
|
||||
// Update primary actions - remove push button if branch is out of date (it will be in the notice)
|
||||
const primaryActions = (
|
||||
<div className="flex items-end gap-2">
|
||||
{isMergeDisabled ? (
|
||||
<ButtonTooltip
|
||||
tooltip={{
|
||||
content: {
|
||||
text: !combinedHasChanges
|
||||
? 'No changes to merge'
|
||||
: isWorkflowRunning
|
||||
? 'Workflow is currently running'
|
||||
: 'Branch is out of date',
|
||||
},
|
||||
}}
|
||||
type="primary"
|
||||
loading={isMerging || isSubmitting}
|
||||
disabled={isMergeDisabled}
|
||||
onClick={handleShowConfirmDialog}
|
||||
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
|
||||
>
|
||||
Merge branch
|
||||
</ButtonTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isMerging || isSubmitting}
|
||||
onClick={handleShowConfirmDialog}
|
||||
disabled={isBranchOutOfDateOverall}
|
||||
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
|
||||
>
|
||||
Merge branch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const pageTitle = () => (
|
||||
<span>
|
||||
Merge{' '}
|
||||
<Link href={`/project/${ref}/editor`}>
|
||||
<Badge className="font-mono text-lg gap-1">
|
||||
<GitBranchIcon strokeWidth={1.5} size={16} className="text-foreground-muted" />
|
||||
{currentBranch.name}
|
||||
</Badge>
|
||||
</Link>{' '}
|
||||
into{' '}
|
||||
<Link
|
||||
href={`/project/${mainBranch?.project_ref}/editor`}
|
||||
className="font-mono inline-flex gap-4"
|
||||
>
|
||||
<Badge className="font-mono text-lg gap-1">
|
||||
<Shield strokeWidth={1.5} size={16} className="text-warning" />
|
||||
{mainBranch?.name || 'main'}
|
||||
</Badge>
|
||||
</Link>
|
||||
</span>
|
||||
)
|
||||
|
||||
const pageSubtitle = () => {
|
||||
if (!currentBranch?.created_at) return 'Branch information unavailable'
|
||||
|
||||
const createdTime = dayjs(currentBranch.created_at).fromNow()
|
||||
return `Branch created ${createdTime}`
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={pageTitle()}
|
||||
subtitle={pageSubtitle()}
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryActions={primaryActions}
|
||||
size="full"
|
||||
className="border-b-0 pb-0"
|
||||
>
|
||||
<div className="border-b">
|
||||
<ScaffoldContainer size="full">
|
||||
{isBranchOutOfDateOverall && !currentWorkflowRunId ? (
|
||||
<OutOfDateNotice
|
||||
isBranchOutOfDateMigrations={isBranchOutOfDateMigrations}
|
||||
missingMigrationsCount={missingMigrationsCount}
|
||||
hasMissingFunctions={hasMissingFunctions}
|
||||
missingFunctionsCount={missingFunctionsCount}
|
||||
hasOutOfDateFunctions={hasOutOfDateFunctions}
|
||||
outOfDateFunctionsCount={outOfDateFunctionsCount}
|
||||
hasEdgeFunctionModifications={hasEdgeFunctionModifications}
|
||||
modifiedFunctionsCount={modifiedFunctionsCount}
|
||||
isPushing={isPushing}
|
||||
onPush={handlePush}
|
||||
/>
|
||||
) : currentWorkflowRunId ? (
|
||||
<div className="my-6">
|
||||
<WorkflowLogsCard
|
||||
workflowRun={currentWorkflowRun}
|
||||
logs={workflowRunLogs}
|
||||
isLoading={!workflowRunLogs && !!currentWorkflowRunId}
|
||||
onClose={clearWorkflowRun}
|
||||
overrideTitle={hasCurrentWorkflowFailed ? 'Workflow failed' : undefined}
|
||||
overrideDescription={
|
||||
hasCurrentWorkflowFailed
|
||||
? 'Consider creating a fresh branch from the latest production branch to resolve potential conflicts.'
|
||||
: undefined
|
||||
}
|
||||
overrideIcon={
|
||||
hasCurrentWorkflowFailed ? (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="text-destructive shrink-0"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
overrideAction={
|
||||
hasCurrentWorkflowFailed ? (
|
||||
<Button
|
||||
type="default"
|
||||
asChild
|
||||
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href={`/project/${parentProjectRef}/branches`}>Create new branch</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NavMenu className="mt-4 border-none">
|
||||
{navigationItems.map((item) => {
|
||||
const isActive =
|
||||
item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
|
||||
return (
|
||||
<NavMenuItem key={item.label} active={isActive}>
|
||||
<Link
|
||||
href={
|
||||
item.href.includes('[ref]') && !!ref
|
||||
? item.href.replace('[ref]', ref)
|
||||
: item.href
|
||||
}
|
||||
className={cn('inline-flex items-center gap-2', isActive && 'text-foreground')}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</NavMenuItem>
|
||||
)
|
||||
})}
|
||||
</NavMenu>
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
<ScaffoldContainer size="full" className="pt-6 pb-12">
|
||||
{currentTab === 'database' ? (
|
||||
<DatabaseDiffPanel
|
||||
diffContent={diffContent}
|
||||
isLoading={isDatabaseDiffLoading || isDatabaseDiffRefetching}
|
||||
error={diffError}
|
||||
showRefreshButton={true}
|
||||
currentBranchRef={ref}
|
||||
/>
|
||||
) : (
|
||||
<EdgeFunctionsDiffPanel
|
||||
diffResults={edgeFunctionsDiff}
|
||||
currentBranchRef={ref}
|
||||
mainBranchRef={parentProjectRef}
|
||||
/>
|
||||
)}
|
||||
</ScaffoldContainer>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showConfirmDialog}
|
||||
title="Confirm Branch Merge"
|
||||
description={`Are you sure you want to merge "${currentBranch?.name}" into "${mainBranch?.name || 'main'}"? This action cannot be undone.`}
|
||||
confirmLabel="Merge Branch"
|
||||
confirmLabelLoading="Merging..."
|
||||
onConfirm={handleConfirmMerge}
|
||||
onCancel={handleCancelMerge}
|
||||
loading={isMerging || isSubmitting}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
MergePage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default MergePage
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -31190,7 +31190,7 @@ snapshots:
|
||||
flags@4.0.1(@opentelemetry/api@1.9.0)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@edge-runtime/cookies': 5.0.2
|
||||
jose: 5.2.1
|
||||
jose: 5.9.6
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
|
||||
Reference in New Issue
Block a user