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
@@ -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();
});
});