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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user