feat(studio): setup soft-delete and easy branch restore (#40353)

* wip: branch deletion grace period

* fix: allow to force delete pending

* fix: update for staging

* fix: typo

* fix: revert soft-delete by default

* chore: revert branch-delete-mutation changes

* fix: revert platform.d.ts changes

* fix: use INVALID status as distinguisher

* fix: hide unusable options

* fix: apply review suggestion

Co-authored-by: Ali Waseem <waseema393@gmail.com>

* updated formatting

* fix: lint

* fix: react v5 update

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
Andrew ValleteauandAli Waseem authored and GitHub committed 2025-12-18 12:14:52 +08:00
1 parent 2fbb6cb9a7
commit 38176b82a3
5 files changed
+212 -68

No files matched your search

@@ -81,6 +81,10 @@ export const BranchRow = ({
const page = router.pathname.split('/').pop()
const daysFromNow = dayjs().diff(dayjs(branch.updated_at), 'day')
const willBeDeletedIn = branch.deletion_scheduled_at
? dayjs(branch.deletion_scheduled_at).diff(dayjs(), 'minutes')
: null
const isDeletionPending = willBeDeletedIn !== null && willBeDeletedIn < 0
const formattedTimeFromNow = dayjs(branch.updated_at).fromNow()
const formattedUpdatedAt = dayjs(branch.updated_at).format('DD MMM YYYY, HH:mm:ss (ZZ)')
@@ -125,9 +129,19 @@ export const BranchRow = ({
</Tooltip>
</div>
<div className="flex items-center gap-x-4">
<p className="text-xs text-foreground-lighter">
{daysFromNow > 1 ? `Updated on ${formattedUpdatedAt}` : `Updated ${formattedTimeFromNow}`}
</p>
{branch.deletion_scheduled_at ? (
<p className="text-xs text-foreground-lighter">
{isDeletionPending
? 'Deletion pending...'
: `Will be deleted in ${willBeDeletedIn} minutes`}
</p>
) : (
<p className="text-xs text-foreground-lighter">
{daysFromNow > 1
? `Updated on ${formattedUpdatedAt}`
: `Updated ${formattedTimeFromNow}`}
</p>
)}
<WorkflowLogs projectRef={branch.project_ref} status={branch.status} />
{rowActions}
</div>
@@ -39,6 +39,7 @@ import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { BranchLoader, BranchManagementSection, BranchRow, BranchRowLoader } from './BranchPanels'
import { EditBranchModal } from './EditBranchModal'
import { PreviewBranchesEmptyState } from './EmptyStates'
import { useBranchRestoreMutation } from 'data/branches/branch-restore-mutation'
interface OverviewProps {
isGithubConnected: boolean
@@ -63,8 +64,12 @@ export const Overview = ({
onSelectDeleteBranch,
generateCreatePullRequestURL,
}: OverviewProps) => {
const [persistentBranches, ephemeralBranches] = partition(
const [scheduledForDeletionBranches, aliveBranches] = partition(
previewBranches,
(branch) => branch.deletion_scheduled_at !== undefined
)
const [persistentBranches, ephemeralBranches] = partition(
aliveBranches,
(branch) => branch.persistent
)
const { ref: projectRef } = useParams()
@@ -186,6 +191,35 @@ export const Overview = ({
)
})}
</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 justify-center w-full py-10">
<p>No scheduled for deletion branches</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>
</>
)
}
@@ -213,13 +247,14 @@ const PreviewBranchActions = ({
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, isLoading: isLoadingEntitlement } =
useCheckEntitlements('branching_persistent')
const { hasAccess: hasAccessToPersistentBranching } = useCheckEntitlements('branching_persistent')
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
@@ -245,6 +280,16 @@ const PreviewBranchActions = ({
}
},
})
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 })
@@ -275,64 +320,66 @@ const PreviewBranchActions = ({
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="bottom" align="end">
<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>
<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'
{!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,
},
}}
>
{branch.persistent ? (
<>
<Clock size={14} /> Switch to preview
</>
) : (
<>
<Infinity size={14} className="scale-110" /> Switch to persistent
</>
)}
</DropdownMenuItemTooltip>
},
}}
>
<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>
)}
{/* Edit Branch (gitless) */}
{gitlessBranching && (
<DropdownMenuItemTooltip
@@ -391,6 +438,32 @@ const PreviewBranchActions = ({
</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>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -33,6 +33,7 @@ import {
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { sanitizeRoute } from './ProjectDropdown'
import { partition } from 'lodash'
const BranchLink = ({
branch,
@@ -101,7 +102,7 @@ export const BranchDropdown = () => {
const mainBranch = branches?.find((branch) => branch.is_default)
const restOfBranches = branches
?.filter((branch) => !branch.is_default)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
?.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
const sortedBranches =
branches && branches.length > 0
@@ -0,0 +1,50 @@
import { type UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
export type BranchRestoreVariables = {
branchRef: string
projectRef: string
}
export async function restoreBranch({ branchRef }: BranchRestoreVariables) {
const { data, error } = await post('/v1/branches/{branch_id_or_ref}/restore', {
params: {
path: { branch_id_or_ref: branchRef },
},
})
if (error) handleError(error)
return data
}
type BranchRestoreData = Awaited<ReturnType<typeof restoreBranch>>
export const useBranchRestoreMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchRestoreData, ResponseError, BranchRestoreVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchRestoreData, ResponseError, BranchRestoreVariables>({
mutationFn: (vars) => restoreBranch(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to restore branch: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -95,8 +95,12 @@ const BranchesPage: NextPageWithLayout = () => {
}
const onConfirmDeleteBranch = () => {
if (selectedBranchToDelete == undefined) return console.error('No branch selected')
const { project_ref: branchRef, parent_project_ref: projectRef } = selectedBranchToDelete
if (selectedBranchToDelete === undefined) return console.error('No branch selected')
const {
project_ref: branchRef,
parent_project_ref: projectRef,
persistent,
} = selectedBranchToDelete
deleteBranch(
{ branchRef, projectRef },
{
@@ -108,7 +112,7 @@ const BranchesPage: NextPageWithLayout = () => {
sendEvent({
action: 'branch_delete_button_clicked',
properties: {
branchType: selectedBranchToDelete.persistent ? 'persistent' : 'preview',
branchType: persistent ? 'persistent' : 'preview',
origin: 'branches_page',
},
groups: {
@@ -175,7 +179,9 @@ const BranchesPage: NextPageWithLayout = () => {
confirmLabel="Delete branch"
confirmPlaceholder="Type in name of branch"
confirmString={selectedBranchToDelete?.name ?? ''}
alert={{ title: 'You cannot recover this branch once deleted' }}
alert={{
title: 'You cannot recover this branch once deleted',
}}
text={
<>
This will delete your database preview branch{' '}