fix(lanes,git): harden the lane git surface and fix client races in the Changes panel

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.
This commit is contained in:
2026-09-24 09:54:51 +07:00
parent 6153d50fa8
commit 7bc4594b94
24 changed files with 2832 additions and 342 deletions
@@ -3,13 +3,21 @@
* @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.
* 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 } from "../LaneDiffView";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
],
};
/** 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" />);
@@ -56,6 +75,8 @@ describe("LaneDiffView — side by side", () => {
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);
@@ -66,6 +87,55 @@ describe("LaneDiffView — side by side", () => {
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();
@@ -158,3 +228,154 @@ describe("LaneDiffView — row styling", () => {
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");
});
});