Skip to content

Commit e9a1578

Browse files
committed
Add delete-all worktree cleanup action
- Hide cleanup entry points when no candidates exist - Share cleanup candidate resolution across the sidebar, palette, and dialog - Add delete-all handling for on-disk worktrees and stale Git records
1 parent c59240c commit e9a1578

6 files changed

Lines changed: 339 additions & 86 deletions

File tree

apps/web/src/components/CommandPalette.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { cn } from "~/lib/utils";
2222
import { useStore } from "~/store";
2323
import { useCommandPaletteStore } from "~/commandPaletteStore";
2424
import { useHandleNewThread } from "~/hooks/useHandleNewThread";
25+
import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates";
2526
import { useTheme } from "~/hooks/useTheme";
2627
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
2728
import {
@@ -144,6 +145,7 @@ function CommandsView() {
144145
const pushMruThread = useCommandPaletteStore((s) => s.pushMruThread);
145146
const mruThreadIds = useCommandPaletteStore((s) => s.mruThreadIds);
146147
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
148+
const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates();
147149
const routeThreadId = useParams({
148150
strict: false,
149151
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
@@ -282,7 +284,7 @@ function CommandsView() {
282284
keywords: ["cleanup", "delete", "prune", "merged", "worktree", "git"],
283285
icon: GitMergeIcon,
284286
group: "Actions",
285-
hidden: !currentProjectId,
287+
hidden: !currentProjectId || !hasWorktreeCleanupCandidates,
286288
onSelect: () => {
287289
closePalette();
288290
openWorktreeCleanupDialog();
@@ -374,6 +376,7 @@ function CommandsView() {
374376
pushMruProject,
375377
pushMruThread,
376378
openWorktreeCleanupDialog,
379+
hasWorktreeCleanupCandidates,
377380
]);
378381

379382
// Filter commands by query

apps/web/src/components/Sidebar.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
5050
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
5151
import { useClientMode } from "~/hooks/useClientMode";
5252
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
53+
import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates";
5354
import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
5455
import { useTheme } from "~/hooks/useTheme";
5556
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
@@ -487,6 +488,7 @@ export default function Sidebar() {
487488
...serverConfigQueryOptions(),
488489
select: (config) => config.keybindings,
489490
});
491+
const { hasCandidates: hasWorktreeCleanupCandidates } = useCurrentWorktreeCleanupCandidates();
490492
const queryClient = useQueryClient();
491493
const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
492494
const openWorktreeCleanupDialog = useWorktreeCleanupStore((s) => s.openDialog);
@@ -2025,16 +2027,18 @@ export default function Sidebar() {
20252027
<span className="text-xs">Open Workspace</span>
20262028
</SidebarMenuButton>
20272029
</SidebarMenuItem>
2028-
<SidebarMenuItem>
2029-
<SidebarMenuButton
2030-
size="sm"
2031-
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2032-
onClick={() => openWorktreeCleanupDialog()}
2033-
>
2034-
<GitMergeIcon className="size-3.5" />
2035-
<span className="text-xs">Worktree cleanup</span>
2036-
</SidebarMenuButton>
2037-
</SidebarMenuItem>
2030+
{hasWorktreeCleanupCandidates ? (
2031+
<SidebarMenuItem>
2032+
<SidebarMenuButton
2033+
size="sm"
2034+
className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2035+
onClick={() => openWorktreeCleanupDialog()}
2036+
>
2037+
<GitMergeIcon className="size-3.5" />
2038+
<span className="text-xs">Worktree cleanup</span>
2039+
</SidebarMenuButton>
2040+
</SidebarMenuItem>
2041+
) : null}
20382042
<SidebarMenuItem>
20392043
<SidebarMenuButton
20402044
size="sm"

apps/web/src/components/WorktreeCleanupDialog.tsx

Lines changed: 136 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import type { GitWorktreeCleanupCandidate } from "@okcode/contracts";
2-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
32
import { GitMergeIcon, LoaderCircleIcon, Trash2Icon } from "lucide-react";
4-
import { useMemo } from "react";
3+
import { useMemo, useState } from "react";
54

5+
import { useCurrentWorktreeCleanupCandidates } from "~/hooks/useCurrentWorktreeCleanupCandidates";
6+
import { gitRemoveWorktreeMutationOptions } from "~/lib/gitReactQuery";
7+
import { readNativeApi } from "~/nativeApi";
68
import { useStore } from "~/store";
7-
import { useHandleNewThread } from "~/hooks/useHandleNewThread";
89
import {
9-
gitMergedWorktreeCleanupCandidatesQueryOptions,
10-
gitPruneWorktreesMutationOptions,
11-
gitRemoveWorktreeMutationOptions,
12-
} from "~/lib/gitReactQuery";
13-
import { formatBranchAge, formatWorktreePathForDisplay } from "~/worktreeCleanup";
10+
buildWorktreeCleanupCandidateStates,
11+
formatBranchAge,
12+
formatWorktreePathForDisplay,
13+
type WorktreeCleanupCandidateState,
14+
} from "~/worktreeCleanup";
1415
import { useWorktreeCleanupStore } from "~/worktreeCleanupStore";
15-
import { toastManager } from "./ui/toast";
1616
import { Badge } from "./ui/badge";
1717
import { Button } from "./ui/button";
18+
import { Card, CardContent } from "./ui/card";
1819
import {
1920
Dialog,
2021
DialogDescription,
@@ -24,100 +25,160 @@ import {
2425
DialogPopup,
2526
DialogTitle,
2627
} from "./ui/dialog";
27-
import { Separator } from "./ui/separator";
2828
import { ScrollArea } from "./ui/scroll-area";
29-
import { Card, CardContent } from "./ui/card";
30-
31-
function resolveWorktreeUsageCount(
32-
candidate: GitWorktreeCleanupCandidate,
33-
threadWorktreePaths: readonly (string | null)[],
34-
): number {
35-
return threadWorktreePaths.filter((path) => path === candidate.path).length;
36-
}
29+
import { Separator } from "./ui/separator";
30+
import { toastManager } from "./ui/toast";
3731

3832
export function WorktreeCleanupDialog() {
3933
const open = useWorktreeCleanupStore((state) => state.open);
4034
const closeDialog = useWorktreeCleanupStore((state) => state.closeDialog);
41-
const { activeThread } = useHandleNewThread();
4235
const threads = useStore((state) => state.threads);
43-
const projects = useStore((state) => state.projects);
4436
const queryClient = useQueryClient();
45-
const activeProject = useMemo(() => {
46-
if (activeThread) {
47-
return (
48-
projects.find((project) => project.id === activeThread.projectId) ?? projects[0] ?? null
49-
);
50-
}
51-
return projects[0] ?? null;
52-
}, [activeThread, projects]);
53-
const cwd = activeProject?.cwd ?? null;
37+
const [isDeletingAll, setIsDeletingAll] = useState(false);
38+
const { candidates, candidatesQuery, cwd } = useCurrentWorktreeCleanupCandidates();
5439
const threadWorktreePaths = useMemo(
5540
() => threads.map((thread) => thread.worktreePath),
5641
[threads],
5742
);
5843

59-
const candidatesQuery = useQuery(gitMergedWorktreeCleanupCandidatesQueryOptions(cwd));
6044
const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
61-
const pruneWorktreesMutation = useMutation(gitPruneWorktreesMutationOptions({ queryClient }));
62-
63-
const candidates = Array.isArray(candidatesQuery.data) ? candidatesQuery.data : [];
64-
const hasCandidates = candidates.length > 0;
65-
const isBusy = removeWorktreeMutation.isPending || pruneWorktreesMutation.isPending;
66-
67-
const staleCandidates = useMemo(
45+
const candidateStates = useMemo(
6846
() =>
69-
candidates.filter(
70-
(c) => !c.pathExists && resolveWorktreeUsageCount(c, threadWorktreePaths) === 0,
71-
),
47+
buildWorktreeCleanupCandidateStates({
48+
candidates,
49+
threadWorktreePaths,
50+
}),
7251
[candidates, threadWorktreePaths],
7352
);
74-
const hasStaleCandidates = staleCandidates.length > 0;
53+
const actionableCandidateStates = useMemo(
54+
() => candidateStates.filter((state) => state.canDelete),
55+
[candidateStates],
56+
);
57+
const onDiskCandidateCount = actionableCandidateStates.filter(
58+
(state) => state.candidate.pathExists,
59+
).length;
60+
const staleCandidateCount = actionableCandidateStates.length - onDiskCandidateCount;
61+
const hasCandidates = candidateStates.length > 0;
62+
const isBusy = isDeletingAll || removeWorktreeMutation.isPending;
7563

7664
const handleClose = () => {
7765
closeDialog();
7866
};
7967

80-
const handlePruneAllStale = async () => {
81-
if (!cwd || !hasStaleCandidates) return;
68+
const handleRemoveCandidate = async (candidateState: WorktreeCleanupCandidateState) => {
69+
if (!cwd || !candidateState.canDelete) return;
70+
8271
try {
83-
await pruneWorktreesMutation.mutateAsync({ cwd });
84-
toastManager.add({
85-
type: "success",
86-
title: "Stale records pruned",
87-
description: `Pruned ${staleCandidates.length} stale worktree record${staleCandidates.length === 1 ? "" : "s"}.`,
72+
await removeWorktreeMutation.mutateAsync({
73+
cwd,
74+
path: candidateState.candidate.path,
75+
force: true,
8876
});
8977
} catch (error) {
9078
toastManager.add({
9179
type: "error",
92-
title: "Could not prune stale records",
80+
title: candidateState.candidate.pathExists
81+
? "Could not delete worktree"
82+
: "Could not remove stale record",
9383
description: error instanceof Error ? error.message : "Unknown error.",
9484
});
9585
}
9686
};
9787

98-
const handleRemoveCandidate = async (candidate: GitWorktreeCleanupCandidate) => {
99-
if (!cwd) return;
100-
const usageCount = resolveWorktreeUsageCount(candidate, threadWorktreePaths);
101-
if (usageCount > 0) return;
88+
const handleDeleteAll = async () => {
89+
if (!cwd || actionableCandidateStates.length === 0) return;
90+
91+
const skippedCount = candidateStates.length - actionableCandidateStates.length;
92+
const summaryLines = ["Delete all available cleanup candidates?"];
93+
const effects: string[] = [];
94+
if (onDiskCandidateCount > 0) {
95+
effects.push(
96+
`delete ${onDiskCandidateCount} worktree${onDiskCandidateCount === 1 ? "" : "s"} on disk`,
97+
);
98+
}
99+
if (staleCandidateCount > 0) {
100+
effects.push(
101+
`remove ${staleCandidateCount} stale Git record${staleCandidateCount === 1 ? "" : "s"}`,
102+
);
103+
}
104+
if (effects.length > 0) {
105+
summaryLines.push(`${effects.join(" and ")}.`);
106+
}
107+
if (skippedCount > 0) {
108+
summaryLines.push(
109+
`Skip ${skippedCount} candidate${skippedCount === 1 ? "" : "s"} still linked to thread${skippedCount === 1 ? "" : "s"}.`,
110+
);
111+
}
112+
summaryLines.push("This cannot be undone.");
113+
114+
const api = readNativeApi();
115+
const confirmMessage = summaryLines.join("\n");
116+
const confirmed = api
117+
? await api.dialogs.confirm(confirmMessage)
118+
: window.confirm(confirmMessage);
119+
if (!confirmed) {
120+
return;
121+
}
102122

123+
setIsDeletingAll(true);
124+
let deletedOnDiskCount = 0;
125+
let deletedStaleCount = 0;
103126
try {
104-
if (candidate.pathExists) {
127+
for (const candidateState of actionableCandidateStates) {
105128
await removeWorktreeMutation.mutateAsync({
106129
cwd,
107-
path: candidate.path,
130+
path: candidateState.candidate.path,
108131
force: true,
109132
});
110-
} else {
111-
await pruneWorktreesMutation.mutateAsync({ cwd });
133+
if (candidateState.candidate.pathExists) {
134+
deletedOnDiskCount += 1;
135+
} else {
136+
deletedStaleCount += 1;
137+
}
138+
}
139+
140+
const completedParts: string[] = [];
141+
if (deletedOnDiskCount > 0) {
142+
completedParts.push(
143+
`Deleted ${deletedOnDiskCount} worktree${deletedOnDiskCount === 1 ? "" : "s"}`,
144+
);
145+
}
146+
if (deletedStaleCount > 0) {
147+
completedParts.push(
148+
`removed ${deletedStaleCount} stale Git record${deletedStaleCount === 1 ? "" : "s"}`,
149+
);
112150
}
151+
if (skippedCount > 0) {
152+
completedParts.push(
153+
`skipped ${skippedCount} candidate${skippedCount === 1 ? "" : "s"} still linked to thread${skippedCount === 1 ? "" : "s"}`,
154+
);
155+
}
156+
157+
toastManager.add({
158+
type: "success",
159+
title: "Cleanup complete",
160+
description: `${completedParts.join("; ")}.`,
161+
});
113162
} catch (error) {
163+
const completedParts: string[] = [];
164+
if (deletedOnDiskCount > 0) {
165+
completedParts.push(
166+
`Deleted ${deletedOnDiskCount} worktree${deletedOnDiskCount === 1 ? "" : "s"}`,
167+
);
168+
}
169+
if (deletedStaleCount > 0) {
170+
completedParts.push(
171+
`removed ${deletedStaleCount} stale Git record${deletedStaleCount === 1 ? "" : "s"}`,
172+
);
173+
}
174+
114175
toastManager.add({
115176
type: "error",
116-
title: candidate.pathExists
117-
? "Could not delete worktree"
118-
: "Could not prune worktree record",
119-
description: error instanceof Error ? error.message : "Unknown error.",
177+
title: "Cleanup stopped before finishing",
178+
description: `${completedParts.length > 0 ? `${completedParts.join("; ")} before the failure. ` : ""}${error instanceof Error ? error.message : "Unknown error."}`,
120179
});
180+
} finally {
181+
setIsDeletingAll(false);
121182
}
122183
};
123184

@@ -128,7 +189,7 @@ export function WorktreeCleanupDialog() {
128189
<DialogTitle>Merged worktree cleanup</DialogTitle>
129190
<DialogDescription>
130191
Review worktrees whose pull requests are already merged. Delete the worktree if it is
131-
still on disk, or prune the stale Git record if it is already missing.
192+
still on disk, or remove the stale Git record if it is already missing.
132193
</DialogDescription>
133194
</DialogHeader>
134195
<DialogPanel className="px-4 pb-4 sm:px-6">
@@ -161,13 +222,12 @@ export function WorktreeCleanupDialog() {
161222
) : (
162223
<ScrollArea className="max-h-[60vh] pr-1" scrollbarGutter>
163224
<div className="space-y-3">
164-
{candidates.map((candidate, index) => {
165-
const usageCount = resolveWorktreeUsageCount(candidate, threadWorktreePaths);
225+
{candidateStates.map((candidateState, index) => {
226+
const { candidate, canDelete, usageCount } = candidateState;
166227
const displayPath = formatWorktreePathForDisplay(candidate.path);
167-
const canDelete = usageCount === 0;
168228
const actionLabel = candidate.pathExists
169229
? "Delete worktree"
170-
: "Prune stale records";
230+
: "Delete stale record";
171231

172232
return (
173233
<Card
@@ -233,7 +293,7 @@ export function WorktreeCleanupDialog() {
233293
variant="destructive-outline"
234294
size="sm"
235295
disabled={!canDelete || isBusy}
236-
onClick={() => void handleRemoveCandidate(candidate)}
296+
onClick={() => void handleRemoveCandidate(candidateState)}
237297
>
238298
{isBusy ? (
239299
<LoaderCircleIcon className="size-3.5 animate-spin" />
@@ -244,7 +304,9 @@ export function WorktreeCleanupDialog() {
244304
</Button>
245305
</div>
246306
</div>
247-
{index < candidates.length - 1 ? <Separator className="mt-4" /> : null}
307+
{index < candidateStates.length - 1 ? (
308+
<Separator className="mt-4" />
309+
) : null}
248310
</CardContent>
249311
</Card>
250312
);
@@ -256,22 +318,22 @@ export function WorktreeCleanupDialog() {
256318
<DialogFooter variant="bare">
257319
<div className="flex w-full items-center justify-between gap-3">
258320
<div className="text-xs text-muted-foreground">
259-
{candidates.length} candidate{candidates.length === 1 ? "" : "s"} found
321+
{candidateStates.length} candidate{candidateStates.length === 1 ? "" : "s"} found
260322
</div>
261323
<div className="flex items-center gap-2">
262-
{hasStaleCandidates ? (
324+
{actionableCandidateStates.length > 0 ? (
263325
<Button
264326
variant="destructive-outline"
265327
size="sm"
266328
disabled={isBusy}
267-
onClick={() => void handlePruneAllStale()}
329+
onClick={() => void handleDeleteAll()}
268330
>
269-
{pruneWorktreesMutation.isPending ? (
331+
{isDeletingAll ? (
270332
<LoaderCircleIcon className="size-3.5 animate-spin" />
271333
) : (
272334
<Trash2Icon className="size-3.5" />
273335
)}
274-
Prune all stale ({staleCandidates.length})
336+
Delete all ({actionableCandidateStates.length})
275337
</Button>
276338
) : null}
277339
<Button variant="outline" size="sm" onClick={handleClose}>

0 commit comments

Comments
 (0)