History

Delete projects from the sidebar context menu

- Add project right-click Delete action in `Sidebar` and clean up sessions/terminals before removal - Add `DELETE_PROJECT` reducer action to remove the project, its threads, and reset active thread - Add reducer test coverage for project deletion behavior

Julius Marminge committed 8 months ago

Commit ebac7551 parent 8dd10743 files · +118 −0Browse files

Unified · Split

3 files
  1. apps/web/src/components/Sidebar.tsx +53 −0
  2. apps/web/src/store.test.ts +45 −0
  3. apps/web/src/store.ts +20 −0
apps/web/src/components/Sidebar.tsx+53−0
@@ -333,6 +333,52 @@ export default function Sidebar() { · 332 unmodified lines
333333 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
334334 );
335335
336+ const handleProjectContextMenu = useCallback(
337+ async (projectId: string, position: { x: number; y: number }) => {
338+ if (!api) return;
339+ const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
340+ if (clicked !== "delete") return;
341+
342+ const project = state.projects.find((entry) => entry.id === projectId);
343+ if (!project) return;
344+
345+ if (isElectron) {
346+ try {
347+ await api.projects.remove({ id: projectId });
348+ } catch {
349+ return;
350+ }
351+ }
352+
353+ const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);
354+ await Promise.all(
355+ projectThreads.map(async (thread) => {
356+ if (thread.session?.sessionId) {
357+ try {
358+ await api.providers.stopSession({
359+ sessionId: thread.session.sessionId,
360+ });
361+ } catch {
362+ // Session may already be stopped
363+ }
364+ }
365+
366+ try {
367+ await api.terminal.close({
368+ threadId: thread.id,
369+ deleteHistory: true,
370+ });
371+ } catch {
372+ // Terminal may already be closed
373+ }
374+ }),
375+ );
376+
377+ dispatch({ type: "DELETE_PROJECT", projectId });
378+ },
379+ [api, dispatch, state.projects, state.threads],
380+ );
381+
336382 useEffect(() => {
337383 const onWindowKeyDown = (event: KeyboardEvent) => {
338384 if (!isChatNewShortcut(event, keybindings)) return;
@@ -413,6 +459,13 @@ export default function Sidebar() { · 74 unmodified lines
413459 type="button"
414460 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"
415461 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
462+ onContextMenu={(e) => {
463+ e.preventDefault();
464+ void handleProjectContextMenu(project.id, {
465+ x: e.clientX,
466+ y: e.clientY,
467+ });
468+ }}
416469 >
417470 <span className="text-[10px] text-muted-foreground/70">
418471 {project.expanded ? "▼" : "▶"}
apps/web/src/store.test.ts+45−0
@@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { · 485 unmodified lines
486486 expect(next.activeThreadId).toBe("thread-a");
487487 });
488488
489+ it("deletes a project and all of its threads", () => {
490+ const state: AppState = {
491+ projects: [
492+ {
493+ id: "project-1",
494+ name: "One",
495+ cwd: "/tmp/one",
496+ model: "gpt-5.3-codex",
497+ expanded: true,
498+ },
499+ {
500+ id: "project-2",
501+ name: "Two",
502+ cwd: "/tmp/two",
503+ model: "gpt-5.3-codex",
504+ expanded: true,
505+ },
506+ ],
507+ threads: [
508+ makeThread({
509+ id: "thread-a",
510+ projectId: "project-1",
511+ }),
512+ makeThread({
513+ id: "thread-b",
514+ projectId: "project-2",
515+ }),
516+ ],
517+ activeThreadId: "thread-a",
518+ runtimeMode: "full-access",
519+ diffOpen: false,
520+ };
521+
522+ const next = reducer(state, {
523+ type: "DELETE_PROJECT",
524+ projectId: "project-1",
525+ });
526+
527+ expect(next.projects).toHaveLength(1);
528+ expect(next.projects[0]?.id).toBe("project-2");
529+ expect(next.threads).toHaveLength(1);
530+ expect(next.threads[0]?.id).toBe("thread-b");
531+ expect(next.activeThreadId).toBe("thread-b");
532+ });
533+
489534 it("marks the active thread as visited when selected", () => {
490535 const state = makeState(
491536 makeThread({
apps/web/src/store.ts+20−0
@@ -28,6 +28,7 @@ type Action = · 27 unmodified lines
2828 | { type: "ADD_PROJECT"; project: Project }
2929 | { type: "SYNC_PROJECTS"; projects: Project[] }
3030 | { type: "TOGGLE_PROJECT"; projectId: string }
31+ | { type: "DELETE_PROJECT"; projectId: string }
3132 | { type: "ADD_THREAD"; thread: Thread }
3233 | { type: "SET_ACTIVE_THREAD"; threadId: string }
3334 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { · 439 unmodified lines
473474 ),
474475 };
475476
477+ case "DELETE_PROJECT": {
478+ const projects = state.projects.filter((project) => project.id !== action.projectId);
479+ if (projects.length === state.projects.length) {
480+ return state;
481+ }
482+
483+ const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
484+ const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
485+ ? state.activeThreadId
486+ : (threads[0]?.id ?? null);
487+
488+ return {
489+ ...state,
490+ projects,
491+ threads,
492+ activeThreadId,
493+ };
494+ }
495+
476496 case "ADD_THREAD": {
477497 const nextThread = normalizeThreadTerminals({
478498 ...action.thread,