From 38176b82a30b9053778779fbba2c984bad4211ce Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 18 Dec 2025 05:14:52 +0100 Subject: [PATCH] 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 * updated formatting * fix: lint * fix: react v5 update --------- Co-authored-by: Ali Waseem --- .../BranchManagement/BranchPanels.tsx | 20 +- .../interfaces/BranchManagement/Overview.tsx | 193 ++++++++++++------ .../layouts/AppLayout/BranchDropdown.tsx | 3 +- .../data/branches/branch-restore-mutation.ts | 50 +++++ .../pages/project/[ref]/branches/index.tsx | 14 +- 5 files changed, 212 insertions(+), 68 deletions(-) create mode 100644 apps/studio/data/branches/branch-restore-mutation.ts diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index f1a67a24999..c9f752df7aa 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -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 = ({
-

- {daysFromNow > 1 ? `Updated on ${formattedUpdatedAt}` : `Updated ${formattedTimeFromNow}`} -

+ {branch.deletion_scheduled_at ? ( +

+ {isDeletionPending + ? 'Deletion pending...' + : `Will be deleted in ${willBeDeletedIn} minutes`} +

+ ) : ( +

+ {daysFromNow > 1 + ? `Updated on ${formattedUpdatedAt}` + : `Updated ${formattedTimeFromNow}`} +

+ )} {rowActions}
diff --git a/apps/studio/components/interfaces/BranchManagement/Overview.tsx b/apps/studio/components/interfaces/BranchManagement/Overview.tsx index aa498cb98ae..20f9e947c25 100644 --- a/apps/studio/components/interfaces/BranchManagement/Overview.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Overview.tsx @@ -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 = ({ ) })} + {/* Scheduled for deletion branches section */} + + {isLoading && } + {isSuccess && scheduledForDeletionBranches.length === 0 && ( +
+

No scheduled for deletion branches

+
+ )} + {isSuccess && + scheduledForDeletionBranches.map((branch) => { + return ( + onSelectDeleteBranch(branch)} + generateCreatePullRequestURL={generateCreatePullRequestURL} + /> + } + /> + ) + })} +
) } @@ -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 = ({ /> - { - 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, - }, - }} - > - Reset branch - - - { - 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 && ( + { + 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 ? ( - <> - Switch to preview - - ) : ( - <> - Switch to persistent - - )} - - + }, + }} + > + Reset branch + + )} + {!branch.deletion_scheduled_at && ( + { + 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 ? ( + <> + Switch to preview + + ) : ( + <> + Switch to persistent + + )} + + )} {/* Edit Branch (gitless) */} {gitlessBranching && ( )} + {branch.deletion_scheduled_at && ( + { + 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, + }, + }} + > + Restore branch + + )} diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index 88d26f753c0..3529b54fd67 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -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 diff --git a/apps/studio/data/branches/branch-restore-mutation.ts b/apps/studio/data/branches/branch-restore-mutation.ts new file mode 100644 index 00000000000..0ac798a371e --- /dev/null +++ b/apps/studio/data/branches/branch-restore-mutation.ts @@ -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> + +export const useBranchRestoreMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation({ + 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, + }) +} diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index 024c45cede2..5e11086f673 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -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{' '}