From ac905b7c8d7289406e5d7c4f841f91ddc9d6fba6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 1 Aug 2026 10:55:47 -0700 Subject: [PATCH] feat(desktop,host-service): add right-click import of untracked worktrees - Project context menu gains 'Import untracked worktrees', opening a confirmation dialog listing each orphan worktree (branch + path) with an opt-in 'Run setup script' checkbox (off by default) - listProjectWorktrees now annotates entries with hasWorkspace (matched by branch or normalized path) and isMainWorktree - workspaces.create gains an optional runSetup flag; false skips the setup terminal (import default) --- .../DashboardSidebarProjectSection.tsx | 23 ++++ .../DashboardSidebarProjectContextMenu.tsx | 7 ++ .../ImportWorktreesDialog.tsx | 116 ++++++++++++++++++ .../components/ImportWorktreesDialog/index.ts | 2 + ...seDashboardSidebarProjectSectionActions.ts | 104 +++++++++++++++- .../procedures/list-project-worktrees.ts | 51 +++++++- .../src/trpc/router/workspaces/workspaces.ts | 5 +- 7 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ImportWorktreesDialog/ImportWorktreesDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ImportWorktreesDialog/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 71fe510351f..27cad9eea95 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -10,6 +10,7 @@ import { DashboardSidebarCollapsedProjectContent } from "./components/DashboardS import { DashboardSidebarExpandedProjectContent } from "./components/DashboardSidebarExpandedProjectContent"; import { DashboardSidebarProjectContextMenu } from "./components/DashboardSidebarProjectContextMenu"; import { DashboardSidebarProjectRow } from "./components/DashboardSidebarProjectRow"; +import { ImportWorktreesDialog } from "./components/ImportWorktreesDialog"; import { useDashboardSidebarProjectSectionActions } from "./hooks/useDashboardSidebarProjectSectionActions"; interface DashboardSidebarProjectSectionProps { @@ -40,15 +41,20 @@ export function DashboardSidebarProjectSection({ const { cancelRename, + confirmImportWorktrees, confirmRemoveFromSidebar, deleteSection, + handleImportWorktrees, handleNewSection, handleNewWorkspace, handleOpenInFinder, handleOpenSettings, + importableWorktrees, + isImportingWorktrees, isRenaming, renameSection, renameValue, + setImportableWorktrees, setRenameValue, startRename, submitRename, @@ -59,10 +65,24 @@ export function DashboardSidebarProjectSection({ const totalWorkspaceCount = flattenedCollapsedWorkspaces.length; + // Rendered only while open so the checkbox state resets per invocation. + const importWorktreesDialog = importableWorktrees && ( + { + if (!open) setImportableWorktrees(null); + }} + onConfirm={confirmImportWorktrees} + /> + ); + if (isSidebarCollapsed) { return ( onToggleCollapse(project.id)} /> + {importWorktreesDialog} ); @@ -89,6 +110,7 @@ export function DashboardSidebarProjectSection({
)} + {importWorktreesDialog}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx index c7f93cdd38a..74d242e5915 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx @@ -6,6 +6,7 @@ import { ContextMenuTrigger, } from "@superset/ui/context-menu"; import { + LuFolderInput, LuFolderOpen, LuFolderPlus, LuPencil, @@ -15,6 +16,7 @@ import { interface DashboardSidebarProjectContextMenuProps { onCreateSection: () => void; + onImportWorktrees: () => void; onOpenInFinder: () => void; onOpenSettings: () => void; onRemoveFromSidebar: () => void; @@ -24,6 +26,7 @@ interface DashboardSidebarProjectContextMenuProps { export function DashboardSidebarProjectContextMenu({ onCreateSection, + onImportWorktrees, onOpenInFinder, onOpenSettings, onRemoveFromSidebar, @@ -51,6 +54,10 @@ export function DashboardSidebarProjectContextMenu({ New group + + + Import untracked worktrees + void; + onConfirm: (options: { runSetup: boolean }) => void; +} + +export function ImportWorktreesDialog({ + open, + worktrees, + isImporting, + onOpenChange, + onConfirm, +}: ImportWorktreesDialogProps) { + const [runSetup, setRunSetup] = useState(false); + const count = worktrees.length; + + return ( + + + + Import untracked worktrees + + {count === 1 + ? "1 worktree in this repository has no workspace. It will be imported as a workspace." + : `${count} worktrees in this repository have no workspace. They will be imported as workspaces.`} + + +
    + {worktrees.map((worktree) => ( +
  • + +
    + + {worktree.branch} + + + {worktree.path} + +
    +
  • + ))} +
+
+ setRunSetup(checked === true)} + disabled={isImporting} + /> + +
+ + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ImportWorktreesDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ImportWorktreesDialog/index.ts new file mode 100644 index 00000000000..b5e27e7b3a1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ImportWorktreesDialog/index.ts @@ -0,0 +1,2 @@ +export type { ImportableWorktree } from "./ImportWorktreesDialog"; +export { ImportWorktreesDialog } from "./ImportWorktreesDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 224ff8ee102..f2bebf0610f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -1,7 +1,7 @@ import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -10,7 +10,9 @@ import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; import type { DashboardSidebarProject } from "../../../../types"; +import type { ImportableWorktree } from "../../components/ImportWorktreesDialog"; interface UseDashboardSidebarProjectSectionActionsOptions { project: DashboardSidebarProject; @@ -36,6 +38,13 @@ export function useDashboardSidebarProjectSectionActions({ // undefined (not null) when no host serves it — null would resolve to // the local host and rename the wrong replica. const servingHostUrl = useHostUrl(servingHostId ?? undefined); + const { submit } = useWorkspaceCreates(); + const importingWorktreesRef = useRef(false); + // Non-null while the import confirmation dialog is open. + const [importableWorktrees, setImportableWorktrees] = useState< + ImportableWorktree[] | null + >(null); + const [isImportingWorktrees, setIsImportingWorktrees] = useState(false); const { requestSectionRename } = useDashboardSidebarSectionRename(); const { createSection, @@ -124,6 +133,94 @@ export function useDashboardSidebarProjectSectionActions({ openModal(project.id); }; + // Menu action: list the worktrees git knows about that have no workspace + // row yet and open the confirmation dialog. + const handleImportWorktrees = async () => { + if (importingWorktreesRef.current) return; + if (!servingHostUrl) { + toast.error( + "Project's host is unreachable — cannot import worktrees right now", + ); + return; + } + try { + const { worktrees } = await getHostServiceClientByUrl( + servingHostUrl, + ).workspaceCreation.listProjectWorktrees.query({ + projectId: project.id, + }); + // `=== false` also skips hosts too old to report tracked-ness. + const untracked = worktrees.filter( + (worktree) => + worktree.hasWorkspace === false && worktree.isMainWorktree === false, + ); + if (untracked.length === 0) { + toast.info("All of this project's worktrees are already tracked"); + return; + } + setImportableWorktrees(untracked); + } catch (error) { + toast.error( + `Failed to list worktrees: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + // Dialog confirm: adopt each worktree via the `worktreePath` branch of + // `workspaces.create` (no `git worktree add`; setup only when opted in). + const confirmImportWorktrees = async ({ + runSetup, + }: { + runSetup: boolean; + }) => { + const untracked = importableWorktrees; + if (importingWorktreesRef.current || !untracked) return; + if (!servingHostId) { + toast.error( + "Project's host is unreachable — cannot import worktrees right now", + ); + return; + } + importingWorktreesRef.current = true; + setIsImportingWorktrees(true); + try { + const outcomes = await Promise.all( + untracked.map( + (worktree) => + submit({ + hostId: servingHostId, + snapshot: { + id: crypto.randomUUID(), + projectId: project.id, + branch: worktree.branch, + worktreePath: worktree.path, + runSetup, + }, + }).completed, + ), + ); + const errors = outcomes.flatMap((outcome) => + outcome.ok ? [] : [outcome.error], + ); + const imported = outcomes.length - errors.length; + if (errors.length > 0) { + toast.error( + `Imported ${imported} of ${untracked.length} worktrees: ${errors[0]}`, + ); + } else { + toast.success( + imported === 1 + ? "Imported 1 worktree as a workspace" + : `Imported ${imported} worktrees as workspaces`, + ); + } + setImportableWorktrees(null); + } finally { + importingWorktreesRef.current = false; + setIsImportingWorktrees(false); + } + }; + const handleNewSection = () => { const sectionId = createSection(project.id); requestSectionRename(sectionId); @@ -134,15 +231,20 @@ export function useDashboardSidebarProjectSectionActions({ return { cancelRename, + confirmImportWorktrees, confirmRemoveFromSidebar, deleteSection, + handleImportWorktrees, handleNewSection, handleNewWorkspace, handleOpenInFinder, handleOpenSettings, + importableWorktrees, + isImportingWorktrees, isRenaming, renameSection, renameValue, + setImportableWorktrees, setRenameValue, startRename, submitRename, diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts index 4eafc39fae5..554f600b7b1 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/list-project-worktrees.ts @@ -1,14 +1,20 @@ +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { workspaces } from "../../../../db/schema"; import { protectedProcedure } from "../../../index"; import { requireLocalProject } from "../shared/local-project"; -import { listGitWorktrees } from "../shared/worktree-list"; +import { + listGitWorktrees, + normalizeWorktreePath, +} from "../shared/worktree-list"; /** * Returns the live `git worktree list` for a project — only entries that * are valid adoption targets (have a real branch checked out, not bare, - * not prunable). Used by the v1→v2 importer to filter out v1 workspaces - * whose worktree no longer exists on disk before showing them as - * importable rows. + * not prunable) — annotated with whether a workspace row already tracks + * them. Used by the v1→v2 importer to filter out v1 workspaces whose + * worktree no longer exists on disk, and by the sidebar's "Import + * Worktrees" action to find untracked worktrees. */ export const listProjectWorktrees = protectedProcedure .input(z.object({ projectId: z.string() })) @@ -16,12 +22,45 @@ export const listProjectWorktrees = protectedProcedure const localProject = requireLocalProject(ctx, input.projectId); const git = await ctx.git(localProject.repoPath); const records = await listGitWorktrees(git); - const worktrees: { branch: string; path: string }[] = []; + + // Tracking key is (projectId, branch) — same as searchBranches — but + // also match by normalized path so a tracked worktree whose branch was + // renamed out from under its row isn't offered for re-import. + const workspaceRows = ctx.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); + const workspaceBranches = new Set( + workspaceRows.map((row) => row.branch).filter(Boolean), + ); + const workspacePaths = new Set( + workspaceRows + .map((row) => row.worktreePath) + .filter(Boolean) + .map(normalizeWorktreePath), + ); + const mainRepoPath = normalizeWorktreePath(localProject.repoPath); + + const worktrees: { + branch: string; + path: string; + hasWorkspace: boolean; + isMainWorktree: boolean; + }[] = []; for (const record of records) { if (record.bare) continue; if (record.prunable) continue; if (!record.branch) continue; - worktrees.push({ branch: record.branch, path: record.path }); + const normalizedPath = normalizeWorktreePath(record.path); + worktrees.push({ + branch: record.branch, + path: record.path, + hasWorkspace: + workspaceBranches.has(record.branch) || + workspacePaths.has(normalizedPath), + isMainWorktree: normalizedPath === mainRepoPath, + }); } return { worktrees }; }); diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 52d2869f718..f4c4c5f6c2a 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -106,6 +106,9 @@ const createInputSchema = z // inferring the path from `branch`. When present, `branch` is // caller context only; the server reads the current branch from git. worktreePath: z.string().min(1).optional(), + // When false, skip the setup terminal. Used by worktree import, + // where the worktree is usually already set up. + runSetup: z.boolean().optional(), }) .refine((value) => !(value.branch && value.pr), { message: "`branch` and `pr` cannot both be set", @@ -1030,7 +1033,7 @@ export const workspacesRouter = router({ } let chainedAgentResult: AgentLaunchResult | null = null; - if (!alreadyExists) { + if (!alreadyExists && input.runSetup !== false) { const { terminal, warning, chained } = await startSetupTerminalIfPresent({ ctx,