authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:19:35-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 16:35:17-08:00
log56a09ad1aa6334101047aa1f7da9882e38b837bf
tree1b80321523e48271f5ff98a51be1622fada14610
parentbe1c8c4656adad2592d718597018d0df120c58d3

Confirm project deletion and clean up orphaned worktrees

- Prompt before deleting a project with thread and linked worktree counts - Best-effort remove orphaned worktrees when project threads are deleted - Show toast with error details if project deletion fails

1 files changed, 46 insertions(+), 3 deletions(-)

apps/web/src/components/Sidebar.tsx+46-3
......@@ -17,6 +17,7 @@ import {
1717import { useNativeApi } from "../hooks/useNativeApi";
1818import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1919import { serverConfigQueryOptions } from "../lib/serverReactQuery";
20import { toastManager } from "./ui/toast";
2021import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
2122
2223const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
......@@ -342,17 +343,59 @@ export default function Sidebar() {
342343 const project = state.projects.find((entry) => entry.id === projectId);
343344 if (!project) return;
344345
346 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
347 const linkedWorktreeCount = new Set(
348 projectThreads
349 .map((thread) => thread.worktreePath?.trim() ?? "")
350 .filter((worktreePath) => worktreePath.length > 0),
351 ).size;
352 const confirmed = await api.dialogs.confirm(
353 [
354 `Delete project "${project.name}"?`,
355 `This will delete ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`,
356 linkedWorktreeCount > 0
357 ? `Linked worktrees to remove: ${linkedWorktreeCount}.`
358 : "No linked worktrees detected.",
359 ].join("\n"),
360 );
361 if (!confirmed) return;
362
345363 if (isElectron) {
346364 try {
347365 await api.projects.remove({ id: projectId });
348 } catch {
366 } catch (error) {
367 const message =
368 error instanceof Error ? error.message : "Unknown error deleting project.";
369 console.error("Failed to remove project", { projectId, error });
370 toastManager.add({
371 type: "error",
372 title: `Failed to delete "${project.name}"`,
373 description: message,
374 });
349375 return;
350376 }
351377 }
352378
353 const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
379 const retainedThreads = state.threads.filter((thread) => thread.projectId !== projectId);
380 const removedWorktreePaths = new Set<string>();
354381 await Promise.all(
355382 projectThreads.map(async (thread) => {
383 const orphanedWorktreePath = getOrphanedWorktreePathForThread(
384 [thread, ...retainedThreads],
385 thread.id,
386 );
387 if (orphanedWorktreePath && !removedWorktreePaths.has(orphanedWorktreePath)) {
388 removedWorktreePaths.add(orphanedWorktreePath);
389 try {
390 await removeWorktreeMutation.mutateAsync({
391 cwd: project.cwd,
392 path: orphanedWorktreePath,
393 });
394 } catch {
395 // Worktree deletion is best-effort and should not block project deletion.
396 }
397 }
398
356399 if (thread.session?.sessionId) {
357400 try {
358401 await api.providers.stopSession({
......@@ -376,7 +419,7 @@ export default function Sidebar() {
376419
377420 dispatch({ type: "DELETE_PROJECT", projectId });
378421 },
379 [api, dispatch, state.projects, state.threads],
422 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
380423 );
381424
382425 useEffect(() => {