Files
Joshen Lim be0d05fb08 Bring Branching 2.0 out of feature preview (#44279)
## Context

Have Branching 2.0 as the default behaviour + remove it from feature
preview
Behaviour should match staging / prod if branching 2.0 feature preview
is toggled on

## To test

- [ ] Test branching flow in general for any oddities

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Removed the Branching 2.0 preview and cleared its persisted preview
setting; branching UI and branch editing are now available without
opt‑in.
* Simplified branch management flows and empty states by removing
preview-dependent conditions and tooltips.
* Made GitHub branch sync optional in create/edit forms and simplified
validation and submit behavior.
* "Create merge request" and related branch actions now render
consistently across the UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-14 18:39:20 +08:00

566 lines
20 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { partition } from 'lodash'
import {
Clock,
ExternalLink,
Infinity,
MoreVertical,
Pencil,
RefreshCw,
Shield,
Trash2,
} from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { BranchLoader, BranchManagementSection, BranchRow, BranchRowLoader } from './BranchPanels'
import { EditBranchModal } from './EditBranchModal'
import { PreviewBranchesEmptyState } from './EmptyStates'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { useBranchQuery } from '@/data/branches/branch-query'
import { useBranchResetMutation } from '@/data/branches/branch-reset-mutation'
import { useBranchRestoreMutation } from '@/data/branches/branch-restore-mutation'
import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
import type { Branch } from '@/data/branches/branches-query'
import { branchKeys } from '@/data/branches/keys'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from '@/lib/constants'
interface OverviewProps {
isGithubConnected: boolean
isLoading: boolean
isSuccess: boolean
repo: string
mainBranch: Branch
previewBranches: Branch[]
onSelectCreateBranch: () => void
onSelectDeleteBranch: (branch: Branch) => void
generateCreatePullRequestURL: (branchName?: string) => string
}
export const Overview = ({
isGithubConnected,
isLoading,
isSuccess,
repo,
mainBranch,
previewBranches,
onSelectCreateBranch,
onSelectDeleteBranch,
generateCreatePullRequestURL,
}: OverviewProps) => {
const [scheduledForDeletionBranches, aliveBranches] = partition(
previewBranches,
(branch) => branch.deletion_scheduled_at !== undefined
)
const [persistentBranches, ephemeralBranches] = partition(
aliveBranches,
(branch) => branch.persistent
)
const { ref: projectRef } = useParams()
const { data: selectedOrg } = useSelectedOrganizationQuery()
const { hasAccess: hasAccessToPersistentBranching, isLoading: isLoadingEntitlement } =
useCheckEntitlements('branching_persistent')
return (
<>
<BranchManagementSection header="Production branch">
{isLoading && <BranchRowLoader />}
{isSuccess && mainBranch !== undefined && (
<BranchRow
branch={mainBranch}
isGithubConnected={isGithubConnected}
label={
<div className="flex items-center gap-x-2">
<Shield size={14} strokeWidth={1.5} className="text-warning" />
{mainBranch.name}
</div>
}
repo={repo}
rowActions={<MainBranchActions branch={mainBranch} repo={repo} />}
/>
)}
{isSuccess && mainBranch === undefined && (
<div className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface-100">
<Link href={`/project/${projectRef}`} className="text-foreground block w-full">
<div className="flex items-center gap-x-3">
<Shield size={14} strokeWidth={1.5} className="text-warning" />
main
</div>
</Link>
</div>
)}
</BranchManagementSection>
{/* Persistent Branches Section */}
<BranchManagementSection header="Persistent branches">
{(isLoading || isLoadingEntitlement) && <BranchLoader />}
{isSuccess &&
!isLoadingEntitlement &&
!hasAccessToPersistentBranching &&
IS_PLATFORM &&
persistentBranches.length === 0 && (
<div className="px-6 py-10 flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<p className="text-sm">Upgrade to unlock persistent branches</p>
<p className="text-sm text-foreground-lighter text-balance">
Persistent branches are long-lived, cannot be reset, and are ideal for staging
environments.
</p>
</div>
<Button type="primary" asChild>
<Link href={`/org/${selectedOrg?.slug}/billing?panel=subscriptionPlan`}>
Upgrade
</Link>
</Button>
</div>
)}
{isSuccess &&
!isLoadingEntitlement &&
hasAccessToPersistentBranching &&
persistentBranches.length === 0 && (
<div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
<p>No persistent branches</p>
<p className="text-foreground-lighter text-center text-balance">
Persistent branches are long-lived, cannot be reset, and are ideal for staging
environments.
</p>
</div>
)}
{isSuccess &&
!isLoadingEntitlement &&
persistentBranches.map((branch) => {
return (
<BranchRow
isGithubConnected={isGithubConnected}
key={branch.id}
repo={repo}
branch={branch}
rowActions={
<PreviewBranchActions
branch={branch}
repo={repo}
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
generateCreatePullRequestURL={generateCreatePullRequestURL}
/>
}
/>
)
})}
</BranchManagementSection>
{/* Ephemeral/Preview Branches Section */}
<BranchManagementSection header="Preview branches">
{isLoading && <BranchLoader />}
{isSuccess && ephemeralBranches.length === 0 && (
<PreviewBranchesEmptyState onSelectCreateBranch={onSelectCreateBranch} />
)}
{isSuccess &&
ephemeralBranches.map((branch) => {
return (
<BranchRow
isGithubConnected={isGithubConnected}
key={branch.id}
repo={repo}
branch={branch}
rowActions={
<PreviewBranchActions
branch={branch}
repo={repo}
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
generateCreatePullRequestURL={generateCreatePullRequestURL}
/>
}
/>
)
})}
</BranchManagementSection>
{/* Scheduled for deletion branches section */}
<BranchManagementSection header="Scheduled for deletion branches">
{isLoading && <BranchLoader />}
{isSuccess && scheduledForDeletionBranches.length === 0 && (
<div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
<p className="text-foreground-lighter">No branches scheduled for deletion</p>
</div>
)}
{isSuccess &&
scheduledForDeletionBranches.map((branch) => {
return (
<BranchRow
isGithubConnected={isGithubConnected}
key={branch.id}
repo={repo}
branch={branch}
rowActions={
<PreviewBranchActions
branch={branch}
repo={repo}
// If a scheduled for deletion branch is deleted, we force the deletion
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
generateCreatePullRequestURL={generateCreatePullRequestURL}
/>
}
/>
)
})}
</BranchManagementSection>
</>
)
}
// Row actions for preview branches (non-main)
const PreviewBranchActions = ({
branch,
onSelectDeleteBranch,
generateCreatePullRequestURL,
}: {
branch: Branch
repo: string
onSelectDeleteBranch: () => void
generateCreatePullRequestURL: (branchName?: string) => string
}) => {
const queryClient = useQueryClient()
const { project_ref: branchRef, parent_project_ref: projectRef } = branch
const { can: canDeleteBranches } = useAsyncCheckPermissions(
PermissionAction.DELETE,
'preview_branches'
)
const { can: canUpdateBranches } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'preview_branches'
)
// If user can update branches, they can restore branches
const canRestoreBranches = canUpdateBranches
const { data } = useBranchQuery({ projectRef, branchRef })
const isBranchActiveHealthy = data?.status === 'ACTIVE_HEALTHY'
const isPersistentBranch = branch.persistent
const { hasAccess: hasAccessToPersistentBranching } = useCheckEntitlements('branching_persistent')
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
const [
showPersistentBranchDeleteConfirmationModal,
setShowPersistentBranchDeleteConfirmationModal,
] = useState(false)
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
const { mutate: resetBranch, isPending: isResetting } = useBranchResetMutation({
onSuccess() {
toast.success('Success! Please allow a few seconds for the branch to reset.')
setShowConfirmResetModal(false)
},
})
const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({
onSuccess() {
toast.success('Successfully updated branch')
setShowBranchModeSwitch(false)
if (projectRef) {
queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
}
},
})
const { mutate: restoreBranch } = useBranchRestoreMutation({
onSuccess() {
toast.success('Success! Please allow a few minutes for the branch to restore.')
setShowBranchModeSwitch(false)
},
})
const onRestoreBranch = () => {
restoreBranch({ branchRef, projectRef })
}
const onConfirmReset = () => {
resetBranch({ branchRef, projectRef })
}
const onTogglePersistent = () => {
updateBranch({ branchRef, projectRef, persistent: !branch.persistent })
}
const onDeleteBranch = (e: Event | React.MouseEvent<HTMLDivElement>) => {
if (isPersistentBranch) {
setShowPersistentBranchDeleteConfirmationModal(true)
} else {
e.stopPropagation()
onSelectDeleteBranch()
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
icon={<MoreVertical />}
className="px-1"
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="bottom" align="end">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdateBranches || !isBranchActiveHealthy || isUpdatingBranch}
onSelect={(e) => {
e.stopPropagation()
setShowEditBranchModal(true)
}}
onClick={(e) => {
e.stopPropagation()
setShowEditBranchModal(true)
}}
tooltip={{
content: {
side: 'left',
text: !canUpdateBranches
? 'You need additional permissions to edit branches'
: !isBranchActiveHealthy
? 'Branch is still initializing. Please wait for it to become healthy before editing.'
: undefined,
},
}}
>
<Pencil size={14} /> Edit branch
</DropdownMenuItemTooltip>
{!branch.deletion_scheduled_at && (
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={isResetting || !isBranchActiveHealthy}
onSelect={(e) => {
e.stopPropagation()
setShowConfirmResetModal(true)
}}
onClick={(e) => {
e.stopPropagation()
setShowConfirmResetModal(true)
}}
tooltip={{
content: {
side: 'left',
text: !isBranchActiveHealthy
? 'Branch is still initializing. Please wait for it to become healthy before resetting.'
: undefined,
},
}}
>
<RefreshCw size={14} /> Reset branch
</DropdownMenuItemTooltip>
)}
{!branch.deletion_scheduled_at && (
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={
!isBranchActiveHealthy || (!branch.persistent && !hasAccessToPersistentBranching)
}
onSelect={(e) => {
e.stopPropagation()
setShowBranchModeSwitch(true)
}}
onClick={(e) => {
e.stopPropagation()
setShowBranchModeSwitch(true)
}}
tooltip={{
content: {
side: 'left',
text: !isBranchActiveHealthy
? 'Branch is still initializing. Please wait for it to become healthy before switching.'
: !branch.persistent && !hasAccessToPersistentBranching
? 'Upgrade your plan to access persistent branches'
: undefined,
},
}}
>
{branch.persistent ? (
<>
<Clock size={14} /> Switch to preview
</>
) : (
<>
<Infinity size={14} className="scale-110" /> Switch to persistent
</>
)}
</DropdownMenuItemTooltip>
)}
{/* Create PR if applicable */}
{branch.git_branch && branch.pr_number === undefined && (
<DropdownMenuItem asChild className="gap-x-2">
<a
target="_blank"
rel="noreferrer"
href={generateCreatePullRequestURL(branch.git_branch)}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={14} /> Create pull request
</a>
</DropdownMenuItem>
)}
{branch.deletion_scheduled_at && (
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canRestoreBranches || branch.preview_project_status !== 'INACTIVE'}
onSelect={(e) => {
e.stopPropagation()
onRestoreBranch()
}}
onClick={(e) => {
e.stopPropagation()
onRestoreBranch()
}}
tooltip={{
content: {
side: 'left',
text: !canRestoreBranches
? 'You need additional permissions to restore branches'
: branch.preview_project_status !== 'INACTIVE'
? 'Preview project is not fully paused or already coming up. Please wait for it to become fully paused before restoring.'
: undefined,
},
}}
>
<Clock size={14} /> Restore branch
</DropdownMenuItemTooltip>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canDeleteBranches}
onSelect={onDeleteBranch}
onClick={onDeleteBranch}
tooltip={{
content: {
side: 'left',
text: !canDeleteBranches
? 'You need additional permissions to delete branches'
: undefined,
},
}}
>
<Trash2 size={14} /> Delete branch
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
<TextConfirmModal
variant="warning"
visible={showConfirmResetModal}
onCancel={() => setShowConfirmResetModal(false)}
onConfirm={onConfirmReset}
loading={isResetting}
title="Reset branch"
confirmLabel="Reset branch"
confirmPlaceholder="Type in name of branch"
confirmString={branch?.name ?? ''}
alert={{
title: `Are you sure you want to reset the "${branch.name}" branch? All data will be deleted.`,
}}
/>
<ConfirmationModal
variant="default"
visible={showBranchModeSwitch}
confirmLabel={branch.persistent ? 'Switch to preview' : 'Switch to persistent'}
title="Confirm branch mode switch"
loading={isUpdatingBranch}
onCancel={() => setShowBranchModeSwitch(false)}
onConfirm={onTogglePersistent}
>
<p className="text-sm text-foreground-light">
Are you sure you want to switch the branch "{branch.name}" to{' '}
{branch.persistent ? 'preview' : 'persistent'}?
</p>
</ConfirmationModal>
<ConfirmationModal
variant="warning"
visible={showPersistentBranchDeleteConfirmationModal}
confirmLabel={'Switch to preview'}
title="Branch must be switched to preview before deletion"
loading={isUpdatingBranch}
onCancel={() => setShowPersistentBranchDeleteConfirmationModal(false)}
onConfirm={onTogglePersistent}
>
<p className="text-sm text-foreground-light">
You must switch the branch "{branch.name}" to preview before deleting it.
</p>
</ConfirmationModal>
<EditBranchModal
branch={branch}
visible={showEditBranchModal}
onClose={() => setShowEditBranchModal(false)}
/>
</>
)
}
// Actions for main (production) branch
const MainBranchActions = ({ branch, repo }: { branch: Branch; repo: string }) => {
const { ref: projectRef } = useParams()
const { can: canUpdateBranches } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'preview_branches'
)
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<MoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="bottom" align="end">
{repo ? (
<Link passHref href={`/project/${projectRef}/settings/integrations`}>
<DropdownMenuItem asChild className="gap-x-2">
<a>Change production branch</a>
</DropdownMenuItem>
</Link>
) : (
<DropdownMenuItem
className="gap-x-2"
disabled={!canUpdateBranches}
onSelect={() => setShowEditBranchModal(true)}
onClick={() => setShowEditBranchModal(true)}
>
<Pencil size={14} /> Edit Branch
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<EditBranchModal
branch={branch}
visible={showEditBranchModal}
onClose={() => setShowEditBranchModal(false)}
/>
</>
)
}