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,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