feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
@@ -109,18 +109,6 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -181,8 +169,101 @@ function savedRouter(over: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
const withWorktrees = {
|
||||
branches: ["main", "feat/other", "feat/held"],
|
||||
current: "main",
|
||||
worktrees: [
|
||||
{ path: "/Users/tester/projects/repo", branch: "main" },
|
||||
{ path: "/lanes/repo__held", branch: "feat/held" },
|
||||
],
|
||||
};
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
const panel = await screen.findByTestId("add-lane-worktrees");
|
||||
expect(panel).toHaveTextContent("/lanes/repo__held");
|
||||
expect(panel).toHaveTextContent("feat/held");
|
||||
});
|
||||
|
||||
it("checks an existing branch out, sending no base", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
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"), "Reuse a branch");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
const select = await screen.findByLabelText("Branch to check out");
|
||||
await user.selectOptions(select, "feat/other");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: "feat/other", base: undefined })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("greys out a branch another worktree already holds", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
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.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
|
||||
const held = await screen.findByRole("option", { name: /feat\/held/ });
|
||||
expect(held).toBeDisabled();
|
||||
expect(held).toHaveTextContent("/lanes/repo__held");
|
||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("lets the server name a new branch when the field is left empty", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
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"), "Auto named");
|
||||
// The placeholder previews the name the server will pick.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"feat/auto-named"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: undefined, base: "main" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
// The branch NAME is no longer part of this list: an empty one means "let
|
||||
// the server name it" (issue #34).
|
||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
@@ -191,6 +272,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main", "feat/other"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -223,7 +305,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
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.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -239,7 +325,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
@@ -253,7 +338,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
@@ -272,7 +361,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -291,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("still calls onAdded even when every setup call fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -311,7 +408,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -334,7 +435,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -397,7 +502,6 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
})
|
||||
);
|
||||
@@ -591,50 +695,6 @@ describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — tool selection", () => {
|
||||
it("sends the picked tool when adopting a repo", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
@@ -679,3 +739,41 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["development", "main"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByTestId("add-lane-branch-section");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Current branch" }));
|
||||
|
||||
// Repo mode adopts the directory as it stands — no branch section left to
|
||||
// answer, because there is no fork to configure.
|
||||
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("prefills the repo and branch it was opened with", async () => {
|
||||
renderModal({
|
||||
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
|
||||
});
|
||||
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
|
||||
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,12 @@ describe("LaneCard git block", () => {
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* @file LaneChangesPanel.test.tsx
|
||||
* @description Pins the Changes panel's five modes and its three ranges: the
|
||||
* overview lists files and nothing else, clicking one opens that file's diff
|
||||
* (and asks the server for THAT path), Review asks for every file's hunks at
|
||||
* once, the range toggle changes which comparison is requested, History opens
|
||||
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
||||
* writes — including discard needing two clicks before the server hears
|
||||
* `confirm` — and the degrade for a cwd git cannot read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
changes: vi.fn(),
|
||||
gitAction: vi.fn(),
|
||||
gitLog: vi.fn(),
|
||||
gitFile: vi.fn(),
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const HUNK = {
|
||||
header: "@@ -1,2 +1,3 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
||||
{ type: "del" as const, oldLine: 2, newLine: null, text: "old line" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "new line" },
|
||||
],
|
||||
};
|
||||
|
||||
function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}): LaneChanges {
|
||||
return {
|
||||
available: true,
|
||||
range: "work",
|
||||
base: "development",
|
||||
baseRef: "origin/development",
|
||||
sha: null,
|
||||
context: 3,
|
||||
truncated: false,
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
},
|
||||
{ path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false },
|
||||
{
|
||||
path: "docs/API.md",
|
||||
status: "M ",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
vi.mocked(api.lanes.gitAction).mockReset();
|
||||
vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" });
|
||||
vi.mocked(api.lanes.gitLog).mockResolvedValue({
|
||||
available: true,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitLs).mockResolvedValue({
|
||||
available: true,
|
||||
paths: ["src/main.ts"],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
});
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneChangesPanel", () => {
|
||||
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByText("+12")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/new line/)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens one file's diff when its row is clicked, asking for that path only", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
expect(view).toHaveTextContent("new line");
|
||||
// Both gutters are rendered, so a reviewer can cite a line number.
|
||||
expect(view).toHaveTextContent("keep");
|
||||
});
|
||||
|
||||
it("asks for every file's hunks in review mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true })
|
||||
);
|
||||
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the comparison when the range toggle is used", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "vs base" }));
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(
|
||||
await screen.findByText("This lane's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages one file and asks the server for that path alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// Both rows offer it (one modified, one untracked); the first is the
|
||||
// modified file the assertion below names.
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a commit from history as a commit-range review", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "commit",
|
||||
sha: "a".repeat(40),
|
||||
full: true,
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
||||
});
|
||||
|
||||
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
||||
path === "src"
|
||||
? {
|
||||
available: true,
|
||||
path: "src",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
truncated: false,
|
||||
}
|
||||
: {
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
}
|
||||
);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.click(await screen.findByText("src"));
|
||||
|
||||
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
||||
// The root's "src" row is still there — expanding did not replace the list.
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
|
||||
if (path === "src")
|
||||
return {
|
||||
available: true,
|
||||
path: "src",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
truncated: false,
|
||||
};
|
||||
return {
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
};
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await screen.findByText("src");
|
||||
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
||||
|
||||
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse all folders" }));
|
||||
expect(screen.queryByText("main.ts")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("quick-opens a tracked file as its whole content, not a diff", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
});
|
||||
|
||||
it("switches the diff to side-by-side", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split");
|
||||
});
|
||||
|
||||
it("expands and then collapses the context again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
// expanded diff that cannot be collapsed sends the reader back out of the
|
||||
// file to get the tight view.
|
||||
expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 12,
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for more context when the diff is expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 12,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the index from the overview, never the whole tree", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// The box exists only because a file is staged — with a clean index there
|
||||
// is nothing this button could mean.
|
||||
const box = await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
||||
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: the staged half",
|
||||
only: "staged",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts Files first in the mode row", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
|
||||
screen.getByRole("button", { name })
|
||||
);
|
||||
// Source order is the reading order here, so comparing positions pins it.
|
||||
for (let i = 1; i < modes.length; i += 1) {
|
||||
expect(
|
||||
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("folds one file shut in review mode, and all of them at once", async () => {
|
||||
const user = userEvent.setup();
|
||||
const withHunks = changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
{
|
||||
path: "notes.txt",
|
||||
status: "??",
|
||||
untracked: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(withHunks);
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1);
|
||||
// The header stays, so a folded file is still a row you can reopen.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse every file" }));
|
||||
expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Expand every file" }));
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens a file only from its path, and folds it from anywhere else", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
// Anywhere on the header but the path: fold, do not navigate.
|
||||
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
||||
expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("goes back to the list it came from, at the offset it was left at", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
// jsdom lays nothing out, so the scrollable box is described by hand —
|
||||
// the restore deliberately waits until the list is tall enough again.
|
||||
const body = screen.getByTestId("lane-changes-body");
|
||||
Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true });
|
||||
Object.defineProperty(body, "clientHeight", { value: 400, configurable: true });
|
||||
body.scrollTop = 640;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
||||
await screen.findByRole("button", { name: /All files/ });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /All files/ }));
|
||||
// Back to REVIEW (where the file was opened from), not to the overview.
|
||||
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file LaneDiffView.test.tsx
|
||||
* @description Pins the side-by-side renderer: two independently scrolling
|
||||
* columns (a long line must be reachable, not painted over its neighbour)
|
||||
* whose horizontal scroll is mirrored, so the two halves of a line stay
|
||||
* opposite each other.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { LaneDiffView } from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,2 +1,2 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("bounds its own height so the horizontal bar stays on screen", () => {
|
||||
// A stacked review list caps each file; the single-file view takes the
|
||||
// height its parent gives it. Either way the diff scrolls itself rather
|
||||
// than growing a 2000-line container whose scrollbar is off the bottom.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
|
||||
it("mirrors one column's horizontal scroll onto the other", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(320);
|
||||
|
||||
right.scrollLeft = 0;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that commit sends the typed
|
||||
* message, that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
return {
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: true,
|
||||
upstream: "origin/feat/thing",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "fetch",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
vi.mocked(api.lanes.gitBranches)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneGitBar", () => {
|
||||
it("shows both sides of the upstream comparison", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
const tracking = screen.getByTestId("lane-git-tracking");
|
||||
expect(tracking).toHaveTextContent("2");
|
||||
expect(tracking).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("disables the network actions when the repo has no remote", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still commits.
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("commits with the typed message and reports the change upward", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no commit when nothing is uncommitted", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={onNewLane}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "feat/sibling",
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "New lane here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("commits only the index when asked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
|
||||
await user.click(screen.getByRole("button", { name: "Commit staged" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
|
||||
message: "fix: staged only",
|
||||
only: "staged",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user