diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 226fc929c..447438cfb 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -17,6 +17,7 @@ import type { BatchAssessmentResult, AttachLaneArgs, AdoptAttachedLaneArgs, + UnregisteredLaneCandidate, AppInfo, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, @@ -3307,6 +3308,11 @@ export function registerIpc({ return lane; }); + ipcMain.handle(IPC.lanesListUnregisteredWorktrees, async (): Promise => { + const ctx = getCtx(); + return ctx.laneService.listUnregisteredWorktrees(); + }); + ipcMain.handle(IPC.lanesAdoptAttached, async (_event, arg: AdoptAttachedLaneArgs): Promise => { const ctx = getCtx(); const lane = await ctx.laneService.adoptAttached(arg); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 14c48c539..9f552a6e0 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -36,6 +36,7 @@ import type { RebasePushArgs, PushMode, StackChainItem, + UnregisteredLaneCandidate, UpdateLaneAppearanceArgs } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; @@ -1128,6 +1129,42 @@ export function createLaneService({ return await listLanes(args); }, + async listUnregisteredWorktrees(): Promise { + const stdout = await runGitOrThrow( + ["worktree", "list", "--porcelain"], + { cwd: projectRoot, timeoutMs: 15_000 } + ); + + const blocks = stdout.split(/\n\n+/).filter(Boolean); + const worktrees: UnregisteredLaneCandidate[] = []; + + for (const block of blocks) { + const lines = block.split(/\r?\n/); + let wtPath = ""; + let branch = ""; + let isBare = false; + for (const line of lines) { + if (line.startsWith("worktree ")) wtPath = line.slice("worktree ".length).trim(); + if (line.startsWith("branch ")) branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, ""); + if (line === "bare") isBare = true; + } + if (!wtPath || isBare) continue; + worktrees.push({ path: normAbs(wtPath), branch }); + } + + // Filter out primary worktree and worktrees already tracked as lanes + const registeredPaths = new Set( + db.all<{ worktree_path: string }>( + "select worktree_path from lanes where project_id = ?", + [projectId] + ).map((row) => normAbs(row.worktree_path)) + ); + + return worktrees.filter( + (wt) => wt.path !== normalizedProjectRoot && !registeredPaths.has(wt.path) + ); + }, + getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null { const row = db.get( ` diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index b6c84446d..bda16166a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -6,6 +6,7 @@ import type { ApplyConflictProposalArgs, AttachLaneArgs, AdoptAttachedLaneArgs, + UnregisteredLaneCandidate, AppInfo, AutoUpdateSnapshot, ClearLocalAdeDataArgs, @@ -762,6 +763,7 @@ declare global { createFromUnstaged: (args: CreateLaneFromUnstagedArgs) => Promise; importBranch: (args: ImportBranchLaneArgs) => Promise; attach: (args: AttachLaneArgs) => Promise; + listUnregisteredWorktrees: () => Promise; adoptAttached: (args: AdoptAttachedLaneArgs) => Promise; rename: (args: RenameLaneArgs) => Promise; reparent: (args: ReparentLaneArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e919d79b5..155f18fd1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6,6 +6,7 @@ import type { ApplyConflictProposalArgs, AttachLaneArgs, AdoptAttachedLaneArgs, + UnregisteredLaneCandidate, AppInfo, AutoUpdateSnapshot, ClearLocalAdeDataArgs, @@ -972,6 +973,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args), importBranch: async (args: ImportBranchLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesImportBranch, args), attach: async (args: AttachLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesAttach, args), + listUnregisteredWorktrees: async (): Promise => + ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), adoptAttached: async (args: AdoptAttachedLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesAdoptAttached, args), rename: async (args: RenameLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesRename, args), reparent: async (args: ReparentLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesReparent, args), diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 958afeaf1..3e6065686 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -16,6 +16,7 @@ import { LaneDiffPane } from "./LaneDiffPane"; import { LaneWorkPane } from "./LaneWorkPane"; import { CreateLaneDialog, type CreateLaneMode } from "./CreateLaneDialog"; import { AttachLaneDialog } from "./AttachLaneDialog"; +import { MultiAttachWorktreeDialog } from "./MultiAttachWorktreeDialog"; import { ManageLaneDialog } from "./ManageLaneDialog"; import { LaneContextMenu } from "./LaneContextMenu"; import { LaneRebaseBanner } from "./LaneRebaseBanner"; @@ -140,6 +141,7 @@ export function LanesPage() { const createBaseBranchUserPickedRef = useRef(false); const [templates, setTemplates] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [multiAttachOpen, setMultiAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false); const [attachName, setAttachName] = useState(""); const [attachPath, setAttachPath] = useState(""); @@ -1524,19 +1526,15 @@ export function LanesPage() { className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-muted-fg transition-colors hover:bg-white/[0.04] hover:text-fg" onClick={() => { setAddLaneDropdownOpen(false); - setAttachName(""); - setAttachPath(""); - setAttachBusy(false); - setAttachError(null); - setAttachOpen(true); + setMultiAttachOpen(true); }} > - Add existing worktree as lane - Track a worktree that already exists on disk. + Add existing worktrees as lanes + Select from worktrees that already exist on disk. @@ -2034,6 +2032,23 @@ export function LanesPage() { onSubmit={handleAttachSubmit} /> + { + setMultiAttachOpen(false); + setAttachName(""); + setAttachPath(""); + setAttachDescription(""); + setAttachBusy(false); + setAttachError(null); + setAttachOpen(true); + }} + onComplete={() => { + refreshLanes(); + }} + /> + {adoptConfirmOpen ? (
diff --git a/apps/desktop/src/renderer/components/lanes/MultiAttachWorktreeDialog.tsx b/apps/desktop/src/renderer/components/lanes/MultiAttachWorktreeDialog.tsx new file mode 100644 index 000000000..c283cb37b --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/MultiAttachWorktreeDialog.tsx @@ -0,0 +1,350 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, WarningCircle, CircleNotch, TextAlignLeft, Warning } from "@phosphor-icons/react"; +import type { UnregisteredLaneCandidate } from "../../../shared/types/lanes"; +import { Button } from "../ui/Button"; +import { LaneDialogShell } from "./LaneDialogShell"; +import { SECTION_CLASS_NAME } from "./laneDialogTokens"; + +export function MultiAttachWorktreeDialog({ + open, + onOpenChange, + onFallbackToManual, + onComplete, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onFallbackToManual: () => void; + onComplete: () => void; +}) { + const [loading, setLoading] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [fetchError, setFetchError] = useState(null); + const [attaching, setAttaching] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [errors, setErrors] = useState([]); + const [moveToAde, setMoveToAde] = useState(false); + const [moveConfirmOpen, setMoveConfirmOpen] = useState(false); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setFetchError(null); + setSelected(new Set()); + setErrors([]); + setProgress({ current: 0, total: 0 }); + setMoveToAde(false); + setMoveConfirmOpen(false); + window.ade.lanes + .listUnregisteredWorktrees() + .then((result) => { + if (cancelled) return; + setWorktrees(result); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + setFetchError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const allSelected = selected.size === worktrees.length && worktrees.length > 0; + const someSelected = selected.size > 0 && selected.size < worktrees.length; + + const selectAllRef = useRef(null); + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someSelected; + } + }, [someSelected]); + + const toggleAll = () => { + if (allSelected) setSelected(new Set()); + else setSelected(new Set(worktrees.map((wt) => wt.path))); + }; + + const toggleOne = (path: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const handleAttach = async () => { + const toAttach = worktrees.filter((wt) => selected.has(wt.path)); + if (toAttach.length === 0) return; + setAttaching(true); + setErrors([]); + const collectedErrors: string[] = []; + for (let i = 0; i < toAttach.length; i++) { + const wt = toAttach[i]!; + setProgress({ current: i + 1, total: toAttach.length }); + try { + const name = wt.branch || wt.path.split("/").pop() || "worktree"; + const lane = await window.ade.lanes.attach({ name, attachedPath: wt.path }); + if (moveToAde) { + await window.ade.lanes.adoptAttached({ laneId: lane.id }); + } + } catch (err) { + collectedErrors.push( + `${wt.branch || wt.path}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + setErrors(collectedErrors); + setAttaching(false); + if (collectedErrors.length === 0) { + onComplete(); + onOpenChange(false); + } else { + // Partial success — refresh the list to remove successfully attached items, + // then pre-select the ones that failed so the user can retry them. + const failedPaths = new Set( + toAttach + .filter((wt) => { + const prefix = wt.branch || wt.path; + return collectedErrors.some((e) => e.startsWith(prefix + ":")); + }) + .map((wt) => wt.path) + ); + onComplete(); + try { + const refreshed = await window.ade.lanes.listUnregisteredWorktrees(); + setWorktrees(refreshed); + setSelected(new Set(refreshed.filter((wt) => failedPaths.has(wt.path)).map((wt) => wt.path))); + } catch { + // non-fatal + } + } + }; + + const busy = loading || attaching; + + return ( + +
+ {loading ? ( +
+
+ + Discovering worktrees... +
+
+ ) : fetchError ? ( +
+
+ + {fetchError} +
+
+ ) : worktrees.length === 0 ? ( +
+
+
+ All worktrees are already tracked as lanes. +
+ +
+
+ ) : ( + <> +
+
+ + + {worktrees.length} worktree{worktrees.length !== 1 ? "s" : ""} found + +
+ +
+ {worktrees.map((wt) => ( + + ))} +
+
+ + {/* Move to .ade/worktrees option */} +
+ +
+ + {attaching ? ( +
+ + Attaching {progress.current} of {progress.total}{moveToAde ? " (+ moving)" : ""}... +
+ ) : null} + + )} + + {errors.length > 0 ? ( +
+ {errors.map((err, i) => ( +
+ + {err} +
+ ))} +
+ ) : null} + +
+ +
+ + {worktrees.length > 0 ? ( + + ) : null} +
+
+
+ {/* Confirmation modal for move-to-.ade */} + {moveConfirmOpen ? ( +
+
+
+ + Confirm: Move worktrees into .ade +
+
+

+ This will physically relocate each selected worktree + from its current directory into .ade/worktrees/. +

+

The following may break after the move:

+
    +
  • Absolute paths in build configs, scripts, or CI pipelines
  • +
  • IDE project files and workspace references
  • +
  • Running terminal sessions or file watchers in the old path
  • +
  • Symlinks or external tooling that reference the worktree location
  • +
+

+ Git branch history and commits are not affected — only the on-disk location changes. +

+
+
+ + +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 52a78e175..f28b45da3 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -33,6 +33,7 @@ export const IPC = { lanesCreateFromUnstaged: "ade.lanes.createFromUnstaged", lanesImportBranch: "ade.lanes.importBranch", lanesAttach: "ade.lanes.attach", + lanesListUnregisteredWorktrees: "ade.lanes.listUnregisteredWorktrees", lanesAdoptAttached: "ade.lanes.adoptAttached", lanesRename: "ade.lanes.rename", lanesReparent: "ade.lanes.reparent", diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index a8bedfc37..ca2260f3f 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -141,6 +141,11 @@ export type AttachLaneArgs = { description?: string; }; +export type UnregisteredLaneCandidate = { + path: string; + branch: string; +}; + export type AdoptAttachedLaneArgs = { laneId: string; };