Files
AgentHub/client/src/components/lanes/__tests__/LaneDiffView.test.tsx
T
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00

579 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file LaneDiffView.test.tsx
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
added: 1,
deleted: 1,
binary: false,
hunks: [
{
header: "@@ -1,2 +1,2 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
],
},
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
});
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
// Two lines removed, none added: the right column's two rows are fillers.
const file = fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
]);
render(<LaneDiffView file={file} layout="split" />);
const fillers = [
...screen
.getByTestId("lane-diff-split-right")
.querySelectorAll<HTMLElement>("div.flex.w-max"),
];
expect(fillers).toHaveLength(2);
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
});
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
// windowed one keeps the cap because windowing needs its own scroller.
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
const big: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
type: "ctx" as const,
oldLine: i + 1,
newLine: i + 1,
text: `line ${i}`,
})),
},
],
};
rerender(<LaneDiffView file={big} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
it("mirrors one column's horizontal scroll onto the other", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollLeft = 320;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(320);
right.scrollLeft = 0;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(0);
});
it("keeps the split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
}
const ROWS = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
]);
/** The unified row whose text contains `needle`. */
function rowOf(container: HTMLElement, needle: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
r.textContent?.includes(needle)
);
if (!row) throw new Error(`no row for ${needle}`);
return row;
}
describe("LaneDiffView — row styling", () => {
// A line added or removed whole: nothing in common with its pair.
const WHOLE = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
]);
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const add = rowOf(container, "xyz");
const del = rowOf(container, "abc");
const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
});
it("tints a line edited in place blue on both sides", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
for (const text of ["x = 1", "x = 2"]) {
const row = rowOf(container, text);
expect(row.className).toContain("bg-diff-mod/20");
expect(row.className).not.toMatch(/status-(success|danger)/);
}
});
it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "xyz"));
const minus = marker(rowOf(container, "abc"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
expect(plus.className).toContain("text-status-success");
expect(minus.textContent).toBe("-");
expect(minus.className).toContain("text-status-danger");
expect(ctx.className).toContain("text-fg-muted");
expect(ctx.className).not.toContain("font-bold");
});
it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
});
it("does not emphasise a line that changed completely", () => {
const { container } = render(
<LaneDiffView
file={fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
])}
layout="unified"
/>
);
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
render(
<LaneDiffView
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
layout="split"
/>
);
const rows = (id: string) => [
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
];
const [right] = rows("lane-diff-split-right");
const [left] = rows("lane-diff-split-left");
expect(right!.className).toContain("bg-status-success/20");
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
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: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
describe("LaneDiffView — hunk review bar navigation", () => {
const hunk = (n: number) => ({
header: `@@ -${n},1 +${n},1 @@`,
context: "",
oldStart: n,
newStart: n,
lines: [
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
],
});
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
function renderInScroller() {
const view = render(
<div data-testid="scroller" style={{ overflowY: "auto" }}>
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
</div>
);
const scroller = screen.getByTestId("scroller");
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
Object.defineProperty(scroller, "clientHeight", { value: 100 });
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
const k = Number(el.dataset.changeIndex);
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
}
return scroller;
}
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
const scroller = renderInScroller();
const next = screen.getAllByRole("button", { name: "Next change" });
const prev = screen.getAllByRole("button", { name: "Previous change" });
fireEvent.click(next[0]!); // bar 1 → hunk 2
expect(scroller.scrollTop).toBe(100);
fireEvent.click(next[1]!); // bar 2 → hunk 3
expect(scroller.scrollTop).toBe(200);
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
expect(scroller.scrollTop).toBe(100);
});
it("counts the hunks and disables the arrows at either end", () => {
renderInScroller();
const bars = screen.getAllByTestId("lane-hunk-bar");
expect(bars.map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
});
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
const merged: LaneChangeFile = {
...FILE,
hunks: [
{
header: "@@ -1,6 +1,6 @@",
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: "del" as const, oldLine: 3, newLine: null, text: "c" },
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
],
},
],
};
const seen: number[] = [];
for (const layout of ["unified", "split"] as const) {
const { unmount } = render(
<LaneDiffView
file={merged}
layout={layout}
hunkActions={(c) => {
seen.push(c.block);
return <span />;
}}
/>
);
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
expect.stringContaining("1 of 3"),
expect.stringContaining("2 of 3"),
expect.stringContaining("3 of 3"),
]);
unmount();
}
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
});
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
render(<LaneDiffView file={three} layout="unified" />);
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
});
});
describe("LaneDiffView — line comments", () => {
it("writes a comment on a new line and hands over `<path>:<line> — <text>`", () => {
const sent: string[] = [];
let delivered = false;
render(
<LaneDiffView
file={FILE}
layout="unified"
onComment={(text) => {
sent.push(text);
return delivered;
}}
/>
);
fireEvent.click(screen.getByTestId("diff-comment-line-1"));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "rename this" } });
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(sent).toEqual(["server/lib/git-ops.js:1 — rename this"]);
// No pane took it: the box stays, with the reason.
expect(screen.getByRole("alert")).toBeInTheDocument();
delivered = true;
fireEvent.submit(screen.getByTestId("diff-comment-box"));
expect(screen.queryByTestId("diff-comment-box")).toBeNull();
});
it("docks the box under the diff once its line scrolls out of a windowed diff", () => {
render(<LaneDiffView file={FILE} layout="unified" onComment={() => true} />);
const btn = screen.getByTestId("diff-comment-line-1");
fireEvent.click(btn);
expect(screen.getByTestId("diff-comment-box").style.position).toBe("fixed");
// What windowing does to a row scrolled out of range.
btn.remove();
fireEvent.scroll(screen.getByTestId("lane-diff-view"));
expect(screen.getByTestId("diff-comment-box").style.position).toBe("");
});
it("offers no comment button without onComment", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
});
});