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
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
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";
@@ -94,12 +94,14 @@ beforeEach(() => {
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
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: "",
@@ -229,6 +231,76 @@ describe("LaneChangesPanel", () => {
});
});
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} />);
@@ -411,6 +483,16 @@ describe("LaneChangesPanel", () => {
});
});
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({
@@ -515,6 +597,73 @@ describe("LaneChangesPanel", () => {
);
});
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} />);
@@ -537,4 +686,152 @@ describe("LaneChangesPanel", () => {
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 at most four tree reads in flight", async () => {
const user = userEvent.setup();
let inFlight = 0;
let peak = 0;
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path = "") => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
const depth = path ? path.split("/").length : 0;
const entries =
depth >= 2
? [{ name: "leaf.ts", path: `${path}/leaf.ts`, dir: false }]
: Array.from({ length: 5 }, (_, i) => ({
name: `d${i}`,
path: path ? `${path}/d${i}` : `d${i}`,
dir: true,
}));
return { available: true, path, entries, truncated: false };
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("d0");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
await waitFor(() => expect(screen.getAllByText("leaf.ts")).toHaveLength(25));
// Root + 5 + 25 folders, each fetched exactly once.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(31);
expect(peak).toBeLessThanOrEqual(4);
});
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 walk outlives a lane switch", async () => {
const user = userEvent.setup();
let resolveSrc: () => void = () => {};
vi.mocked(api.lanes.gitTree).mockImplementation((id, path) => {
if (id === 7 && path === "src")
return new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
}))
);
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();
});
});