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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => load(entry.path)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -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<typeof userEvent.setup>, 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<typeof userEvent.setup>,
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user