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..b57d171d6f4 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 { @@ -615,6 +615,170 @@ export const createCreateProcedures = () => { }; }), + openExternalWorktree: 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( + and( + eq(worktrees.projectId, input.projectId), + eq(worktrees.path, input.worktreePath), + ), + ) + .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) + .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); + + copySupersetConfigToWorktree( + project.mainRepoPath, + existingWorktree.path, + ); + const setupConfig = loadSetupConfig(project.mainRepoPath); + + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "worktree", + source: "external_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: { + branch: input.branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + }) + .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); + + copySupersetConfigToWorktree(project.mainRepoPath, input.worktreePath); + const setupConfig = loadSetupConfig(project.mainRepoPath); + + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + branch: input.branch, + source: "external_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..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,6 +13,7 @@ import { checkNeedsRebase, fetchDefaultBranch, getDefaultBranch, + listExternalWorktrees, refreshDefaultBranch, } from "../utils/git"; import { fetchGitHubPRStatus } from "../utils/github"; @@ -166,5 +167,38 @@ export const createGitStatusProcedures = () => { }; }); }), + + getExternalWorktrees: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input }) => { + const project = getProject(input.projectId); + if (!project) { + return []; + } + + const allWorktrees = await listExternalWorktrees(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 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((wt) => ({ + path: wt.path, + // biome-ignore lint/style/noNonNullAssertion: filtered above + 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 0377366a237..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,6 +720,59 @@ export async function worktreeExists( } } +export interface ExternalWorktree { + path: string; + branch: string | null; + isDetached: boolean; + isBare: boolean; +} + +export async function listExternalWorktrees( + mainRepoPath: string, +): Promise { + try { + const git = simpleGit(mainRepoPath); + const output = await git.raw(["worktree", "list", "--porcelain"]); + + const result: ExternalWorktree[] = []; + 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 external 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..f8d3e27c35b 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ExistingWorktreesList/ExistingWorktreesList.tsx @@ -4,6 +4,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateFromPr, useCreateWorkspace, + useOpenExternalWorktree, useOpenWorktree, } from "renderer/react-query/workspaces"; import { BranchesSection, PrUrlSection, WorktreesSection } from "./components"; @@ -19,14 +20,21 @@ 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: branchData, isLoading: isBranchesLoading } = electronTrpc.projects.getBranches.useQuery({ projectId }); const openWorktree = useOpenWorktree(); + const openExternalWorktree = useOpenExternalWorktree(); const createWorkspace = useCreateWorkspace(); const createFromPr = useCreateFromPr(); const [branchOpen, setBranchOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); + const [worktreeOpen, setWorktreeOpen] = useState(false); + const [worktreeSearch, setWorktreeSearch] = useState(""); const [prUrl, setPrUrl] = useState(""); const closedWorktrees = worktrees @@ -39,10 +47,15 @@ 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), + ); return branchData.branches.filter( - (branch) => !worktreeBranches.has(branch.name), + (branch) => + !worktreeBranches.has(branch.name) && + !externalWorktreeBranches.has(branch.name), ); - }, [branchData?.branches, worktrees]); + }, [branchData?.branches, worktrees, externalWorktrees]); const filteredBranches = useMemo(() => { if (!branchSearch) return branchesWithoutWorktrees; @@ -53,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: () => { @@ -64,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(""); @@ -139,9 +132,32 @@ export function ExistingWorktreesList({ } }; - const isLoading = isWorktreesLoading || isBranchesLoading; + const handleOpenExternalWorktree = async (path: string, branch: string) => { + setWorktreeOpen(false); + setWorktreeSearch(""); + toast.promise( + openExternalWorktree.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 || isExternalWorktreesLoading || isBranchesLoading; const isPending = openWorktree.isPending || + openExternalWorktree.isPending || createWorkspace.isPending || createFromPr.isPending; @@ -153,7 +169,10 @@ export function ExistingWorktreesList({ ); } - const hasWorktrees = closedWorktrees.length > 0 || openWorktrees.length > 0; + const hasWorktrees = + closedWorktrees.length > 0 || + openWorktrees.length > 0 || + externalWorktrees.length > 0; const hasBranches = branchesWithoutWorktrees.length > 0; return ( @@ -182,8 +201,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 69dd64e576b..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 @@ -1,84 +1,207 @@ 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; } +interface ExternalWorktree { + path: string; + branch: string; +} + interface WorktreesSectionProps { closedWorktrees: Worktree[]; openWorktrees: Worktree[]; + externalWorktrees: ExternalWorktree[]; + searchValue: string; + onSearchChange: (value: string) => void; + isOpen: boolean; + onOpenChange: (open: boolean) => void; onOpenWorktree: (worktreeId: string, branch: string) => void; - onOpenAll: () => void; + onOpenExternalWorktree: (path: string, branch: string) => void; disabled: boolean; } export function WorktreesSection({ closedWorktrees, openWorktrees, + externalWorktrees, + searchValue, + onSearchChange, + isOpen, + onOpenChange, onOpenWorktree, - onOpenAll, + onOpenExternalWorktree, disabled, }: WorktreesSectionProps) { + const searchLower = searchValue.toLowerCase(); + + const filteredClosed = searchValue + ? closedWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), + ) + : closedWorktrees; + + const filteredOpen = searchValue + ? openWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), + ) + : openWorktrees; + + const filteredExternal = searchValue + ? externalWorktrees.filter( + (wt) => + wt.branch.toLowerCase().includes(searchLower) || + wt.path.toLowerCase().includes(searchLower), + ) + : externalWorktrees; + + const totalCount = + closedWorktrees.length + openWorktrees.length + externalWorktrees.length; + const hasResults = + filteredClosed.length > 0 || + filteredOpen.length > 0 || + filteredExternal.length > 0; + return ( -
+
-
-
- Worktrees -
- {closedWorktrees.length > 1 && ( +
+ Worktrees +
+ + - )} -
- - {closedWorktrees.map((wt) => ( - - ))} - - {openWorktrees.length > 0 && ( -
-
- Already open -
- {openWorktrees.map((wt) => ( -
- - - {wt.branch} - - open -
- ))} -
- )} + + + + {!hasResults && 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} + + + ))} + + )} + {filteredExternal.length > 0 && ( + + {filteredExternal.map((wt) => ( + + onOpenExternalWorktree(wt.path, wt.branch) + } + className="flex flex-col items-start gap-0.5" + > + + + + {wt.branch} + + + + {wt.path} + + + ))} + + )} + {filteredOpen.length > 0 && ( + + {filteredOpen.map((wt) => ( + + + + + {wt.branch} + + open + + + {wt.path} + + + ))} + + )} + + + +
); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index dae7653439e..9796ca441ed 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 { 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/useOpenExternalWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts new file mode 100644 index 00000000000..bc8ecbc98a1 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.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 useOpenExternalWorktree( + options?: Parameters< + typeof electronTrpc.workspaces.openExternalWorktree.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.openExternalWorktree.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); + }, + }); +}