diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 9a612d1ca10..8ddfda9dea5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -671,6 +671,162 @@ export const createWorkspacesRouter = () => { githubStatus: worktree.githubStatus ?? null, }; }), + + getWorktreesByProject: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(({ input }) => { + const worktrees = db.data.worktrees.filter( + (wt) => wt.projectId === input.projectId, + ); + + return worktrees.map((wt) => { + const workspace = db.data.workspaces.find( + (w) => w.worktreeId === wt.id, + ); + return { + ...wt, + hasActiveWorkspace: workspace !== undefined, + workspace: workspace ?? null, + }; + }); + }), + + openWorktree: publicProcedure + .input( + z.object({ + worktreeId: z.string(), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const worktree = db.data.worktrees.find( + (wt) => wt.id === input.worktreeId, + ); + if (!worktree) { + throw new Error(`Worktree ${input.worktreeId} not found`); + } + + // Check if worktree already has an active workspace + const existingWorkspace = db.data.workspaces.find( + (w) => w.worktreeId === input.worktreeId, + ); + if (existingWorkspace) { + throw new Error("Worktree already has an active workspace"); + } + + const project = db.data.projects.find( + (p) => p.id === worktree.projectId, + ); + if (!project) { + throw new Error(`Project ${worktree.projectId} not found`); + } + + // Verify worktree still exists on disk + const exists = await worktreeExists( + project.mainRepoPath, + worktree.path, + ); + if (!exists) { + throw new Error("Worktree no longer exists on disk"); + } + + const projectWorkspaces = db.data.workspaces.filter( + (w) => w.projectId === worktree.projectId, + ); + const maxTabOrder = + projectWorkspaces.length > 0 + ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) + : -1; + + const workspace = { + id: nanoid(), + projectId: worktree.projectId, + worktreeId: worktree.id, + name: input.name ?? worktree.branch, + tabOrder: maxTabOrder + 1, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }; + + await db.update((data) => { + data.workspaces.push(workspace); + data.settings.lastActiveWorkspaceId = workspace.id; + + const p = data.projects.find((p) => p.id === worktree.projectId); + if (p) { + p.lastOpenedAt = Date.now(); + + if (p.tabOrder === null) { + const activeProjects = data.projects.filter( + (proj) => proj.tabOrder !== null, + ); + const maxProjectTabOrder = + activeProjects.length > 0 + ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null + Math.max(...activeProjects.map((proj) => proj.tabOrder!)) + : -1; + p.tabOrder = maxProjectTabOrder + 1; + } + } + }); + + // Load setup configuration from the main repo + const setupConfig = loadSetupConfig(project.mainRepoPath); + + return { + workspace, + initialCommands: setupConfig?.setup || null, + worktreePath: worktree.path, + projectId: project.id, + }; + }), + + close: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const workspace = db.data.workspaces.find((w) => w.id === input.id); + + if (!workspace) { + throw new Error("Workspace not found"); + } + + // Kill all terminal processes in this workspace + const terminalResult = await terminalManager.killByWorkspaceId( + input.id, + ); + + // Delete workspace record ONLY, keep worktree + await db.update((data) => { + data.workspaces = data.workspaces.filter((w) => w.id !== input.id); + + // Check if project should be hidden (no more open workspaces) + const remainingWorkspaces = data.workspaces.filter( + (w) => w.projectId === workspace.projectId, + ); + if (remainingWorkspaces.length === 0) { + const p = data.projects.find((p) => p.id === workspace.projectId); + if (p) { + p.tabOrder = null; + } + } + + // Update active workspace if this was the active one + if (data.settings.lastActiveWorkspaceId === input.id) { + const sorted = data.workspaces + .slice() + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; + } + }); + + const terminalWarning = + terminalResult.failed > 0 + ? `${terminalResult.failed} terminal process(es) may still be running` + : undefined; + + return { success: true, terminalWarning }; + }), }); }; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index b126120c90c..cf373ddb6f5 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -2,22 +2,21 @@ import { Button } from "@superset/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@superset/ui/dialog"; import { Input } from "@superset/ui/input"; -import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; import { useEffect, useState } from "react"; -import { - HiCheck, - HiChevronDown, - HiChevronUp, - HiMiniFolderOpen, -} from "react-icons/hi2"; -import { formatPathWithProject } from "renderer/lib/formatPath"; +import { HiPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; @@ -25,12 +24,8 @@ import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, } from "renderer/stores/new-workspace-modal"; +import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; -const INITIAL_PROJECTS_LIMIT = 5; - -/** - * Generates a git-appropriate branch name from a title. - */ function generateBranchFromTitle(title: string): string { if (!title.trim()) return ""; @@ -44,35 +39,25 @@ function generateBranchFromTitle(title: string): string { .slice(0, 50); } +type Mode = "existing" | "new"; + export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); - const { data: homeDir } = trpc.window.getHomeDir.useQuery(); const [selectedProjectId, setSelectedProjectId] = useState( null, ); const [title, setTitle] = useState(""); const [branchName, setBranchName] = useState(""); const [branchNameEdited, setBranchNameEdited] = useState(false); - const [showAllProjects, setShowAllProjects] = useState(false); + const [mode, setMode] = useState("new"); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const createWorkspace = useCreateWorkspace(); const openNew = useOpenNew(); - // Sort projects with current project first const currentProjectId = activeWorkspace?.projectId; - const sortedProjects = [...recentProjects].sort((a, b) => { - if (a.id === currentProjectId) return -1; - if (b.id === currentProjectId) return 1; - return 0; - }); - - const visibleProjects = showAllProjects - ? sortedProjects - : sortedProjects.slice(0, INITIAL_PROJECTS_LIMIT); - const hasMoreProjects = sortedProjects.length > INITIAL_PROJECTS_LIMIT; // Auto-select current project when modal opens useEffect(() => { @@ -93,7 +78,7 @@ export function NewWorkspaceModal() { setTitle(""); setBranchName(""); setBranchNameEdited(false); - setShowAllProjects(false); + setMode("new"); }; const handleClose = () => { @@ -158,123 +143,145 @@ export function NewWorkspaceModal() { } }; - const renderProjectButton = ( - project: { id: string; name: string; mainRepoPath: string }, - isSelected: boolean, - ) => ( - - ); - return ( !open && handleClose()}> - - - New Workspace - - Each workspace is an isolated git worktree. - + + + Open Workspace -
- {/* Project Selection */} -
- -
- {visibleProjects.map((project) => - renderProjectButton(project, selectedProjectId === project.id), - )} - {hasMoreProjects && ( + {/* Project Selector */} +
+
+ + +
+
+ + {selectedProjectId && ( + <> + {/* Mode Switcher */} +
+
- )} - + +
-
- {/* Optional Fields */} -
-
- - setTitle(e.target.value)} - /> + {/* Content */} +
+ {mode === "new" ? ( +
+
+ + setTitle(e.target.value)} + /> +
+ +
+ + handleBranchNameChange(e.target.value)} + /> +
+
+ ) : ( + + )}
+ + )} -
- - handleBranchNameChange(e.target.value)} - /> + {!selectedProjectId && ( +
+
+ Select a project to get started
-
+ )} - - - - + {mode === "new" && selectedProjectId && ( + + + + )}
); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx new file mode 100644 index 00000000000..e303aaf3d45 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -0,0 +1,104 @@ +import { toast } from "@superset/ui/sonner"; +import { formatDistanceToNow } from "date-fns"; +import { LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenWorktree } from "renderer/react-query/workspaces"; + +interface ExistingWorktreesListProps { + projectId: string; + onOpenSuccess: () => void; +} + +export function ExistingWorktreesList({ + projectId, + onOpenSuccess, +}: ExistingWorktreesListProps) { + const { data: worktrees = [], isLoading } = + trpc.workspaces.getWorktreesByProject.useQuery({ projectId }); + const openWorktree = useOpenWorktree(); + + const closedWorktrees = worktrees + .filter((wt) => !wt.hasActiveWorkspace) + .sort((a, b) => b.createdAt - a.createdAt); + const openWorktrees = worktrees + .filter((wt) => wt.hasActiveWorkspace) + .sort((a, b) => b.createdAt - a.createdAt); + + const handleOpenWorktree = async (worktreeId: string, branch: string) => { + toast.promise(openWorktree.mutateAsync({ worktreeId }), { + loading: "Opening workspace...", + success: () => { + onOpenSuccess(); + return `Opened ${branch}`; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to open workspace", + }); + }; + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (worktrees.length === 0) { + return ( +
+ No worktrees yet. Create a new branch to get started. +
+ ); + } + + if (closedWorktrees.length === 0) { + return ( +
+ All worktrees are open. +
+ Close a workspace to reopen it here. +
+ ); + } + + return ( +
+ {closedWorktrees.map((wt) => ( + + ))} + + {openWorktrees.length > 0 && ( +
+
+ Already open +
+ {openWorktrees.map((wt) => ( +
+ + + {wt.branch} + + open +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/index.ts new file mode 100644 index 00000000000..dda43fa3dee --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/index.ts @@ -0,0 +1 @@ +export { ExistingWorktreesList } from "./ExistingWorktreesList"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 179548a4a93..912c28f3d2c 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -1,5 +1,7 @@ +export { useCloseWorkspace } from "./useCloseWorkspace"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; +export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts new file mode 100644 index 00000000000..e4c96b0fc86 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -0,0 +1,24 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for closing a workspace without deleting the worktree + * Automatically invalidates all workspace queries on success + */ +export function useCloseWorkspace( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.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/react-query/workspaces/useOpenWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts new file mode 100644 index 00000000000..4ef7e1632d1 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts @@ -0,0 +1,67 @@ +import { toast } from "@superset/ui/sonner"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +/** + * Mutation hook for opening an existing worktree as a new workspace + * Automatically invalidates all workspace queries on success + * Creates a terminal tab with setup commands if present + * Shows config toast if no setup commands are configured + */ +export function useOpenWorktree( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + const addTab = useTabsStore((state) => state.addTab); + const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); + const createOrAttach = trpc.terminal.createOrAttach.useMutation(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); + + return trpc.workspaces.openWorktree.useMutation({ + ...options, + onSuccess: async (data, ...rest) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + // Invalidate project queries since openWorktree updates project metadata + await utils.projects.getRecents.invalidate(); + + const initialCommands = + Array.isArray(data.initialCommands) && data.initialCommands.length > 0 + ? data.initialCommands + : undefined; + + // Always create a terminal tab when opening a worktree + const { tabId, paneId } = addTab(data.workspace.id); + if (initialCommands) { + setTabAutoTitle(tabId, "Workspace Setup"); + } + // Pre-create terminal session (with initial commands if present) + // Terminal component will attach to this session when it mounts + createOrAttach.mutate({ + paneId, + tabId, + workspaceId: data.workspace.id, + initialCommands, + }); + + if (!initialCommands) { + // Show config toast if no setup commands + toast.info("No setup script configured", { + description: "Automate workspace setup with a config.json file", + action: { + label: "Configure", + onClick: () => openConfigModal(data.projectId), + }, + onDismiss: () => { + dismissConfigToast.mutate({ projectId: data.projectId }); + }, + }); + } + + // Call user's onSuccess if provided + await options?.onSuccess?.(data, ...rest); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx index 79d19c65acc..99f67544224 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx @@ -1,16 +1,19 @@ import { AlertDialog, - AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { trpc } from "renderer/lib/trpc"; -import { useDeleteWorkspace } from "renderer/react-query/workspaces"; +import { + useCloseWorkspace, + useDeleteWorkspace, +} from "renderer/react-query/workspaces"; interface DeleteWorkspaceDialogProps { workspaceId: string; @@ -26,18 +29,17 @@ export function DeleteWorkspaceDialog({ onOpenChange, }: DeleteWorkspaceDialogProps) { const deleteWorkspace = useDeleteWorkspace(); + const closeWorkspace = useCloseWorkspace(); - // Initial query for git status (expensive) - only runs once when dialog opens const { data: gitStatusData, isLoading: isLoadingGitStatus } = trpc.workspaces.canDelete.useQuery( { id: workspaceId }, { enabled: open, - staleTime: Number.POSITIVE_INFINITY, // Don't refetch automatically + staleTime: Number.POSITIVE_INFINITY, }, ); - // Polling query for terminal count only (cheap) - skips git checks const { data: terminalCountData } = trpc.workspaces.canDelete.useQuery( { id: workspaceId, skipGitChecks: true }, { @@ -46,7 +48,6 @@ export function DeleteWorkspaceDialog({ }, ); - // Merge the data: use git status from initial query, terminal count from polling const canDeleteData = gitStatusData ? { ...gitStatusData, @@ -57,98 +58,130 @@ export function DeleteWorkspaceDialog({ : terminalCountData; const isLoading = isLoadingGitStatus; + const handleClose = () => { + onOpenChange(false); + + toast.promise(closeWorkspace.mutateAsync({ id: workspaceId }), { + loading: "Closing...", + success: (result) => { + if (result.terminalWarning) { + setTimeout(() => { + toast.warning("Terminal warning", { + description: result.terminalWarning, + }); + }, 100); + } + return "Workspace closed"; + }, + error: (error) => + error instanceof Error ? error.message : "Failed to close", + }); + }; + const handleDelete = () => { onOpenChange(false); toast.promise(deleteWorkspace.mutateAsync({ id: workspaceId }), { - loading: `Deleting "${workspaceName}"...`, + loading: "Deleting...", success: (result) => { if (result.teardownError || result.terminalWarning) { setTimeout(() => { if (result.teardownError) { - toast.warning("Workspace deleted with teardown warning", { + toast.warning("Teardown warning", { description: result.teardownError, }); } if (result.terminalWarning) { - toast.warning("Workspace deleted with terminal warning", { + toast.warning("Terminal warning", { description: result.terminalWarning, }); } }, 100); } - return `Workspace "${workspaceName}" deleted`; + return "Workspace deleted"; }, error: (error) => - error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", + error instanceof Error ? error.message : "Failed to delete", }); }; const canDelete = canDeleteData?.canDelete ?? true; const reason = canDeleteData?.reason; - const warning = canDeleteData?.warning; - const activeTerminalCount = canDeleteData?.activeTerminalCount ?? 0; const hasChanges = canDeleteData?.hasChanges ?? false; const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; + const hasWarnings = hasChanges || hasUnpushedCommits; return ( - - - Delete Workspace - + + + + Close workspace? + + {isLoading ? ( - Checking workspace status... + "Checking status..." ) : !canDelete ? ( - - Cannot delete workspace: {reason} - + {reason} ) : ( - <> - Are you sure you want to delete "{workspaceName}"? - {warning && ( - - Warning: {warning} - - )} - {hasChanges && ( - - This workspace has uncommitted changes that will be lost. - - )} - {hasUnpushedCommits && ( - - This workspace has unpushed commits that will be lost. - - )} - {activeTerminalCount > 0 && ( - - {activeTerminalCount} active terminal - {activeTerminalCount === 1 ? "" : "s"} will be terminated. - - )} - - This will remove the workspace and its associated git - worktree. This action cannot be undone. - - + {workspaceName} )} - - Cancel - { - e.preventDefault(); - handleDelete(); - }} - disabled={!canDelete || isLoading} - className="bg-destructive text-white hover:bg-destructive/90" + + {!isLoading && canDelete && hasWarnings && ( +
+
+ {hasChanges && hasUnpushedCommits + ? "Has uncommitted changes and unpushed commits" + : hasChanges + ? "Has uncommitted changes" + : "Has unpushed commits"} +
+
+ )} + + + + + + + + + Hide from tabs. Worktree stays on disk and can be reopened later. + + + + + + + + Permanently delete workspace and git worktree from disk. + +