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
@@ -10,6 +10,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});