Files
AgentHub/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

1955 lines
76 KiB
TypeScript

/**
* @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` — the JetBrains commit (ticked files, Changes ticked and
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
* a change block's own checkbox making a hunk-level commit), a file's
* history, Annotate, next/previous file, 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, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
vi.mock("../DiffEditor", () => ({
default: (p: {
value: string;
original: string;
layout?: string;
collapse?: boolean;
label: string;
onChange: (v: string) => void;
onSave: () => void;
}) => (
<textarea
data-testid="lane-file-editor"
data-original={p.original}
data-layout={p.layout}
data-collapse={String(!!p.collapse)}
aria-label={p.label}
autoFocus
value={p.value}
onChange={(e) => p.onChange(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
p.onSave();
}
}}
/>
),
}));
vi.mock("../../../lib/confirm", () => ({
confirmDiscard: vi.fn(async () => true),
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
lanes: {
git: vi.fn(),
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
gitSaveFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: 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,
};
}
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's checkbox for one path. */
function tickOf(path: string) {
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
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"],
parents: [],
},
],
});
vi.mocked(api.lanes.gitBranches)
.mockReset()
.mockResolvedValue({
available: true,
branches: ["feat/thing", "other"],
current: "feat/thing",
worktrees: [],
});
vi.mocked(api.lanes.gitLs)
.mockReset()
.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;"],
});
});
/** The panel as the Workspace renders it: Files and Changes are its own mode
* buttons now, no activity-bar tab around it. */
function Tabbed({
laneId,
onDirtyChange,
}: {
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
}
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.getByTitle("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.getByTitle("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
await toDiff(user);
// 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("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
const view = render(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
vi.mocked(api.lanes.changes).mockClear();
// The same request again changes nothing; a new nonce opens the next one.
view.rerender(
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
);
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "src/main.ts",
context: 3,
})
);
expect(api.lanes.changes).not.toHaveBeenCalledWith(
7,
expect.objectContaining({ path: "server/lib/git-ops.js" })
);
});
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");
// No git facts (an older server): the base has no name to show.
await user.click(screen.getByRole("button", { name: "Compared to base branch" }));
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 workspace's directory is not a readable git repository.")
).toBeInTheDocument();
});
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Only the untracked row offers it: a tracked file is already in VCS.
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
expect(more).toHaveLength(0);
expect(tickOf("notes.txt")).not.toBeChecked();
await user.click(add!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
});
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(tickOf("docs/API.md"));
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
expect(group).toBePartiallyChecked();
await user.click(group); // some ticked -> untick all
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(group); // -> tick all
expect(tickOf("docs/API.md")).toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 2,
untracked: 1,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
expect(api.lanes.git).toHaveBeenCalledTimes(1);
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
// The header's counts moved with the index: waiting out its 30s poll
// would leave them wrong beside the list that just changed.
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledTimes(2));
});
it("re-reads the diff after a git bar action", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
branch: "feat/thing",
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: true,
upstream: "origin/feat/thing",
ahead: 0,
behind: 1,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-git-bar");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
await user.click(screen.getByRole("button", { name: /Pull/ }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "pull", {});
// A pull rewrites the working tree the list below describes.
await waitFor(() =>
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before)
);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
await screen.findByText("feat: the commit");
vi.mocked(api.lanes.gitLog).mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
// A pull or a switch from the git bar moves the commit list as well.
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
});
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const actions = screen.getByTestId("lane-changes-actions");
// The commit box comes BEFORE the file list.
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
await user.click(tickOf("notes.txt"));
await user.click(tickOf("docs/API.md"));
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: picked",
paths: ["server/lib/git-ops.js", "notes.txt"],
});
});
it("Commit and Push commits the ticked files, then pushes", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
[7, "push", {}],
]);
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
"server/lib/git-ops.js",
"docs/API.md",
]);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(tracked).queryByText("notes.txt")).toBeNull();
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "README.md",
rev: "work",
binary: false,
truncated: false,
lines: ["# Hello", "", "- one"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("README.md"));
await screen.findByTestId("lane-file-editor");
await user.click(screen.getByTestId("lane-file-preview"));
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
});
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "logo.png",
rev: "work",
binary: true,
truncated: false,
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("logo.png"));
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
await user.click(await within(sidebar).findByText("main.ts"));
// Opening a file is editing it, diffed against its HEAD text.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
expect(editor).toHaveFocus();
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
await user.type(editor, "x");
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
// Cancel asks before dropping an edit: "keep" keeps it…
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-file-cancel"));
expect(confirmDiscard).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
// …and "discard" reverts it, still editing.
await user.click(screen.getByTestId("lane-file-cancel"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
});
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["a"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Changes", pressed: true });
// Changes is the editor folded to its changes, side by side at full width.
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("a");
expect(editor).toHaveAttribute("data-collapse", "true");
expect(editor).toHaveAttribute("data-layout", "split");
await user.type(editor, "b");
// Whole file unfolds the same edit — no prompt, nothing dropped.
vi.mocked(confirmDiscard).mockClear();
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
const whole = await screen.findByTestId("lane-file-editor");
expect(whole).toHaveAttribute("data-collapse", "false");
expect(whole).toHaveValue("ab");
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
expect(confirmDiscard).not.toHaveBeenCalled();
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
});
it("opens diffs unified at the narrow width", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
rev: "work",
binary: false,
truncated: false,
lines: ["a"],
sha: "b".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
});
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");
await user.click(discardOf("server/lib/git-ops.js"));
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("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
// Commit File: that file alone is ticked, and the message box has focus.
await right();
await user.click(menu().getByText("Commit File…"));
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(tickOf("docs/API.md")).not.toBeChecked();
expect(screen.getByLabelText("Commit message")).toHaveFocus();
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
await right();
await user.click(screen.getByTestId("lane-changes-menu-discard"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(screen.getByTestId("lane-changes-menu-discard")).toHaveTextContent("Click again");
await user.click(screen.getByTestId("lane-changes-menu-discard"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js"],
confirm: true,
});
await right();
await user.click(menu().getByText("Copy relative path"));
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
// Copy as Patch: that file's diff, read for its path, as a unified patch.
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
changes({
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
})
);
await right();
await user.click(menu().getByText("Copy as Patch to Clipboard"));
await waitFor(async () =>
expect(await navigator.clipboard.readText()).toBe(
[
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
"--- a/server/lib/git-ops.js",
"+++ b/server/lib/git-ops.js",
"@@ -1,2 +1,3 @@",
" keep",
"-old line",
"+new line",
"",
].join("\n")
)
);
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
});
});
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("notes.txt");
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(menu().getByText("Add to .gitignore"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
await user.pointer({ keys: "[MouseRight]", target: row });
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
// A tracked file is already in VCS, and git would not ignore it anyway.
await user.keyboard("{Escape}");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("docs/API.md"),
});
expect(menu().queryByText("Add to .gitignore")).toBeNull();
expect(menu().queryByText("Add to VCS")).toBeNull();
});
it("Show History lists one file's commits and opens the file at a commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("server/lib/git-ops.js"),
});
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
);
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("walks to the next file from an open one, in list order", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Next file" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "work",
path: "notes.txt",
context: 3,
})
);
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
});
it("annotates the open file with who last changed each line", async () => {
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
commits: {},
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Annotate" }));
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
const user = userEvent.setup();
const { rerender } = 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!);
// Lane 8 has a file at the SAME path.
rerender(<LaneChangesPanel laneId={8} />);
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
await user.click(again!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("disarms a pending discard once that path leaves the file list", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(discardOf("server/lib/git-ops.js"));
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes();
const rest = base.available ? base.files.slice(1) : [];
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByTitle("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
});
it("ignores a late response for the lane it has already left", async () => {
let resolveOld: (v: LaneChanges) => void = () => {};
vi.mocked(api.lanes.changes).mockImplementation((id) =>
id === 7
? new Promise<LaneChanges>((r) => (resolveOld = r))
: Promise.resolve(
changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
)
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
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" }));
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
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");
});
describe("history rewrites, amend and vs base", () => {
const facts = (over: Record<string, unknown> = {}) => ({
available: true as const,
branch: "feat/thing",
head: "aaaaaaa",
subject: "feat: the commit",
dirty: 2,
untracked: 1,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
base: "main",
...over,
});
const commitMenu = async (user: ReturnType<typeof userEvent.setup>) => {
const row = await within(await screen.findByTestId("lane-changes-history")).findByText(
"feat: the commit"
);
await user.pointer({ keys: "[MouseRight]", target: row });
return screen.getByTestId("lane-changes-commit-menu");
};
const withParent = () =>
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"],
parents: ["b".repeat(40)],
},
],
});
it("reverts a commit and undoes the top one from the history menu", async () => {
withParent();
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(within(await commitMenu(user)).getByText("Revert this commit"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "undo-commit", {
confirm: true,
sha: "a".repeat(40),
})
);
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
expect(within(await commitMenu(user)).queryByText(/Cherry-pick/)).toBeNull();
await user.keyboard("{Escape}");
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
);
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
});
it("asks before a revert and an amend, and a No sends nothing", async () => {
withParent();
vi.mocked(confirmDialog).mockClear();
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
expect(confirmDialog).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("amends the last commit, keeping its message when none is typed", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
sha: "aaaaaaa",
paths: ["server/lib/git-ops.js", "docs/API.md"],
})
);
});
it("names the base in full, and on the base branch itself shows no toggle at all", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const { unmount } = render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByRole("button", { name: "Compared to main" })).toBeInTheDocument();
unmount();
vi.mocked(api.lanes.git).mockResolvedValue(facts({ branch: "main" }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-git");
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Compared to main" })).toBeNull();
// A lone "Uncommitted" would be a toggle with nothing to toggle to.
expect(screen.queryByRole("button", { name: "Uncommitted" })).toBeNull();
});
});
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(<Tabbed 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 from one whole-tree read", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTreeAll).mockResolvedValue({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
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(<Tabbed 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("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "src", path: "src", dir: true, changed: true },
{ name: "docs", path: "docs", dir: true },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
],
truncated: false,
});
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-diff-mod");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-kept");
expect(await row("add.txt")).toHaveClass("text-status-success");
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
expect(await row("src")).toHaveClass("text-diff-mod");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
});
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<Tabbed 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("edits a whole file in place and saves over the exact version it opened", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 2;"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<Tabbed 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"));
const editor = await screen.findByTestId("lane-file-editor");
expect(editor).toHaveValue("const whole = 1;");
await user.clear(editor);
await user.type(editor, "const whole = 2;");
await user.click(screen.getByTestId("lane-file-save"));
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
7,
"src/main.ts",
"const whole = 2;",
"b".repeat(40)
);
// Still editing, on what was saved: nothing left to save.
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save
lines: ["partial"],
});
const user = userEvent.setup();
render(<Tabbed 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"));
expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent(
"Too large to edit here (over 1 MB)"
);
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("keeps the draft and says why when the file changed on disk meanwhile", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["x"],
sha: "b".repeat(40),
});
vi.mocked(api.lanes.gitSaveFile).mockRejectedValue(
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<Tabbed 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 user.type(await screen.findByTestId("lane-file-editor"), "y");
await user.click(screen.getByTestId("lane-file-save"));
expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/);
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
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.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
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.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
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.getByTitle("server/lib/git-ops.js"));
await toDiff(user);
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("cannot commit with nothing ticked", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
});
it("drops a half-typed commit message on a lane switch", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
rerender(<LaneChangesPanel laneId={8} />);
await screen.findByTestId("lane-changes-commit");
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when there is nothing to commit", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByText("Nothing uncommitted.");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
for (const name of ["Changes", "Files", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
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);
});
describe("review actions", () => {
const reviewed = () =>
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 1,
binary: false,
hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }],
},
{
path: "notes.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
hunks: [HUNK],
},
],
});
const openReview = async (user: ReturnType<typeof userEvent.setup>) => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(reviewed());
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
};
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", 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,
unstaged: true,
added: 2,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,4 +1,5 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "new" },
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
],
},
],
},
],
})
);
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
expect(boxes).toHaveLength(2);
await user.click(boxes[1]!);
expect(boxes[0]).toBeChecked();
expect(boxes[1]).not.toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
// The second hunk's only block unticked: the file is partial.
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: half",
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
})
);
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked file gets no bar.
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
// Undo sits beside Keep inside the bar — not on the @@ line.
expect(bars[0]).toContainElement(
screen.getAllByRole("button", { name: "Undo this hunk" })[0]!
);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled();
});
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
const user = userEvent.setup();
await openReview(user);
const [first] = screen.getAllByRole("button", { name: "Undo this hunk" });
await user.click(first!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
// The other hunk is still at its first click.
expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Click again to undo this hunk — this cannot be undone",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,2 +1,3 @@",
block: 0,
context: 3,
confirm: true,
});
});
it("unticks a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
await user.click(tickOf("server/lib/git-ops.js"));
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
// Every block of it follows the file.
for (const box of screen.getAllByRole("checkbox", {
name: "Include this change in the commit",
}))
expect(box).not.toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
});
it("keeps each file's header pinned while its diff scrolls", async () => {
const user = userEvent.setup();
await openReview(user);
expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass(
"sticky",
"top-0"
);
});
});
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("opens, not folds, when Enter is pressed on the path", 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");
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("drops a quick-open answer for the lane it has already left", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<Tabbed 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 waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<Tabbed laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
expect(screen.queryByText("from/lane7.ts")).toBeNull();
});
it("keeps the newest quick-open answer when an older one lands after it", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs)
.mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
)
.mockResolvedValueOnce({
available: true,
paths: ["new/mainx.ts"],
truncated: false,
total: 1,
});
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
await user.type(box, "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
await user.type(box, "x");
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
await act(async () => answer({ paths: ["old/main-only.ts"] }));
expect(screen.queryByText("old/main-only.ts")).toBeNull();
});
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);
});
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
code: "ENOFILECHANGE",
status: 400,
})
);
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"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTreeAll)
.mockClear()
.mockResolvedValue({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
render(<Tabbed 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();
expect(api.lanes.gitTreeAll).toHaveBeenCalledTimes(1);
// Only the root read the Files tab made on entry — no per-folder walk.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(1);
});
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
const user = userEvent.setup();
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
vi.mocked(api.lanes.gitAction).mockReturnValue(
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
);
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
id === 7
? changes()
: changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
resolveAction({ action: "stage", ok: true, output: "" });
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
const user = userEvent.setup();
let resolveSrc: () => void = () => {};
vi.mocked(api.lanes.gitTreeAll).mockImplementation(
() =>
new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
truncated: false,
levels: {
"": [{ name: "src", path: "src", dir: true }],
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
}))
)
);
vi.mocked(api.lanes.gitTree).mockImplementation((id) => {
return Promise.resolve({
available: true,
path: "",
entries: [
id === 7
? { name: "src", path: "src", dir: true }
: { name: "lib8", path: "lib8", dir: false },
],
truncated: false,
});
});
const { rerender } = render(<Tabbed 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" }));
rerender(<Tabbed laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
resolveSrc();
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lib8")).toBeInTheDocument();
expect(screen.queryByText("src")).toBeNull();
expect(screen.queryByText("main.ts")).toBeNull();
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
describe("a merge in progress (issue #45)", () => {
const merging = (conflicts: number) => ({
available: true as const,
branch: "main",
head: "abc1234",
subject: "main",
dirty: 1,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
merging: true,
conflicts,
});
const conflicted = changes({
files: [
{
path: "a.txt",
status: "UU",
untracked: false,
staged: true,
unstaged: true,
conflicted: true,
added: 3,
deleted: 0,
binary: false,
},
{
path: "b.txt",
status: "A ",
untracked: false,
staged: true,
added: 1,
deleted: 0,
binary: false,
},
],
});
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
const group = await screen.findByTestId("lane-changes-conflicts");
expect(within(group).getByText("a.txt")).toBeInTheDocument();
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
// A partial commit cannot finish a merge: Complete is the way out.
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
path: "a.txt",
side: "theirs",
})
);
});
it("opens a conflicted file in the conflict view, not the diff", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "a.txt",
rev: "work",
lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"],
truncated: false,
binary: false,
sha: "s1",
} as never);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
await user.click(
within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt")
);
expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)");
expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work");
});
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
await user.click(screen.getByRole("button", { name: "Abort merge" }));
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
);
});
});
describe("gitChangeFor", () => {
// One hunk, two change blocks three lines apart: an edit and a removal.
const file = {
path: "a.txt",
status: " M",
untracked: false,
added: 1,
deleted: 2,
binary: false,
hunks: [
{
header: "@@ -1,6 +1,5 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
{ type: "ctx" as const, oldLine: 4, newLine: 4, text: "d" },
{ type: "del" as const, oldLine: 5, newLine: null, text: "e" },
{ type: "ctx" as const, oldLine: 6, newLine: 5, text: "f" },
],
},
],
};
it("finds git's block by its line span on both sides", () => {
expect(gitChangeFor(file, { oldFrom: 1, oldCount: 1, newFrom: 1, newCount: 1 })?.block).toBe(0);
// A pure removal: matched on the old side alone.
expect(gitChangeFor(file, { oldFrom: 5, oldCount: 1, newFrom: 5, newCount: 0 })?.block).toBe(1);
});
it("finds none when the editor split the change differently", () => {
expect(
gitChangeFor(file, { oldFrom: 1, oldCount: 2, newFrom: 1, newCount: 1 })
).toBeUndefined();
});
});