Skip to content

Commit f95fd09

Browse files
committed
feat(web): migrate worktree delete confirm dialog
1 parent 12d1e3c commit f95fd09

3 files changed

Lines changed: 138 additions & 22 deletions

File tree

packages/web/src/components/ui/MIGRATION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
| Spinner | 🟢 complete | `.animate-spin` | 0 | 2026-05-09 |
1515
| Switch | 🟢 complete | new | 0 | 2026-05-09 |
1616
| Modal | 🟢 complete | `.modal-overlay .modal-card .modal-*` | 0 | 2026-05-09 |
17-
| ConfirmDialog | 🟡 partial | modal convenience wrapper | richer confirm/auth flows remain on raw `Modal` | 2026-05-09 |
17+
| ConfirmDialog | 🟡 partial | modal convenience wrapper | file-tree delete, git sync confirm dialogs, and worktree-manager delete confirmation covered; richer confirm/auth flows remain on raw `Modal` | 2026-05-09 |
1818
| Toast | 🟢 complete | `.toast*` | 0 | 2026-05-09 |
1919
| Tooltip | 🟢 complete | native `title` hover labels | 0 | 2026-05-09 |
2020
| ProgressBar | 🟢 complete | `--progress-height` patterns | 0 | 2026-05-09 |
@@ -57,7 +57,7 @@
5757

5858
`Modal` now completes the legacy raw modal-shell migration inventory: the file tree create flow, the objective dialog, the git sync auth flow, the desktop worktree modal, and the desktop worktree manager surface all use the shared primitive from the public UI barrel while preserving the legacy `modal-*` compatibility classes emitted by the primitive itself.
5959

60-
`ConfirmDialog` now covers the bounded confirm-action slice used by the file tree delete flow and git sync confirm dialogs. The row remains partial because richer auth and action-confirmation flows still live on direct `Modal` composition by design.
60+
`ConfirmDialog` now covers the bounded confirm-action slice used by the file tree delete flow, git sync confirm dialogs, and the worktree-manager delete confirmation. The row remains partial because richer auth and action-confirmation flows still live on direct `Modal` composition by design.
6161

6262
`Toast` now covers the bounded notification presenter in `features/notifications/toast-container.tsx`. The shared primitive owns the generic shell and compatibility classes, while Jotai queue state, auto-dismiss timing, icon selection, and workspace/session navigation remain in the feature layer by design.
6363

packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ describe("WorktreeManagerSurface", () => {
272272
);
273273

274274
fireEvent.click(screen.getByRole("button", { name: "Remove feature/x" }));
275+
expect(screen.getByRole("dialog", { name: "Delete" })).toBeInTheDocument();
275276
expect(screen.getByText("Force remove dirty worktree?")).toBeInTheDocument();
277+
expect(screen.getByText("/repo/feature-x")).toHaveClass("worktree-manager__confirm-path");
276278
fireEvent.click(screen.getByRole("button", { name: "Force Remove" }));
277279

278280
await waitFor(() => {
@@ -288,6 +290,83 @@ describe("WorktreeManagerSurface", () => {
288290
});
289291
});
290292

293+
it("uses the shared confirm dialog copy for clean worktree removal and returns to list on cancel", () => {
294+
const cleanRemovableWorktrees: WorktreeInfo[] = [
295+
worktrees[0]!,
296+
{
297+
name: "feature/y",
298+
path: "/repo/feature-y",
299+
branch: "feature/y",
300+
commit: "ghi9012",
301+
status: "clean",
302+
},
303+
];
304+
305+
render(
306+
<Provider store={buildManagerStore(vi.fn(), cleanRemovableWorktrees, "/repo/main")}>
307+
<WorktreeManagerSurface workspaceId="ws-1" openView="list" onClose={vi.fn()} />
308+
</Provider>
309+
);
310+
311+
fireEvent.click(screen.getByRole("button", { name: "Remove feature/y" }));
312+
313+
expect(screen.getByRole("dialog", { name: "Delete" })).toBeInTheDocument();
314+
expect(screen.getByText("Remove worktree?")).toBeInTheDocument();
315+
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
316+
expect(screen.getByText("/repo/feature-y")).toBeInTheDocument();
317+
318+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
319+
320+
expect(screen.queryByRole("dialog", { name: "Delete" })).toBeNull();
321+
expect(screen.getByText("feature/y")).toBeInTheDocument();
322+
});
323+
324+
it("keeps removal errors visible inside the shared confirm dialog until dismissed", async () => {
325+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
326+
if (op === "worktree.remove") {
327+
throw new Error("permission denied");
328+
}
329+
330+
return {};
331+
});
332+
333+
render(
334+
<Provider store={buildManagerStore(sendCommand, worktrees, "/repo/main")}>
335+
<WorktreeManagerSurface workspaceId="ws-1" openView="list" onClose={vi.fn()} />
336+
</Provider>
337+
);
338+
339+
fireEvent.click(screen.getByRole("button", { name: "Remove feature/x" }));
340+
fireEvent.click(screen.getByRole("button", { name: "Force Remove" }));
341+
342+
const dialog = await screen.findByRole("dialog", { name: "Delete" });
343+
expect(dialog).toBeInTheDocument();
344+
expect(screen.getByText("permission denied")).toBeInTheDocument();
345+
expect(screen.getByText("Force remove dirty worktree?")).toBeInTheDocument();
346+
347+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
348+
349+
expect(screen.queryByRole("dialog", { name: "Delete" })).toBeNull();
350+
expect(screen.getByText("feature/x")).toBeInTheDocument();
351+
});
352+
353+
it("keeps mobile worktree removal inside the shared sheet instead of opening a dialog", () => {
354+
viewportMocks.viewport = "mobile";
355+
356+
render(
357+
<Provider store={buildManagerStore(vi.fn(), worktrees, "/repo/main")}>
358+
<WorktreeManagerSurface workspaceId="ws-1" openView="list" onClose={vi.fn()} />
359+
</Provider>
360+
);
361+
362+
fireEvent.click(screen.getByRole("button", { name: "Remove feature/x" }));
363+
364+
expect(screen.queryByRole("dialog", { name: "Delete" })).toBeNull();
365+
expect(screen.getByRole("region", { name: "Worktrees sheet" })).toBeInTheDocument();
366+
expect(screen.getByText("Force remove dirty worktree?")).toBeInTheDocument();
367+
expect(screen.getByRole("button", { name: "Force Remove" })).toBeInTheDocument();
368+
});
369+
291370
it("does not retry worktree.list in a loop after an initial load failure", async () => {
292371
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
293372
if (op === "worktree.list") {

packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
22
import {
33
Button,
4+
ConfirmDialog,
45
EmptyState,
56
Input,
67
Modal,
@@ -134,6 +135,11 @@ export function WorktreeManagerSurface({
134135
const canSubmit =
135136
branchDraft.trim().length > 0 && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(pathDraft.trim());
136137
const pathHintId = `worktree-path-hint-${workspaceId}`;
138+
const closeDeleteConfirm = () => {
139+
setRemoveError(null);
140+
setDeleteTargetPath(null);
141+
setView("list");
142+
};
137143

138144
const openCreate = () => {
139145
resetCreateForm();
@@ -143,6 +149,22 @@ export function WorktreeManagerSurface({
143149
setView("create");
144150
};
145151

152+
const submitDeleteConfirm = () => {
153+
if (!deleteTarget) {
154+
return;
155+
}
156+
157+
setRemoveError(null);
158+
void removeWorktreeByPath(deleteTarget.path, deleteTarget.status === "dirty").then((result) => {
159+
if (result.ok) {
160+
closeDeleteConfirm();
161+
return;
162+
}
163+
164+
setRemoveError(result.error);
165+
});
166+
};
167+
146168
const body =
147169
view === "detail" && selected ? (
148170
<div className={isMobile ? "mobile-worktree-sheet" : undefined}>
@@ -222,31 +244,13 @@ export function WorktreeManagerSurface({
222244
</p>
223245
<code className="worktree-manager__confirm-path">{deleteTarget.path}</code>
224246
<div className="worktree-manager__confirm-actions">
225-
<Button
226-
variant="secondary"
227-
onClick={() => {
228-
setRemoveError(null);
229-
setDeleteTargetPath(null);
230-
setView("list");
231-
}}
232-
>
247+
<Button variant="secondary" onClick={closeDeleteConfirm}>
233248
{t("action.cancel")}
234249
</Button>
235250
<Button
236251
variant="danger"
237252
onClick={() => {
238-
setRemoveError(null);
239-
void removeWorktreeByPath(deleteTarget.path, deleteTarget.status === "dirty").then(
240-
(result) => {
241-
if (result.ok) {
242-
setDeleteTargetPath(null);
243-
setView("list");
244-
return;
245-
}
246-
247-
setRemoveError(result.error);
248-
}
249-
);
253+
submitDeleteConfirm();
250254
}}
251255
>
252256
{deleteTarget.status === "dirty" ? t("worktree.force_remove") : t("common.delete")}
@@ -327,6 +331,39 @@ export function WorktreeManagerSurface({
327331
</div>
328332
);
329333

334+
if (!isMobile && view === "confirm-delete" && deleteTarget) {
335+
return (
336+
<ConfirmDialog
337+
open
338+
className="worktree-manager-surface"
339+
title={t("common.delete")}
340+
description={
341+
<div className="worktree-manager__confirm">
342+
{removeError ? <div className="worktree-error">{removeError}</div> : null}
343+
<p>
344+
{deleteTarget.status === "dirty"
345+
? t("worktree.remove_force_confirm")
346+
: t("worktree.remove_confirm")}
347+
</p>
348+
<code className="worktree-manager__confirm-path">{deleteTarget.path}</code>
349+
</div>
350+
}
351+
cancelText={t("action.cancel")}
352+
closeLabel={t("action.close")}
353+
confirmText={
354+
deleteTarget.status === "dirty" ? t("worktree.force_remove") : t("common.delete")
355+
}
356+
onOpenChange={(open) => {
357+
if (!open) {
358+
closeDeleteConfirm();
359+
}
360+
}}
361+
onConfirm={submitDeleteConfirm}
362+
tone="danger"
363+
/>
364+
);
365+
}
366+
330367
return isMobile ? (
331368
<Sheet
332369
kicker={t("worktree.title").toUpperCase()}

0 commit comments

Comments
 (0)