cfe832383c
Review and the single-file view gain per-hunk Stage and Undo (stage-hunk / discard-hunk: the hunk is named by its @@ line and re-matched against a fresh diff, then git apply --cached / -R; undo needs confirm: true and two clicks). Each review file header stays pinned while its diff scrolls and carries the file's own stage/unstage/discard.
1020 lines
38 KiB
TypeScript
1020 lines
38 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` — 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 } 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: {
|
|
git: vi.fn(),
|
|
gitBranches: vi.fn(),
|
|
changes: vi.fn(),
|
|
gitAction: vi.fn(),
|
|
gitLog: vi.fn(),
|
|
gitFile: vi.fn(),
|
|
gitLs: vi.fn(),
|
|
gitTree: vi.fn(),
|
|
gitTreeAll: 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.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"],
|
|
},
|
|
],
|
|
});
|
|
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;"],
|
|
});
|
|
});
|
|
|
|
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("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: "Stage this file" });
|
|
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("stages every file the index lacks in one call, from above the list", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
const actions = screen.getByTestId("lane-changes-actions");
|
|
// Commit box and Stage all come BEFORE the file list.
|
|
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
|
// The already-staged docs/API.md is not sent again.
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js", "notes.txt"],
|
|
});
|
|
});
|
|
|
|
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("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");
|
|
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
|
await user.click(discard!);
|
|
|
|
// 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.queryByText("server/lib/git-ops.js")).toBeNull());
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
await screen.findByText("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.queryByText("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" }));
|
|
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 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(<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("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 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);
|
|
});
|
|
|
|
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("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
// Two hunks on the tracked file; the untracked one has none to act on.
|
|
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
|
expect(stage).toHaveLength(2);
|
|
await user.click(stage[1]!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
|
path: "server/lib/git-ops.js",
|
|
hunk: "@@ -9,2 +10,3 @@",
|
|
context: 3,
|
|
});
|
|
});
|
|
|
|
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 @@",
|
|
context: 3,
|
|
confirm: true,
|
|
});
|
|
});
|
|
|
|
it("stages a whole file from its review header without folding it", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
|
await user.click(stageFile!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
});
|
|
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(<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 waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
|
|
|
|
rerender(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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.getByText("server/lib/git-ops.js");
|
|
await user.type(screen.getByLabelText("Commit message"), "x");
|
|
expect(screen.getByText("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(<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();
|
|
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: "Stage this file" });
|
|
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.queryByText("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(<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" }));
|
|
|
|
rerender(<LaneChangesPanel 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();
|
|
});
|
|
});
|