diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 33383c5cd2e..15a02853d58 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -3,8 +3,10 @@ import { access } from "node:fs/promises"; import { basename, join } from "node:path"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; +import { track } from "main/lib/analytics"; import { db } from "main/lib/db"; import type { Project } from "main/lib/db/schemas"; +import { terminalManager } from "main/lib/terminal"; import { nanoid } from "nanoid"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; @@ -548,6 +550,67 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { success: true }; }), + + close: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.id); + + if (!project) { + throw new Error("Project not found"); + } + + // Find all workspaces for this project + const projectWorkspaces = db.data.workspaces.filter( + (w) => w.projectId === input.id, + ); + + // Kill all terminal processes in all workspaces of this project + let totalFailed = 0; + for (const workspace of projectWorkspaces) { + const terminalResult = await terminalManager.killByWorkspaceId( + workspace.id, + ); + totalFailed += terminalResult.failed; + } + + // Remove all workspace records and hide the project + await db.update((data) => { + // Remove all workspaces for this project + data.workspaces = data.workspaces.filter( + (w) => w.projectId !== input.id, + ); + + // Hide the project by setting tabOrder to null + const p = data.projects.find((p) => p.id === input.id); + if (p) { + p.tabOrder = null; + } + + // Update active workspace if it was in this project + const closedWorkspaceIds = new Set( + projectWorkspaces.map((w) => w.id), + ); + if ( + data.settings.lastActiveWorkspaceId && + closedWorkspaceIds.has(data.settings.lastActiveWorkspaceId) + ) { + const sorted = data.workspaces + .slice() + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; + } + }); + + const terminalWarning = + totalFailed > 0 + ? `${totalFailed} terminal process(es) may still be running` + : undefined; + + track("project_closed", { project_id: input.id }); + + return { success: true, terminalWarning }; + }), }); }; diff --git a/apps/desktop/src/renderer/react-query/projects/index.ts b/apps/desktop/src/renderer/react-query/projects/index.ts index f18b249d9b3..74473d8bc6b 100644 --- a/apps/desktop/src/renderer/react-query/projects/index.ts +++ b/apps/desktop/src/renderer/react-query/projects/index.ts @@ -1,3 +1,4 @@ +export { useCloseProject } from "./useCloseProject"; export { useOpenNew } from "./useOpenNew"; export { useReorderProjects } from "./useReorderProjects"; export { useUpdateProject } from "./useUpdateProject"; diff --git a/apps/desktop/src/renderer/react-query/projects/useCloseProject.ts b/apps/desktop/src/renderer/react-query/projects/useCloseProject.ts new file mode 100644 index 00000000000..f88ac0e56d2 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useCloseProject.ts @@ -0,0 +1,24 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for closing a project (hides from tabs, keeps worktrees on disk) + * Automatically invalidates all workspace and project queries on success + */ +export function useCloseProject( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.projects.close.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + // Invalidate project queries since close updates project metadata + await utils.projects.getRecents.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx index 2e299fe7393..15f87c6e92c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx @@ -6,7 +6,10 @@ import { } from "@superset/ui/context-menu"; import type { KeyboardEvent, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; -import { useUpdateProject } from "renderer/react-query/projects"; +import { + useCloseProject, + useUpdateProject, +} from "renderer/react-query/projects"; import { PROJECT_COLORS } from "shared/constants/project-colors"; interface WorkspaceGroupContextMenuProps { @@ -26,6 +29,7 @@ export function WorkspaceGroupContextMenu({ const inputRef = useRef(null); const skipBlurSubmit = useRef(false); const updateProject = useUpdateProject(); + const closeProject = useCloseProject(); useEffect(() => { setName(projectName); @@ -146,12 +150,11 @@ export function WorkspaceGroupContextMenu({