diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx index b7fa0c7..f8f865f 100644 --- a/client/src/components/lanes/AddLaneModal.tsx +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -1,21 +1,27 @@ /** * @file AddLaneModal.tsx - * @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to - * adopt), pick which of its branches to fork from, name the feature, and the - * dashboard provisions a managed git worktree via `POST /api/lanes/worktree` - * — the dashboard invents the lane's own directory and branch name, the same - * way Shipyard's "+ Add lane" never asks a human to name a folder. The lane - * returned is `status: "provisioning"`: the route answers before the actual - * `git worktree add` runs, so this component polls `GET /api/lanes/:id` - * until that finishes before running the auto-setup calls against a cwd - * that must actually exist on disk first. + * @description The "+ Add lane" flow, in one of two modes chosen with a + * segmented toggle: "Repo" adopts an existing directory as-is via + * `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a + * main repo you want stage detection on); "Worktree" provisions a + * dashboard-managed git worktree via `POST /api/lanes/worktree` with a + * manually-typed branch name. Either mode's path field can be filled by + * typing, by the CwdAutocomplete suggestions, or by browsing + * (`FolderBrowseModal`) — a native folder picker cannot hand a web page an + * absolute filesystem path, so browsing is server-backed instead. The + * worktree lane returned is `status: "provisioning"`: the route answers + * before the actual `git worktree add` runs, so this component polls + * `GET /api/lanes/:id` until that finishes before running the auto-setup + * calls against a cwd that must actually exist on disk first. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { FolderOpen } from "lucide-react"; import { ConfirmModal } from "../ConfirmModal"; import { CwdAutocomplete } from "../run/RunSetup"; +import { FolderBrowseModal } from "./FolderBrowseModal"; import { api } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api"; import type { Lane } from "../../lib/types"; @@ -36,6 +42,33 @@ async function waitForProvisioned( } } +/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */ +function Seg({ + active, + label, + title, + onClick, +}: { + active: boolean; + label: string; + title?: string; + onClick: () => void; +}) { + return ( + + ); +} + export function AddLaneModal({ open, onClose, @@ -44,20 +77,23 @@ export function AddLaneModal({ }: { open: boolean; onClose: () => void; - /** Called with the newly provisioned (still-provisioning) lane. */ + /** Called with the newly created (possibly still-provisioning) lane. */ onAdded: (lane: Lane) => void; /** The same suggestion list the Run form already fetched (dashboard cwd, * home, recently-used paths) — reused rather than fetched a second time. */ cwdSuggestions: CwdSuggestion[]; }) { const { t } = useTranslation(["lanes"]); + const [mode, setMode] = useState<"repo" | "worktree">("worktree"); const [sourceRepo, setSourceRepo] = useState(""); const [title, setTitle] = useState(""); + const [branch, setBranch] = useState(""); const [branches, setBranches] = useState(null); const [base, setBase] = useState(""); const [branchesError, setBranchesError] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [browseOpen, setBrowseOpen] = useState(false); const [setupResult, setSetupResult] = useState<{ profile: "scaffolded" | "skipped" | "failed"; agents: "ok" | "failed"; @@ -65,8 +101,10 @@ export function AddLaneModal({ } | null>(null); const reset = () => { + setMode("worktree"); setSourceRepo(""); setTitle(""); + setBranch(""); setBranches(null); setBase(""); setBranchesError(null); @@ -77,9 +115,11 @@ export function AddLaneModal({ // Look up the repo's branches once the path settles - debounced so every // keystroke while typing a path doesn't fire a request against a path that - // isn't finished yet. + // isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and + // never forks a branch. const lookedUpFor = useRef(""); useEffect(() => { + if (mode !== "worktree") return; const path = sourceRepo.trim(); if (!path) { setBranches(null); @@ -105,19 +145,35 @@ export function AddLaneModal({ } }, 300); return () => window.clearTimeout(timer); - }, [sourceRepo, t]); + }, [mode, sourceRepo, t]); const submit = async () => { const repo = sourceRepo.trim(); const name = title.trim(); - if (!repo || !branches || !name) return; + if (!repo) return; setBusy(true); setError(null); + + if (mode === "repo") { + try { + const result = await api.lanes.ensure({ cwd: repo, title: name || undefined }); + onAdded(result.lane); + reset(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + return; + } + + if (!branches || !name || !branch.trim()) return; try { const result = await api.lanes.worktree({ sourceRepo: repo, title: name, base: base || undefined, + branch: branch.trim(), }); onAdded(result.lane); @@ -175,9 +231,21 @@ export function AddLaneModal({ // into after the very first character. useCallback keeps the identity stable // across renders so only mount/unmount (and a real onClose change) refocuses. const handleCancel = useCallback(() => { + // ConfirmModal's own Escape/backdrop/X handling calls this directly. + // While the folder browser is open on top of it, that dismissal should + // close only the browser, not both modals at once. + if (browseOpen) { + setBrowseOpen(false); + return; + } reset(); onClose(); - }, [onClose]); + }, [onClose, browseOpen]); + + const disabled = + !!setupResult || + !sourceRepo.trim() || + (mode === "worktree" && (!title.trim() || !branches || !branch.trim())); return (
+
+ setMode("repo")} + /> + setMode("worktree")} + /> +
+
- -

{t("addLaneRepoHint")}

+
+
+ +
+ +
+

+ {mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")} +

@@ -218,7 +316,7 @@ export function AddLaneModal({ />
- {branches && ( + {mode === "worktree" && branches && (
)} - {branchesError && !branches && ( + {mode === "worktree" && branchesError && !branches && (

{branchesError}

)} + {mode === "worktree" && branches && ( +
+ + setBranch(e.target.value)} + placeholder={t("addLaneBranchPlaceholder")} + className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none" + /> +

{t("addLaneBranchHint")}

+
+ )} + {setupResult && (

{t("addLaneSetupTitle")}

@@ -283,6 +397,13 @@ export function AddLaneModal({

)}
+ + setBrowseOpen(false)} + /> ); } diff --git a/client/src/components/lanes/FolderBrowseModal.tsx b/client/src/components/lanes/FolderBrowseModal.tsx new file mode 100644 index 0000000..973f16d --- /dev/null +++ b/client/src/components/lanes/FolderBrowseModal.tsx @@ -0,0 +1,137 @@ +/** + * @file A server-backed folder browser for the Add Lane modal's path inputs. + * Browsers refuse to expose an absolute filesystem path from a native folder + * picker, so path selection here is done by browsing `GET /api/lanes/browse` + * (immediate subdirectories of a path) instead — breadcrumb-free, just an + * up-one-level button and a click-to-descend list, since this tool is + * local-first and the server already trusts arbitrary local paths. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react"; +import { api } from "../../lib/api"; + +export function FolderBrowseModal({ + open, + initialPath, + onSelect, + onClose, +}: { + open: boolean; + /** Path to start browsing from; omitted defaults server-side to the home dir. */ + initialPath?: string; + onSelect: (path: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(["lanes"]); + const [listing, setListing] = useState> | null>(null); + const [error, setError] = useState(null); + + const load = async (path?: string) => { + setError(null); + try { + setListing(await api.lanes.browse(path)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + useEffect(() => { + if (open) void load(initialPath); + // Only re-run when the modal actually opens - not on every initialPath + // keystroke in the field behind it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={t("browse.title")} + > +
+
+ {listing?.path || "…"} +
+
+ +
+ {error && ( +

+ {error} +

+ )} + {listing?.parent && ( + + )} + {listing?.entries.map((entry) => ( + + ))} + {listing && listing.entries.length === 0 && !listing.parent && ( +

{t("browse.empty")}

+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index a1f1c6a..6597222 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -1,11 +1,11 @@ /** * @file AddLaneModal.test.tsx - * @description Pins the "+ Add lane" flow after it was rebuilt around a source - * repo instead of an existing folder: picking or typing a repo path triggers a - * branch lookup, the base-branch picker only appears once that lookup resolves, - * confirm submits through the provisioning endpoint (not the adopt/ensure one), - * an unresolvable path degrades to a quiet hint instead of blocking the form, - * and a server error surfaces instead of closing the modal. + * @description Pins the "+ Add lane" flow's two modes: "Worktree" (default - + * pick a source repo, fork a branch, type a new branch name, submit through + * the provisioning endpoint) and "Repo" (adopt a directory as-is through + * `ensure`, no branch fields). Also covers the branch-lookup debounce, the + * unresolvable-path degrade, server-error handling, the auto-setup summary, + * the provisioning-wait race, and the folder-browse modal. * @author Nguyễn Ngọc Trí Vĩ */ @@ -22,6 +22,8 @@ vi.mock("../../../lib/api", () => ({ lanes: { branches: vi.fn(), worktree: vi.fn(), + ensure: vi.fn(), + browse: vi.fn(), get: vi.fn(), profileInit: vi.fn(), agentsInstall: vi.fn(), @@ -83,9 +85,25 @@ async function focusField(user: ReturnType, el: HTMLElem await user.click(el); } +/** Fills the default "Worktree" mode's form up through a resolved branch + * list, title, and new-branch name - everything Add lane needs to enable. */ +async function fillWorktreeForm( + user: ReturnType, + { repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {} +) { + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, repo); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), title); + await user.type(screen.getByLabelText("New branch name"), branch); +} + beforeEach(() => { vi.mocked(api.lanes.branches).mockReset(); vi.mocked(api.lanes.worktree).mockReset(); + vi.mocked(api.lanes.ensure).mockReset(); + vi.mocked(api.lanes.browse).mockReset(); // Provisioning finishes instantly by default - tests that care about the // provisioning-in-progress race override this per-test. vi.mocked(api.lanes.get) @@ -98,8 +116,8 @@ beforeEach(() => { vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] }); }); -describe("AddLaneModal", () => { - it("disables confirm until a repo, a title, and a resolved branch list are all present", () => { +describe("AddLaneModal — worktree mode (default)", () => { + it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => { renderModal(); expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); }); @@ -139,7 +157,7 @@ describe("AddLaneModal", () => { expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); }); - it("submits through the worktree provisioning endpoint, not ensure", async () => { + it("submits through the worktree provisioning endpoint, with the typed branch name", async () => { vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); const onAdded = vi.fn(); @@ -147,11 +165,7 @@ describe("AddLaneModal", () => { renderModal({ onClose, onAdded }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "New feature"); + await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" }); await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => { @@ -159,6 +173,7 @@ describe("AddLaneModal", () => { sourceRepo: "/Users/tester/projects/repo", title: "New feature", base: "main", + branch: "feat/new-feature", }); }); expect(onAdded).toHaveBeenCalledWith( @@ -176,11 +191,7 @@ describe("AddLaneModal", () => { renderModal({ onClose }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "New feature"); + await fillWorktreeForm(user, { title: "New feature" }); await user.click(screen.getByRole("button", { name: "Add lane" })); expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument(); @@ -201,11 +212,7 @@ describe("AddLaneModal", () => { renderModal({ onAdded }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "demo"); + await fillWorktreeForm(user); await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled()); @@ -228,11 +235,7 @@ describe("AddLaneModal", () => { renderModal({ onAdded, onClose }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "demo2"); + await fillWorktreeForm(user, { title: "demo2" }); await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => expect(onAdded).toHaveBeenCalled()); @@ -248,11 +251,7 @@ describe("AddLaneModal", () => { renderModal({ onClose }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "demo3"); + await fillWorktreeForm(user, { title: "demo3" }); await user.click(screen.getByRole("button", { name: "Add lane" })); expect(await screen.findByText("Setup")).toBeInTheDocument(); @@ -279,11 +278,7 @@ describe("AddLaneModal", () => { renderModal({ onAdded }); const user = userEvent.setup(); - const repoField = screen.getByLabelText("Source repository"); - await focusField(user, repoField); - await user.type(repoField, "/Users/tester/projects/repo"); - await screen.findByLabelText("Branch to fork from"); - await user.type(screen.getByLabelText("Title"), "demo4"); + await fillWorktreeForm(user, { title: "demo4" }); await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => expect(onAdded).toHaveBeenCalled()); @@ -293,3 +288,91 @@ describe("AddLaneModal", () => { expect(api.lanes.mcpSync).not.toHaveBeenCalled(); }); }); + +describe("AddLaneModal — repo mode (adopt)", () => { + it("hides the branch fields and enables confirm on a path alone", async () => { + renderModal(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Repo" })); + + expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); + const dirField = screen.getByLabelText("Directory"); + await focusField(user, dirField); + await user.type(dirField, "/Users/tester/projects/repo"); + + expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled(); + expect(screen.queryByLabelText("Branch to fork from")).toBeNull(); + expect(screen.queryByLabelText("New branch name")).toBeNull(); + expect(api.lanes.branches).not.toHaveBeenCalled(); + }); + + it("submits through ensure, not worktree, and closes immediately", async () => { + vi.mocked(api.lanes.ensure).mockResolvedValue({ + lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }), + created: true, + }); + const onAdded = vi.fn(); + const onClose = vi.fn(); + renderModal({ onAdded, onClose }); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Repo" })); + const dirField = screen.getByLabelText("Directory"); + await focusField(user, dirField); + await user.type(dirField, "/Users/tester/projects/repo"); + await user.type(screen.getByLabelText("Title"), "main repo"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + await waitFor(() => + expect(api.lanes.ensure).toHaveBeenCalledWith({ + cwd: "/Users/tester/projects/repo", + title: "main repo", + }) + ); + expect(api.lanes.worktree).not.toHaveBeenCalled(); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 }))); + // Repo mode never runs the profile/agents/mcp setup summary - it should + // close right away like the old adopt flow did. + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe("AddLaneModal — folder browse", () => { + it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => { + vi.mocked(api.lanes.browse).mockResolvedValue({ + path: "/Users/tester", + parent: "/Users", + entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }], + }); + renderModal(); + const user = userEvent.setup(); + + await user.click(screen.getByTitle("Browse for a folder")); + expect(await screen.findByText("projects")).toBeInTheDocument(); + + await user.click(screen.getByText("projects")); + expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects"); + }); + + it("Escape closes only the folder browser, not the whole modal", async () => { + vi.mocked(api.lanes.browse).mockResolvedValue({ + path: "/Users/tester", + parent: null, + entries: [], + }); + const onClose = vi.fn(); + renderModal({ onClose }); + const user = userEvent.setup(); + + await user.click(screen.getByTitle("Browse for a folder")); + await screen.findByRole("dialog", { name: "Browse for a folder" }); + + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull(); + expect( + screen.getByRole("dialog", { name: "Create a lane from a working directory" }) + ).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index cd7b537..c7cb85f 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -19,16 +19,29 @@ "add": "Add lane", "addLane": "Create a lane from a working directory", "addLaneBaseLabel": "Branch to fork from", + "addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.", + "addLaneBranchLabel": "New branch name", + "addLaneBranchPlaceholder": "feat/my-feature", "addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.", "addLaneNotARepo": "Not a git repository (or no read access) yet.", "addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", + "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.", "addLaneRepoLabel": "Source repository", + "addLaneRepoLabelAdopt": "Directory", "addLaneSetupAgents": "Agents", "addLaneSetupMcp": "MCP servers", "addLaneSetupProfile": "Profile", "addLaneSetupTitle": "Setup", "addLaneTitleLabel": "Title", + "mode.repo": "Repo", + "mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.", + "mode.worktree": "Worktree", + "mode.worktreeHint": "Provision a new git worktree + branch from a source repo.", + "browse.button": "Browse", + "browse.empty": "This folder is empty.", + "browse.select": "Select this folder", + "browse.title": "Browse for a folder", "worktrees.heading_one": "{{count}} worktree", "worktrees.heading_other": "{{count}} worktrees", "addLaneTitlePlaceholder": "Optional", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 5d344c3..cc48a0d 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -19,16 +19,29 @@ "add": "Thêm lane", "addLane": "Tạo lane từ một thư mục làm việc", "addLaneBaseLabel": "Nhánh để tạo nhánh mới", + "addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.", + "addLaneBranchLabel": "Tên nhánh mới", + "addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi", "addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", "addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.", "addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", + "addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.", "addLaneRepoLabel": "Repo nguồn", + "addLaneRepoLabelAdopt": "Thư mục", "addLaneSetupAgents": "Agent", "addLaneSetupMcp": "MCP server", "addLaneSetupProfile": "Profile", "addLaneSetupTitle": "Thiết lập", "addLaneTitleLabel": "Tiêu đề", + "mode.repo": "Repo", + "mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.", + "mode.worktree": "Worktree", + "mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.", + "browse.button": "Duyệt", + "browse.empty": "Thư mục này trống.", + "browse.select": "Chọn thư mục này", + "browse.title": "Duyệt chọn thư mục", "worktrees.heading_one": "{{count}} worktree", "worktrees.heading_other": "{{count}} worktree", "addLaneTitlePlaceholder": "Không bắt buộc", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9507698..b855275 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1922,11 +1922,30 @@ export const api = { * @param body The source repo, a slug/title, and the branch to fork from. * @returns `{ lane }` — the lane row created immediately, before provisioning finishes. */ - worktree: (body: { sourceRepo: string; slug?: string; title?: string; base?: string }) => + worktree: (body: { + sourceRepo: string; + slug?: string; + title?: string; + base?: string; + branch?: string; + }) => request<{ lane: Lane }>("/lanes/worktree", { method: "POST", body: JSON.stringify(body), }), + /** + * GET /api/lanes/browse — list a directory's immediate subdirectories, for + * the Add Lane modal's folder browser. Browsers cannot expose absolute + * filesystem paths from a native picker, so this drives a server-backed one. + * @param path Absolute path to list; defaults server-side to the home dir. + * @returns `{ path, parent, entries }` — `parent` is null at the root. + */ + browse: (path?: string) => + request<{ + path: string; + parent: string | null; + entries: { name: string; path: string; isGitRepo: boolean }[]; + }>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`), /** * GET /api/lanes/:id — fetch one lane. * @param id The lane id. diff --git a/docs/LANES.md b/docs/LANES.md index 01b76f3..623f006 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -36,6 +36,15 @@ Adopting your main repo (`ccam lanes add --cwd $(pwd)`) is also how you get stag Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply. +### The "+ Add lane" modal + +The modal has a segmented toggle mirroring the CLI's two modes: + +- **Repo** — adopts the given directory as-is (maps to `ccam lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on). +- **Worktree** (default) — provisions a managed worktree (maps to `ccam lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged. + +Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing. + ## Destructive lane actions Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI: diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index ad45096..6fbd841 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -488,6 +488,68 @@ describe("managed worktree provisioning", () => { const missing = await request("GET", `/api/lanes/${lane.id}`); assert.equal(missing.status, 404); }); + + it("uses a caller-supplied branch name instead of deriving one from the slug", async () => { + const created = await request("POST", "/api/lanes/worktree", { + sourceRepo: SRC, + title: "Custom Branch", + base: "main", + branch: "custom/my-branch", + }); + + assert.equal(created.status, 202); + assert.equal(created.body.lane.branch, "custom/my-branch"); + const lane = await waitForProvisioning(created.body.lane.id); + assert.equal(lane.status, "idle"); + assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch"); + }); + + it("rejects an invalid caller-supplied branch name before creating anything", async () => { + const response = await request("POST", "/api/lanes/worktree", { + sourceRepo: SRC, + title: "Bad Branch", + base: "main", + branch: "not a valid branch..name", + }); + + assert.equal(response.status, 400); + assert.equal(response.body.error.code, "EBADBRANCH"); + }); +}); + +describe("GET /api/lanes/browse", () => { + it("lists a directory's immediate subdirectories, marking git repos", async () => { + const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`); + assert.equal(response.status, 200); + assert.equal(response.body.path, ROOT); + const names = response.body.entries.map((e) => e.name); + assert.ok(names.includes("src-repo")); + const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo"); + assert.equal(srcRepoEntry.isGitRepo, true); + }); + + it("reports the parent directory, or null at the filesystem root", async () => { + const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`); + assert.equal(response.body.parent, path.dirname(ROOT)); + + const rootResponse = await request("GET", "/api/lanes/browse?path=/"); + assert.equal(rootResponse.body.parent, null); + }); + + it("rejects a path that does not exist or is not a directory", async () => { + const missing = await request( + "GET", + `/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}` + ); + assert.equal(missing.status, 400); + assert.equal(missing.body.error.code, "ENOTFOUND"); + + const filePath = path.join(ROOT, "a-file.txt"); + fs.writeFileSync(filePath, "hi\n"); + const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`); + assert.equal(notADir.status, 400); + assert.equal(notADir.body.error.code, "ENOTADIR"); + }); }); describe("destructive lane lifecycle actions", () => { diff --git a/server/lib/worktree.js b/server/lib/worktree.js index 7baa66a..644dda0 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -78,6 +78,21 @@ async function isGitRepo(dir) { } } +/** + * Whether `name` is a legal git branch name, per git's own rules rather than + * a hand-rolled regex. `cwd` need not be `sourceRepo` specifically — the + * check is not repo-dependent — but `git()` requires a directory to run in. + */ +async function isValidBranchName(cwd, name) { + if (typeof name !== "string" || !name) return false; + try { + await git(cwd, ["check-ref-format", "--branch", name]); + return true; + } catch { + return false; + } +} + /** * List a repo's local branches plus its current HEAD branch, so a caller can * offer a real picker instead of asking someone to remember a branch name. @@ -85,11 +100,7 @@ async function isGitRepo(dir) { * can actually check a new worktree out onto without a fetch first. */ async function listBranches(sourceRepo) { - const result = await git(sourceRepo, [ - "for-each-ref", - "--format=%(refname:short)", - "refs/heads", - ]); + const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]); const branches = result.stdout .split("\n") .map((line) => line.trim()) @@ -643,6 +654,7 @@ module.exports = { LANES_ROOT, git, isGitRepo, + isValidBranchName, listBranches, resolveBase, slugify, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 861e80a..38c0983 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -10,6 +10,7 @@ const { Router } = require("express"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { db } = require("../db"); const lanesLib = require("../lib/lanes"); @@ -25,6 +26,7 @@ const { addWorktree, gitFacts, isGitRepo, + isValidBranchName, listBranches, removeWorktree, resetWorktree, @@ -193,6 +195,47 @@ router.post("/gc", sameOriginGuard, (req, res) => { } }); +/** + * Directory listing for the Add Lane modal's folder browser — browsers cannot + * expose absolute filesystem paths from a native picker, so path selection is + * done by browsing server-side instead. Read-only; registered ahead of + * "/:id" so the literal "browse" segment is never captured as a lane id. + */ +router.get("/browse", (req, res) => { + const raw = + typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir(); + const resolved = path.resolve(raw); + + let stat; + try { + stat = fs.statSync(resolved); + } catch { + return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } }); + } + if (!stat.isDirectory()) { + return res + .status(400) + .json({ error: { code: "ENOTADIR", message: "path is not a directory" } }); + } + + let entries = []; + try { + entries = fs + .readdirSync(resolved, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => { + const full = path.join(resolved, e.name); + return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + // An unreadable entry mid-listing is skipped, not a request failure. + } + + const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved); + res.json({ path: resolved, parent, entries }); +}); + router.get("/:id", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); @@ -503,8 +546,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => { }); } - const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/"; - const branch = `${branchPrefix}${slug}`; + let branch; + if (body.branch !== undefined) { + if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) { + return res.status(400).json({ + error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" }, + }); + } + branch = body.branch; + } else { + const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/"; + branch = `${branchPrefix}${slug}`; + } let lane; try { lane = lanesLib.createLane({