7bc4594b94
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls.
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
/**
|
|
* @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("bounds its own height so the horizontal bar stays on screen", () => {
|
|
// A stacked review list caps each file; the single-file view takes the
|
|
// height its parent gives it. Either way the diff scrolls itself rather
|
|
// than growing a 2000-line container whose scrollbar is off the bottom.
|
|
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
|
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
|
|
|
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
|
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
|
|
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
|
});
|
|
|
|
it("mirrors one column's horizontal scroll onto the other", () => {
|
|
render(<LaneDiffView file={FILE} layout="split" />);
|
|
const left = screen.getByTestId("lane-diff-split-left");
|
|
const right = screen.getByTestId("lane-diff-split-right");
|
|
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", () => {
|
|
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
|
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
|
const add = rowOf(container, "x = 2");
|
|
const del = rowOf(container, "x = 1");
|
|
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)/);
|
|
});
|
|
|
|
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
|
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
|
const marker = (row: HTMLElement) =>
|
|
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
|
const plus = marker(rowOf(container, "x = 2"));
|
|
const minus = marker(rowOf(container, "x = 1"));
|
|
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-fg-primary\\/15")].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-fg-primary\\/15")).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");
|
|
});
|
|
});
|