From 5ac77365f83521cbe335ab0ae17cb2bd038ccd0e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 14:01:02 -0800 Subject: [PATCH 01/11] feat(desktop): show existing disk worktrees in Open Workspace modal Add support for discovering and opening git worktrees that exist on disk but aren't tracked in the app's database. This allows users to open worktrees created via git CLI directly from the "Existing" tab. --- .../routers/workspaces/procedures/create.ts | 135 ++++++++++++++++++ .../workspaces/procedures/git-status.ts | 34 +++++ .../lib/trpc/routers/workspaces/utils/git.ts | 53 +++++++ .../ExistingWorktreesList.tsx | 41 +++++- .../components/DiskWorktreesSection.tsx | 53 +++++++ .../ExistingWorktreesList/components/index.ts | 1 + .../renderer/react-query/workspaces/index.ts | 1 + .../workspaces/useOpenDiskWorktree.ts | 62 ++++++++ 8 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx create mode 100644 apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 72154634f12..2eeb17e70d5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -615,6 +615,141 @@ export const createCreateProcedures = () => { }; }), + openDiskWorktree: publicProcedure + .input( + z.object({ + projectId: z.string(), + worktreePath: z.string(), + branch: z.string(), + }), + ) + .mutation(async ({ input }) => { + const project = getProject(input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const exists = await worktreeExists( + project.mainRepoPath, + input.worktreePath, + ); + if (!exists) { + throw new Error("Worktree no longer exists on disk"); + } + + const existingWorktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, input.worktreePath)) + .get(); + + if (existingWorktree) { + const existingWorkspace = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.worktreeId, existingWorktree.id), + isNull(workspaces.deletingAt), + ), + ) + .get(); + + if (existingWorkspace) { + touchWorkspace(existingWorkspace.id); + setLastActiveWorkspace(existingWorkspace.id); + return { + workspace: existingWorkspace, + initialCommands: null, + worktreePath: existingWorktree.path, + projectId: project.id, + wasExisting: true, + }; + } + + const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + worktreeId: existingWorktree.id, + type: "worktree", + branch: existingWorktree.branch, + name: existingWorktree.branch, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + const setupConfig = loadSetupConfig(project.mainRepoPath); + + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "worktree", + source: "disk_import", + }); + + return { + workspace, + initialCommands: setupConfig?.setup || null, + worktreePath: existingWorktree.path, + projectId: project.id, + wasExisting: false, + }; + } + + const defaultBranch = project.defaultBranch || "main"; + const worktree = localDb + .insert(worktrees) + .values({ + projectId: input.projectId, + path: input.worktreePath, + branch: input.branch, + baseBranch: defaultBranch, + gitStatus: null, + }) + .returning() + .get(); + + const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + worktreeId: worktree.id, + type: "worktree", + branch: input.branch, + name: input.branch, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + const setupConfig = loadSetupConfig(project.mainRepoPath); + + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + branch: input.branch, + source: "disk_import", + }); + + return { + workspace, + initialCommands: setupConfig?.setup || null, + worktreePath: input.worktreePath, + projectId: project.id, + wasExisting: false, + }; + }), + createFromPr: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 85688e7050a..cf783b7e970 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -13,6 +13,7 @@ import { checkNeedsRebase, fetchDefaultBranch, getDefaultBranch, + listDiskWorktrees, refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; @@ -166,5 +167,38 @@ export const createGitStatusProcedures = () => { }; }); }), + + getUntrackedDiskWorktrees: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input }) => { + const project = getProject(input.projectId); + if (!project) { + return []; + } + + const diskWorktrees = await listDiskWorktrees(project.mainRepoPath); + + const trackedWorktrees = localDb + .select({ path: worktrees.path }) + .from(worktrees) + .where(eq(worktrees.projectId, input.projectId)) + .all(); + const trackedPaths = new Set(trackedWorktrees.map((wt) => wt.path)); + + return diskWorktrees + .filter((dw) => { + if (dw.path === project.mainRepoPath) return false; + if (dw.isBare) return false; + if (dw.isDetached) return false; + if (!dw.branch) return false; + if (trackedPaths.has(dw.path)) return false; + return true; + }) + .map((dw) => ({ + path: dw.path, + // biome-ignore lint/style/noNonNullAssertion: filtered above + branch: dw.branch!, + })); + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 0377366a237..29292fad7df 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -720,6 +720,59 @@ export async function worktreeExists( } } +export interface DiskWorktree { + path: string; + branch: string | null; + isDetached: boolean; + isBare: boolean; +} + +export async function listDiskWorktrees( + mainRepoPath: string, +): Promise { + try { + const git = simpleGit(mainRepoPath); + const output = await git.raw(["worktree", "list", "--porcelain"]); + + const result: DiskWorktree[] = []; + let current: Partial = {}; + + for (const line of output.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) { + result.push({ + path: current.path, + branch: current.branch ?? null, + isDetached: current.isDetached ?? false, + isBare: current.isBare ?? false, + }); + } + current = { path: line.slice("worktree ".length) }; + } else if (line.startsWith("branch refs/heads/")) { + current.branch = line.slice("branch refs/heads/".length); + } else if (line === "detached") { + current.isDetached = true; + } else if (line === "bare") { + current.isBare = true; + } + } + + if (current.path) { + result.push({ + path: current.path, + branch: current.branch ?? null, + isDetached: current.isDetached ?? false, + isBare: current.isBare ?? false, + }); + } + + return result; + } catch (error) { + console.error(`Failed to list disk worktrees: ${error}`); + throw error; + } +} + /** * Checks if a branch is already checked out in a worktree. * @param mainRepoPath - Path to the main repository diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index fe3b3a3f266..3590d582683 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -4,9 +4,15 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateFromPr, useCreateWorkspace, + useOpenDiskWorktree, useOpenWorktree, } from "renderer/react-query/workspaces"; -import { BranchesSection, PrUrlSection, WorktreesSection } from "./components"; +import { + BranchesSection, + DiskWorktreesSection, + PrUrlSection, + WorktreesSection, +} from "./components"; interface ExistingWorktreesListProps { projectId: string; @@ -19,9 +25,12 @@ export function ExistingWorktreesList({ }: ExistingWorktreesListProps) { const { data: worktrees = [], isLoading: isWorktreesLoading } = electronTrpc.workspaces.getWorktreesByProject.useQuery({ projectId }); + const { data: diskWorktrees = [], isLoading: isDiskWorktreesLoading } = + electronTrpc.workspaces.getUntrackedDiskWorktrees.useQuery({ projectId }); const { data: branchData, isLoading: isBranchesLoading } = electronTrpc.projects.getBranches.useQuery({ projectId }); const openWorktree = useOpenWorktree(); + const openDiskWorktree = useOpenDiskWorktree(); const createWorkspace = useCreateWorkspace(); const createFromPr = useCreateFromPr(); @@ -139,9 +148,26 @@ export function ExistingWorktreesList({ } }; - const isLoading = isWorktreesLoading || isBranchesLoading; + const handleOpenDiskWorktree = async (path: string, branch: string) => { + toast.promise( + openDiskWorktree.mutateAsync({ projectId, worktreePath: path, branch }), + { + loading: "Opening workspace...", + success: () => { + onOpenSuccess(); + return `Opened ${branch}`; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to open workspace", + }, + ); + }; + + const isLoading = + isWorktreesLoading || isDiskWorktreesLoading || isBranchesLoading; const isPending = openWorktree.isPending || + openDiskWorktree.isPending || createWorkspace.isPending || createFromPr.isPending; @@ -154,6 +180,7 @@ export function ExistingWorktreesList({ } const hasWorktrees = closedWorktrees.length > 0 || openWorktrees.length > 0; + const hasDiskWorktrees = diskWorktrees.length > 0; const hasBranches = branchesWithoutWorktrees.length > 0; return ( @@ -188,7 +215,15 @@ export function ExistingWorktreesList({ /> )} - {!hasWorktrees && !hasBranches && ( + {hasDiskWorktrees && ( + + )} + + {!hasWorktrees && !hasDiskWorktrees && !hasBranches && (
No existing worktrees or branches.
diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx new file mode 100644 index 00000000000..00d958cf3a0 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx @@ -0,0 +1,53 @@ +import { LuGitBranch } from "react-icons/lu"; + +interface DiskWorktree { + path: string; + branch: string; +} + +interface DiskWorktreesSectionProps { + diskWorktrees: DiskWorktree[]; + onOpenWorktree: (path: string, branch: string) => void; + disabled: boolean; +} + +export function DiskWorktreesSection({ + diskWorktrees, + onOpenWorktree, + disabled, +}: DiskWorktreesSectionProps) { + return ( +
+
+
+
+ Disk Worktrees +
+
+ + {diskWorktrees.map((wt) => { + const folderName = wt.path.split("/").pop() ?? wt.branch; + return ( + + ); + })} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts index 614964b4141..260da603752 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts @@ -1,3 +1,4 @@ export { BranchesSection } from "./BranchesSection"; +export { DiskWorktreesSection } from "./DiskWorktreesSection"; export { PrUrlSection } from "./PrUrlSection"; export { WorktreesSection } from "./WorktreesSection"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index dae7653439e..55ef7ab4a5e 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -4,6 +4,7 @@ export { useCreateFromPr } from "./useCreateFromPr"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useDeleteWorktree } from "./useDeleteWorktree"; +export { useOpenDiskWorktree } from "./useOpenDiskWorktree"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts new file mode 100644 index 00000000000..1a5fc29ceff --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts @@ -0,0 +1,62 @@ +import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { useOpenConfigModal } from "renderer/stores/config-modal"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +export function useOpenDiskWorktree( + options?: Parameters< + typeof electronTrpc.workspaces.openDiskWorktree.useMutation + >[0], +) { + const navigate = useNavigate(); + const utils = electronTrpc.useUtils(); + const addTab = useTabsStore((state) => state.addTab); + const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); + const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation(); + const openConfigModal = useOpenConfigModal(); + const dismissConfigToast = + electronTrpc.config.dismissConfigToast.useMutation(); + + return electronTrpc.workspaces.openDiskWorktree.useMutation({ + ...options, + onSuccess: async (data, ...rest) => { + await utils.workspaces.invalidate(); + await utils.projects.getRecents.invalidate(); + + const initialCommands = + Array.isArray(data.initialCommands) && data.initialCommands.length > 0 + ? data.initialCommands + : undefined; + + const { tabId, paneId } = addTab(data.workspace.id); + if (initialCommands) { + setTabAutoTitle(tabId, "Workspace Setup"); + } + createOrAttach.mutate({ + paneId, + tabId, + workspaceId: data.workspace.id, + initialCommands, + }); + + if (!initialCommands) { + 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 }); + }, + }); + } + + navigateToWorkspace(data.workspace.id, navigate); + + await options?.onSuccess?.(data, ...rest); + }, + }); +} From 67248fb46de00e95811f49c685bbe16a7d578f4b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 14:43:08 -0800 Subject: [PATCH 02/11] fix(desktop): improve disk worktrees UI with smaller text and path below Use smaller text size and show the full path on a separate line below the branch name for better readability and proper truncation. --- .../components/DiskWorktreesSection.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx index 00d958cf3a0..9062162609b 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx @@ -25,29 +25,26 @@ export function DiskWorktreesSection({
- {diskWorktrees.map((wt) => { - const folderName = wt.path.split("/").pop() ?? wt.branch; - return ( - - ); - })} + {wt.path} + + + + ))} ); } From 71766f934e591b89db223e635430d68046678299 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 14:46:29 -0800 Subject: [PATCH 03/11] refactor(desktop): convert worktrees sections to selector pattern Update WorktreesSection and DiskWorktreesSection to use popover/command pattern like BranchesSection for consistent UI. Shows branch name and path on separate lines with proper truncation and search functionality. --- .../ExistingWorktreesList.tsx | 39 ++--- .../components/DiskWorktreesSection.tsx | 105 +++++++++--- .../components/WorktreesSection.tsx | 162 ++++++++++++------ 3 files changed, 209 insertions(+), 97 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index 3590d582683..52b4e85f23d 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -36,6 +36,10 @@ export function ExistingWorktreesList({ const [branchOpen, setBranchOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); + const [worktreeOpen, setWorktreeOpen] = useState(false); + const [worktreeSearch, setWorktreeSearch] = useState(""); + const [diskWorktreeOpen, setDiskWorktreeOpen] = useState(false); + const [diskWorktreeSearch, setDiskWorktreeSearch] = useState(""); const [prUrl, setPrUrl] = useState(""); const closedWorktrees = worktrees @@ -62,6 +66,8 @@ export function ExistingWorktreesList({ }, [branchesWithoutWorktrees, branchSearch]); const handleOpenWorktree = async (worktreeId: string, branch: string) => { + setWorktreeOpen(false); + setWorktreeSearch(""); toast.promise(openWorktree.mutateAsync({ worktreeId }), { loading: "Opening workspace...", success: () => { @@ -73,28 +79,6 @@ export function ExistingWorktreesList({ }); }; - const handleOpenAll = async () => { - if (closedWorktrees.length === 0) return; - - const count = closedWorktrees.length; - toast.promise( - (async () => { - for (const wt of closedWorktrees) { - await openWorktree.mutateAsync({ worktreeId: wt.id }); - } - })(), - { - loading: `Opening ${count} workspaces...`, - success: () => { - onOpenSuccess(); - return `Opened ${count} workspaces`; - }, - error: (err) => - err instanceof Error ? err.message : "Failed to open workspaces", - }, - ); - }; - const handleCreateFromBranch = async (branchName: string) => { setBranchOpen(false); setBranchSearch(""); @@ -149,6 +133,8 @@ export function ExistingWorktreesList({ }; const handleOpenDiskWorktree = async (path: string, branch: string) => { + setDiskWorktreeOpen(false); + setDiskWorktreeSearch(""); toast.promise( openDiskWorktree.mutateAsync({ projectId, worktreePath: path, branch }), { @@ -209,8 +195,11 @@ export function ExistingWorktreesList({ )} @@ -218,6 +207,10 @@ export function ExistingWorktreesList({ {hasDiskWorktrees && ( diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx index 9062162609b..4ef5017fe65 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx @@ -1,3 +1,13 @@ +import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { HiChevronUpDown } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; interface DiskWorktree { @@ -7,44 +17,89 @@ interface DiskWorktree { interface DiskWorktreesSectionProps { diskWorktrees: DiskWorktree[]; + searchValue: string; + onSearchChange: (value: string) => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; onOpenWorktree: (path: string, branch: string) => void; disabled: boolean; } export function DiskWorktreesSection({ diskWorktrees, + searchValue, + onSearchChange, + isOpen, + onOpenChange, onOpenWorktree, disabled, }: DiskWorktreesSectionProps) { + const filteredWorktrees = searchValue + ? diskWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchValue.toLowerCase()) || + wt.path.toLowerCase().includes(searchValue.toLowerCase()), + ) + : diskWorktrees; + return ( -
+
-
-
- Disk Worktrees -
+
+ Disk Worktrees
- - {diskWorktrees.map((wt) => ( - + + e.stopPropagation()} > - -
-
{wt.branch}
-
- {wt.path} -
-
- - ))} + + + + No disk worktrees found + {filteredWorktrees.map((wt) => ( + onOpenWorktree(wt.path, wt.branch)} + className="flex flex-col items-start gap-0.5" + > + + + + {wt.branch} + + + + {wt.path} + + + ))} + + +
+
); } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx index 69dd64e576b..249aded5fa4 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx @@ -1,10 +1,21 @@ import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { formatDistanceToNow } from "date-fns"; +import { HiChevronUpDown } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; interface Worktree { id: string; branch: string; + path: string; createdAt: number; hasActiveWorkspace: boolean; } @@ -12,73 +23,126 @@ interface Worktree { interface WorktreesSectionProps { closedWorktrees: Worktree[]; openWorktrees: Worktree[]; + searchValue: string; + onSearchChange: (value: string) => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; onOpenWorktree: (worktreeId: string, branch: string) => void; - onOpenAll: () => void; disabled: boolean; } export function WorktreesSection({ closedWorktrees, openWorktrees, + searchValue, + onSearchChange, + isOpen, + onOpenChange, onOpenWorktree, - onOpenAll, disabled, }: WorktreesSectionProps) { + const allWorktrees = [...closedWorktrees, ...openWorktrees]; + const filteredWorktrees = searchValue + ? allWorktrees.filter((wt) => + wt.branch.toLowerCase().includes(searchValue.toLowerCase()), + ) + : allWorktrees; + + const filteredClosed = filteredWorktrees.filter( + (wt) => !wt.hasActiveWorkspace, + ); + const filteredOpen = filteredWorktrees.filter((wt) => wt.hasActiveWorkspace); + return ( -
+
-
-
- Worktrees -
- {closedWorktrees.length > 1 && ( +
+ Worktrees +
+ + - )} -
- - {closedWorktrees.map((wt) => ( - - ))} - - {openWorktrees.length > 0 && ( -
-
- Already open -
- {openWorktrees.map((wt) => ( -
- - - {wt.branch} - - open -
- ))} -
- )} + + + + No worktrees found + {filteredClosed.length > 0 && ( + + {filteredClosed.map((wt) => ( + onOpenWorktree(wt.id, wt.branch)} + className="flex flex-col items-start gap-0.5" + > + + + + {wt.branch} + + + {formatDistanceToNow(wt.createdAt, { + addSuffix: false, + })} + + + + {wt.path} + + + ))} + + )} + {filteredOpen.length > 0 && ( + + {filteredOpen.map((wt) => ( + + + + + {wt.branch} + + open + + + {wt.path} + + + ))} + + )} + + + +
); } From 0489ea18db2801ba897b565b4f18c397c6a283e3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 15:21:42 -0800 Subject: [PATCH 04/11] fix(desktop): align disk worktrees UI with worktrees dropdown --- .../components/DiskWorktreesSection.tsx | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx index 4ef5017fe65..2a7313d9016 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx @@ -2,6 +2,7 @@ import { Button } from "@superset/ui/button"; import { Command, CommandEmpty, + CommandGroup, CommandInput, CommandItem, CommandList, @@ -78,24 +79,28 @@ export function DiskWorktreesSection({ /> No disk worktrees found - {filteredWorktrees.map((wt) => ( - onOpenWorktree(wt.path, wt.branch)} - className="flex flex-col items-start gap-0.5" - > - - - - {wt.branch} - - - - {wt.path} - - - ))} + {filteredWorktrees.length > 0 && ( + + {filteredWorktrees.map((wt) => ( + onOpenWorktree(wt.path, wt.branch)} + className="flex flex-col items-start gap-0.5" + > + + + + {wt.branch} + + + + {wt.path} + + + ))} + + )} From 425a89313d6a5723780baff5c87eb46d9097433f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 16:01:40 -0800 Subject: [PATCH 05/11] fix(desktop): rename worktrees sections for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worktrees → Superset Worktrees - Disk Worktrees → External Worktrees --- .../components/DiskWorktreesSection.tsx | 8 ++++---- .../ExistingWorktreesList/components/WorktreesSection.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx index 2a7313d9016..d574ffa170e 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx @@ -47,7 +47,7 @@ export function DiskWorktreesSection({
- Disk Worktrees + External Worktrees
@@ -60,7 +60,7 @@ export function DiskWorktreesSection({ - Select disk worktree... + Select external worktree... @@ -73,12 +73,12 @@ export function DiskWorktreesSection({ > - No disk worktrees found + No external worktrees found {filteredWorktrees.length > 0 && ( {filteredWorktrees.map((wt) => ( diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx index 249aded5fa4..2840d2f56a5 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx @@ -57,7 +57,7 @@ export function WorktreesSection({
- Worktrees + Superset Worktrees
@@ -70,7 +70,7 @@ export function WorktreesSection({ - Select worktree... + Select superset worktree... @@ -83,12 +83,12 @@ export function WorktreesSection({ > - No worktrees found + No superset worktrees found {filteredClosed.length > 0 && ( {filteredClosed.map((wt) => ( From 5b4afd1785c602b699587000a5c546c05298a628 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 16:05:20 -0800 Subject: [PATCH 06/11] refactor(desktop): combine worktree sections into single dropdown Merge Superset Worktrees and External Worktrees into a unified "Worktrees" dropdown with grouped items inside. This reduces visual clutter from 4 sections to 3 in the Existing tab (PR, Branches, Worktrees). --- .../ExistingWorktreesList.tsx | 35 ++---- .../components/DiskWorktreesSection.tsx | 110 ------------------ .../components/WorktreesSection.tsx | 87 +++++++++++--- .../ExistingWorktreesList/components/index.ts | 1 - 4 files changed, 82 insertions(+), 151 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index 52b4e85f23d..c4703fa421f 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -7,12 +7,7 @@ import { useOpenDiskWorktree, useOpenWorktree, } from "renderer/react-query/workspaces"; -import { - BranchesSection, - DiskWorktreesSection, - PrUrlSection, - WorktreesSection, -} from "./components"; +import { BranchesSection, PrUrlSection, WorktreesSection } from "./components"; interface ExistingWorktreesListProps { projectId: string; @@ -38,8 +33,6 @@ export function ExistingWorktreesList({ const [branchSearch, setBranchSearch] = useState(""); const [worktreeOpen, setWorktreeOpen] = useState(false); const [worktreeSearch, setWorktreeSearch] = useState(""); - const [diskWorktreeOpen, setDiskWorktreeOpen] = useState(false); - const [diskWorktreeSearch, setDiskWorktreeSearch] = useState(""); const [prUrl, setPrUrl] = useState(""); const closedWorktrees = worktrees @@ -133,8 +126,8 @@ export function ExistingWorktreesList({ }; const handleOpenDiskWorktree = async (path: string, branch: string) => { - setDiskWorktreeOpen(false); - setDiskWorktreeSearch(""); + setWorktreeOpen(false); + setWorktreeSearch(""); toast.promise( openDiskWorktree.mutateAsync({ projectId, worktreePath: path, branch }), { @@ -165,8 +158,10 @@ export function ExistingWorktreesList({ ); } - const hasWorktrees = closedWorktrees.length > 0 || openWorktrees.length > 0; - const hasDiskWorktrees = diskWorktrees.length > 0; + const hasWorktrees = + closedWorktrees.length > 0 || + openWorktrees.length > 0 || + diskWorktrees.length > 0; const hasBranches = branchesWithoutWorktrees.length > 0; return ( @@ -195,28 +190,18 @@ export function ExistingWorktreesList({ )} - {hasDiskWorktrees && ( - - )} - - {!hasWorktrees && !hasDiskWorktrees && !hasBranches && ( + {!hasWorktrees && !hasBranches && (
No existing worktrees or branches.
diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx deleted file mode 100644 index d574ffa170e..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/DiskWorktreesSection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { HiChevronUpDown } from "react-icons/hi2"; -import { LuGitBranch } from "react-icons/lu"; - -interface DiskWorktree { - path: string; - branch: string; -} - -interface DiskWorktreesSectionProps { - diskWorktrees: DiskWorktree[]; - searchValue: string; - onSearchChange: (value: string) => void; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onOpenWorktree: (path: string, branch: string) => void; - disabled: boolean; -} - -export function DiskWorktreesSection({ - diskWorktrees, - searchValue, - onSearchChange, - isOpen, - onOpenChange, - onOpenWorktree, - disabled, -}: DiskWorktreesSectionProps) { - const filteredWorktrees = searchValue - ? diskWorktrees.filter( - (wt) => - wt.branch.toLowerCase().includes(searchValue.toLowerCase()) || - wt.path.toLowerCase().includes(searchValue.toLowerCase()), - ) - : diskWorktrees; - - return ( -
-
-
- External Worktrees -
- - - - - e.stopPropagation()} - > - - - - No external worktrees found - {filteredWorktrees.length > 0 && ( - - {filteredWorktrees.map((wt) => ( - onOpenWorktree(wt.path, wt.branch)} - className="flex flex-col items-start gap-0.5" - > - - - - {wt.branch} - - - - {wt.path} - - - ))} - - )} - - - - -
- ); -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx index 2840d2f56a5..bd7a27a0be8 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx @@ -20,44 +20,74 @@ interface Worktree { hasActiveWorkspace: boolean; } +interface DiskWorktree { + path: string; + branch: string; +} + interface WorktreesSectionProps { closedWorktrees: Worktree[]; openWorktrees: Worktree[]; + diskWorktrees: DiskWorktree[]; searchValue: string; onSearchChange: (value: string) => void; isOpen: boolean; onOpenChange: (open: boolean) => void; onOpenWorktree: (worktreeId: string, branch: string) => void; + onOpenDiskWorktree: (path: string, branch: string) => void; disabled: boolean; } export function WorktreesSection({ closedWorktrees, openWorktrees, + diskWorktrees, searchValue, onSearchChange, isOpen, onOpenChange, onOpenWorktree, + onOpenDiskWorktree, disabled, }: WorktreesSectionProps) { - const allWorktrees = [...closedWorktrees, ...openWorktrees]; - const filteredWorktrees = searchValue - ? allWorktrees.filter((wt) => - wt.branch.toLowerCase().includes(searchValue.toLowerCase()), + const searchLower = searchValue.toLowerCase(); + + const filteredClosed = searchValue + ? closedWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), ) - : allWorktrees; + : closedWorktrees; - const filteredClosed = filteredWorktrees.filter( - (wt) => !wt.hasActiveWorkspace, - ); - const filteredOpen = filteredWorktrees.filter((wt) => wt.hasActiveWorkspace); + const filteredOpen = searchValue + ? openWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), + ) + : openWorktrees; + + const filteredDisk = searchValue + ? diskWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), + ) + : diskWorktrees; + + const totalCount = + closedWorktrees.length + openWorktrees.length + diskWorktrees.length; + const hasResults = + filteredClosed.length > 0 || + filteredOpen.length > 0 || + filteredDisk.length > 0; return (
- Superset Worktrees + Worktrees
@@ -70,10 +100,15 @@ export function WorktreesSection({ - Select superset worktree... + Select worktree... - + + + {totalCount} + + + - No superset worktrees found + {!hasResults && No worktrees found} {filteredClosed.length > 0 && ( - + {filteredClosed.map((wt) => ( )} + {filteredDisk.length > 0 && ( + + {filteredDisk.map((wt) => ( + onOpenDiskWorktree(wt.path, wt.branch)} + className="flex flex-col items-start gap-0.5" + > + + + + {wt.branch} + + + + {wt.path} + + + ))} + + )} {filteredOpen.length > 0 && ( {filteredOpen.map((wt) => ( diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts index 260da603752..614964b4141 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/index.ts @@ -1,4 +1,3 @@ export { BranchesSection } from "./BranchesSection"; -export { DiskWorktreesSection } from "./DiskWorktreesSection"; export { PrUrlSection } from "./PrUrlSection"; export { WorktreesSection } from "./WorktreesSection"; From 69262fe21fce5820dc793a67bed0a0e43e3d883d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 16:20:04 -0800 Subject: [PATCH 07/11] fix(desktop): filter branches that have external worktrees Branches with external disk worktrees were appearing in both the Branches dropdown and the External worktrees group. Clicking on the branch would try to create a new worktree, causing "Setup incomplete" errors. Now branches are filtered out if they have either a tracked or external worktree. --- .../ExistingWorktreesList/ExistingWorktreesList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index c4703fa421f..ffce775c45d 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -45,10 +45,13 @@ export function ExistingWorktreesList({ const branchesWithoutWorktrees = useMemo(() => { if (!branchData?.branches) return []; const worktreeBranches = new Set(worktrees.map((wt) => wt.branch)); + const diskWorktreeBranches = new Set(diskWorktrees.map((wt) => wt.branch)); return branchData.branches.filter( - (branch) => !worktreeBranches.has(branch.name), + (branch) => + !worktreeBranches.has(branch.name) && + !diskWorktreeBranches.has(branch.name), ); - }, [branchData?.branches, worktrees]); + }, [branchData?.branches, worktrees, diskWorktrees]); const filteredBranches = useMemo(() => { if (!branchSearch) return branchesWithoutWorktrees; From 613fcc2cee17de9d9714bece6bbdb4ac29eefe31 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 17:48:03 -0800 Subject: [PATCH 08/11] fix(desktop): set gitStatus when importing external worktrees Imported worktrees were showing "Setup incomplete" because gitStatus was set to null. The workspace page checks for null gitStatus to detect interrupted initialization. For imported worktrees, we now set a valid gitStatus object so they're recognized as ready immediately. --- .../routers/workspaces/procedures/create.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 2eeb17e70d5..5a55a3f0435 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -644,6 +644,21 @@ export const createCreateProcedures = () => { .get(); if (existingWorktree) { + // Failed init can leave gitStatus null, which shows "Setup incomplete" UI + if (!existingWorktree.gitStatus) { + localDb + .update(worktrees) + .set({ + gitStatus: { + branch: existingWorktree.branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + }) + .where(eq(worktrees.id, existingWorktree.id)) + .run(); + } + const existingWorkspace = localDb .select() .from(workspaces) @@ -710,7 +725,11 @@ export const createCreateProcedures = () => { path: input.worktreePath, branch: input.branch, baseBranch: defaultBranch, - gitStatus: null, + gitStatus: { + branch: input.branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, }) .returning() .get(); From 9029658fac7b2f69393d9e60afea92c23f7959fc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 22:30:19 -0800 Subject: [PATCH 09/11] refactor(desktop): rename diskWorktree to externalWorktree Rename all disk worktree references to external worktree for consistency with the UI labeling. Also fix worktree lookup to filter by projectId to prevent cross-project data inconsistencies. Changes: - DiskWorktree -> ExternalWorktree - listDiskWorktrees -> listExternalWorktrees - getUntrackedDiskWorktrees -> getExternalWorktrees - openDiskWorktree -> openExternalWorktree - useOpenDiskWorktree -> useOpenExternalWorktree - disk_import -> external_import (analytics) - Add projectId filter to worktree lookup query --- .../routers/workspaces/procedures/create.ts | 13 ++++++--- .../workspaces/procedures/git-status.ts | 26 ++++++++--------- .../lib/trpc/routers/workspaces/utils/git.ts | 12 ++++---- .../ExistingWorktreesList.tsx | 28 +++++++++---------- .../components/WorktreesSection.tsx | 26 ++++++++--------- .../renderer/react-query/workspaces/index.ts | 2 +- ...Worktree.ts => useOpenExternalWorktree.ts} | 6 ++-- 7 files changed, 59 insertions(+), 54 deletions(-) rename apps/desktop/src/renderer/react-query/workspaces/{useOpenDiskWorktree.ts => useOpenExternalWorktree.ts} (91%) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 5a55a3f0435..18ca3011021 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -615,7 +615,7 @@ export const createCreateProcedures = () => { }; }), - openDiskWorktree: publicProcedure + openExternalWorktree: publicProcedure .input( z.object({ projectId: z.string(), @@ -640,7 +640,12 @@ export const createCreateProcedures = () => { const existingWorktree = localDb .select() .from(worktrees) - .where(eq(worktrees.path, input.worktreePath)) + .where( + and( + eq(worktrees.projectId, input.projectId), + eq(worktrees.path, input.worktreePath), + ), + ) .get(); if (existingWorktree) { @@ -705,7 +710,7 @@ export const createCreateProcedures = () => { workspace_id: workspace.id, project_id: project.id, type: "worktree", - source: "disk_import", + source: "external_import", }); return { @@ -757,7 +762,7 @@ export const createCreateProcedures = () => { workspace_id: workspace.id, project_id: project.id, branch: input.branch, - source: "disk_import", + source: "external_import", }); return { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index cf783b7e970..c17292d293b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -13,7 +13,7 @@ import { checkNeedsRebase, fetchDefaultBranch, getDefaultBranch, - listDiskWorktrees, + listExternalWorktrees, refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; @@ -168,7 +168,7 @@ export const createGitStatusProcedures = () => { }); }), - getUntrackedDiskWorktrees: publicProcedure + getExternalWorktrees: publicProcedure .input(z.object({ projectId: z.string() })) .query(async ({ input }) => { const project = getProject(input.projectId); @@ -176,7 +176,7 @@ export const createGitStatusProcedures = () => { return []; } - const diskWorktrees = await listDiskWorktrees(project.mainRepoPath); + const allWorktrees = await listExternalWorktrees(project.mainRepoPath); const trackedWorktrees = localDb .select({ path: worktrees.path }) @@ -185,19 +185,19 @@ export const createGitStatusProcedures = () => { .all(); const trackedPaths = new Set(trackedWorktrees.map((wt) => wt.path)); - return diskWorktrees - .filter((dw) => { - if (dw.path === project.mainRepoPath) return false; - if (dw.isBare) return false; - if (dw.isDetached) return false; - if (!dw.branch) return false; - if (trackedPaths.has(dw.path)) return false; + return allWorktrees + .filter((wt) => { + if (wt.path === project.mainRepoPath) return false; + if (wt.isBare) return false; + if (wt.isDetached) return false; + if (!wt.branch) return false; + if (trackedPaths.has(wt.path)) return false; return true; }) - .map((dw) => ({ - path: dw.path, + .map((wt) => ({ + path: wt.path, // biome-ignore lint/style/noNonNullAssertion: filtered above - branch: dw.branch!, + branch: wt.branch!, })); }), }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 29292fad7df..75231c64c4e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -720,22 +720,22 @@ export async function worktreeExists( } } -export interface DiskWorktree { +export interface ExternalWorktree { path: string; branch: string | null; isDetached: boolean; isBare: boolean; } -export async function listDiskWorktrees( +export async function listExternalWorktrees( mainRepoPath: string, -): Promise { +): Promise { try { const git = simpleGit(mainRepoPath); const output = await git.raw(["worktree", "list", "--porcelain"]); - const result: DiskWorktree[] = []; - let current: Partial = {}; + const result: ExternalWorktree[] = []; + let current: Partial = {}; for (const line of output.split("\n")) { if (line.startsWith("worktree ")) { @@ -768,7 +768,7 @@ export async function listDiskWorktrees( return result; } catch (error) { - console.error(`Failed to list disk worktrees: ${error}`); + console.error(`Failed to list external worktrees: ${error}`); throw error; } } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index ffce775c45d..46bd06683ba 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -4,7 +4,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateFromPr, useCreateWorkspace, - useOpenDiskWorktree, + useOpenExternalWorktree, useOpenWorktree, } from "renderer/react-query/workspaces"; import { BranchesSection, PrUrlSection, WorktreesSection } from "./components"; @@ -20,12 +20,12 @@ export function ExistingWorktreesList({ }: ExistingWorktreesListProps) { const { data: worktrees = [], isLoading: isWorktreesLoading } = electronTrpc.workspaces.getWorktreesByProject.useQuery({ projectId }); - const { data: diskWorktrees = [], isLoading: isDiskWorktreesLoading } = - electronTrpc.workspaces.getUntrackedDiskWorktrees.useQuery({ projectId }); + const { data: externalWorktrees = [], isLoading: isExternalWorktreesLoading } = + electronTrpc.workspaces.getExternalWorktrees.useQuery({ projectId }); const { data: branchData, isLoading: isBranchesLoading } = electronTrpc.projects.getBranches.useQuery({ projectId }); const openWorktree = useOpenWorktree(); - const openDiskWorktree = useOpenDiskWorktree(); + const openExternalWorktree = useOpenExternalWorktree(); const createWorkspace = useCreateWorkspace(); const createFromPr = useCreateFromPr(); @@ -45,13 +45,13 @@ export function ExistingWorktreesList({ const branchesWithoutWorktrees = useMemo(() => { if (!branchData?.branches) return []; const worktreeBranches = new Set(worktrees.map((wt) => wt.branch)); - const diskWorktreeBranches = new Set(diskWorktrees.map((wt) => wt.branch)); + const externalWorktreeBranches = new Set(externalWorktrees.map((wt) => wt.branch)); return branchData.branches.filter( (branch) => !worktreeBranches.has(branch.name) && - !diskWorktreeBranches.has(branch.name), + !externalWorktreeBranches.has(branch.name), ); - }, [branchData?.branches, worktrees, diskWorktrees]); + }, [branchData?.branches, worktrees, externalWorktrees]); const filteredBranches = useMemo(() => { if (!branchSearch) return branchesWithoutWorktrees; @@ -128,11 +128,11 @@ export function ExistingWorktreesList({ } }; - const handleOpenDiskWorktree = async (path: string, branch: string) => { + const handleOpenExternalWorktree = async (path: string, branch: string) => { setWorktreeOpen(false); setWorktreeSearch(""); toast.promise( - openDiskWorktree.mutateAsync({ projectId, worktreePath: path, branch }), + openExternalWorktree.mutateAsync({ projectId, worktreePath: path, branch }), { loading: "Opening workspace...", success: () => { @@ -146,10 +146,10 @@ export function ExistingWorktreesList({ }; const isLoading = - isWorktreesLoading || isDiskWorktreesLoading || isBranchesLoading; + isWorktreesLoading || isExternalWorktreesLoading || isBranchesLoading; const isPending = openWorktree.isPending || - openDiskWorktree.isPending || + openExternalWorktree.isPending || createWorkspace.isPending || createFromPr.isPending; @@ -164,7 +164,7 @@ export function ExistingWorktreesList({ const hasWorktrees = closedWorktrees.length > 0 || openWorktrees.length > 0 || - diskWorktrees.length > 0; + externalWorktrees.length > 0; const hasBranches = branchesWithoutWorktrees.length > 0; return ( @@ -193,13 +193,13 @@ export function ExistingWorktreesList({ )} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx index bd7a27a0be8..ce735163fe8 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx @@ -20,7 +20,7 @@ interface Worktree { hasActiveWorkspace: boolean; } -interface DiskWorktree { +interface ExternalWorktree { path: string; branch: string; } @@ -28,26 +28,26 @@ interface DiskWorktree { interface WorktreesSectionProps { closedWorktrees: Worktree[]; openWorktrees: Worktree[]; - diskWorktrees: DiskWorktree[]; + externalWorktrees: ExternalWorktree[]; searchValue: string; onSearchChange: (value: string) => void; isOpen: boolean; onOpenChange: (open: boolean) => void; onOpenWorktree: (worktreeId: string, branch: string) => void; - onOpenDiskWorktree: (path: string, branch: string) => void; + onOpenExternalWorktree: (path: string, branch: string) => void; disabled: boolean; } export function WorktreesSection({ closedWorktrees, openWorktrees, - diskWorktrees, + externalWorktrees, searchValue, onSearchChange, isOpen, onOpenChange, onOpenWorktree, - onOpenDiskWorktree, + onOpenExternalWorktree, disabled, }: WorktreesSectionProps) { const searchLower = searchValue.toLowerCase(); @@ -68,20 +68,20 @@ export function WorktreesSection({ ) : openWorktrees; - const filteredDisk = searchValue - ? diskWorktrees.filter( + const filteredExternal = searchValue + ? externalWorktrees.filter( (wt) => wt.branch.toLowerCase().includes(searchLower) || wt.path.toLowerCase().includes(searchLower), ) - : diskWorktrees; + : externalWorktrees; const totalCount = - closedWorktrees.length + openWorktrees.length + diskWorktrees.length; + closedWorktrees.length + openWorktrees.length + externalWorktrees.length; const hasResults = filteredClosed.length > 0 || filteredOpen.length > 0 || - filteredDisk.length > 0; + filteredExternal.length > 0; return (
@@ -151,13 +151,13 @@ export function WorktreesSection({ ))} )} - {filteredDisk.length > 0 && ( + {filteredExternal.length > 0 && ( - {filteredDisk.map((wt) => ( + {filteredExternal.map((wt) => ( onOpenDiskWorktree(wt.path, wt.branch)} + onSelect={() => onOpenExternalWorktree(wt.path, wt.branch)} className="flex flex-col items-start gap-0.5" > diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 55ef7ab4a5e..9796ca441ed 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -4,7 +4,7 @@ export { useCreateFromPr } from "./useCreateFromPr"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useDeleteWorktree } from "./useDeleteWorktree"; -export { useOpenDiskWorktree } from "./useOpenDiskWorktree"; +export { useOpenExternalWorktree } from "./useOpenExternalWorktree"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts similarity index 91% rename from apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts rename to apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts index 1a5fc29ceff..bc8ecbc98a1 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useOpenDiskWorktree.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts @@ -5,9 +5,9 @@ import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/u import { useOpenConfigModal } from "renderer/stores/config-modal"; import { useTabsStore } from "renderer/stores/tabs/store"; -export function useOpenDiskWorktree( +export function useOpenExternalWorktree( options?: Parameters< - typeof electronTrpc.workspaces.openDiskWorktree.useMutation + typeof electronTrpc.workspaces.openExternalWorktree.useMutation >[0], ) { const navigate = useNavigate(); @@ -19,7 +19,7 @@ export function useOpenDiskWorktree( const dismissConfigToast = electronTrpc.config.dismissConfigToast.useMutation(); - return electronTrpc.workspaces.openDiskWorktree.useMutation({ + return electronTrpc.workspaces.openExternalWorktree.useMutation({ ...options, onSuccess: async (data, ...rest) => { await utils.workspaces.invalidate(); From a405f34b8bbfd5d336b60c12cbb74e55bc94f46a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 22:50:40 -0800 Subject: [PATCH 10/11] fix(desktop): copy .superset config when importing external worktrees External worktrees created via git CLI won't have .superset directory if it's gitignored. This caused setup commands referencing ./.superset/setup.sh to fail. Now we copy .superset from main repo before loading setup config. --- .../src/lib/trpc/routers/workspaces/procedures/create.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 18ca3011021..046a701b337 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -34,7 +34,7 @@ import { sanitizeBranchName, worktreeExists, } from "../utils/git"; -import { loadSetupConfig } from "../utils/setup"; +import { copySupersetConfigToWorktree, loadSetupConfig } from "../utils/setup"; import { initializeWorkspaceWorktree } from "../utils/workspace-init"; interface CreateWorkspaceFromWorktreeParams { @@ -704,6 +704,7 @@ export const createCreateProcedures = () => { setLastActiveWorkspace(workspace.id); activateProject(project); + copySupersetConfigToWorktree(project.mainRepoPath, existingWorktree.path); const setupConfig = loadSetupConfig(project.mainRepoPath); track("workspace_opened", { @@ -756,6 +757,7 @@ export const createCreateProcedures = () => { setLastActiveWorkspace(workspace.id); activateProject(project); + copySupersetConfigToWorktree(project.mainRepoPath, input.worktreePath); const setupConfig = loadSetupConfig(project.mainRepoPath); track("workspace_created", { From 0aff777f62d24adc4ea09c7fde10d03d1141d569 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 2 Feb 2026 23:03:14 -0800 Subject: [PATCH 11/11] Lint --- .../trpc/routers/workspaces/procedures/create.ts | 5 ++++- .../ExistingWorktreesList.tsx | 16 ++++++++++++---- .../components/WorktreesSection.tsx | 4 +++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 046a701b337..b57d171d6f4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -704,7 +704,10 @@ export const createCreateProcedures = () => { setLastActiveWorkspace(workspace.id); activateProject(project); - copySupersetConfigToWorktree(project.mainRepoPath, existingWorktree.path); + copySupersetConfigToWorktree( + project.mainRepoPath, + existingWorktree.path, + ); const setupConfig = loadSetupConfig(project.mainRepoPath); track("workspace_opened", { diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx index 46bd06683ba..f8d3e27c35b 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -20,8 +20,10 @@ export function ExistingWorktreesList({ }: ExistingWorktreesListProps) { const { data: worktrees = [], isLoading: isWorktreesLoading } = electronTrpc.workspaces.getWorktreesByProject.useQuery({ projectId }); - const { data: externalWorktrees = [], isLoading: isExternalWorktreesLoading } = - electronTrpc.workspaces.getExternalWorktrees.useQuery({ projectId }); + const { + data: externalWorktrees = [], + isLoading: isExternalWorktreesLoading, + } = electronTrpc.workspaces.getExternalWorktrees.useQuery({ projectId }); const { data: branchData, isLoading: isBranchesLoading } = electronTrpc.projects.getBranches.useQuery({ projectId }); const openWorktree = useOpenWorktree(); @@ -45,7 +47,9 @@ export function ExistingWorktreesList({ const branchesWithoutWorktrees = useMemo(() => { if (!branchData?.branches) return []; const worktreeBranches = new Set(worktrees.map((wt) => wt.branch)); - const externalWorktreeBranches = new Set(externalWorktrees.map((wt) => wt.branch)); + const externalWorktreeBranches = new Set( + externalWorktrees.map((wt) => wt.branch), + ); return branchData.branches.filter( (branch) => !worktreeBranches.has(branch.name) && @@ -132,7 +136,11 @@ export function ExistingWorktreesList({ setWorktreeOpen(false); setWorktreeSearch(""); toast.promise( - openExternalWorktree.mutateAsync({ projectId, worktreePath: path, branch }), + openExternalWorktree.mutateAsync({ + projectId, + worktreePath: path, + branch, + }), { loading: "Opening workspace...", success: () => { diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx index ce735163fe8..d3b6f87864a 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/components/WorktreesSection.tsx @@ -157,7 +157,9 @@ export function WorktreesSection({ onOpenExternalWorktree(wt.path, wt.branch)} + onSelect={() => + onOpenExternalWorktree(wt.path, wt.branch) + } className="flex flex-col items-start gap-0.5" >