feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse

- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
This commit is contained in:
2026-08-06 16:03:49 +07:00
parent 9f13769fb4
commit 78f6e1be8e
10 changed files with 594 additions and 72 deletions
+146 -25
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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 (
<button
type="button"
onClick={onClick}
title={title}
aria-pressed={active}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
active ? "bg-accent/20 text-accent" : "text-fg-secondary hover:text-fg-primary"
}`}
>
{label}
</button>
);
}
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<string[] | null>(null);
const [base, setBase] = useState("");
const [branchesError, setBranchesError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<string>("");
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 (
<ConfirmModal
@@ -187,22 +255,52 @@ export function AddLaneModal({
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={!!setupResult || !sourceRepo.trim() || !title.trim() || !branches}
disabled={disabled}
onConfirm={submit}
onCancel={handleCancel}
>
<div className="space-y-3">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{t("addLaneRepoLabel")}
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
</div>
<button
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
<div>
@@ -218,7 +316,7 @@ export function AddLaneModal({
/>
</div>
{branches && (
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
@@ -241,10 +339,26 @@ export function AddLaneModal({
)}
</div>
)}
{branchesError && !branches && (
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => 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"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
</div>
)}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -283,6 +397,13 @@ export function AddLaneModal({
</p>
)}
</div>
<FolderBrowseModal
open={browseOpen}
initialPath={sourceRepo.trim() || undefined}
onSelect={setSourceRepo}
onClose={() => setBrowseOpen(false)}
/>
</ConfirmModal>
);
}