/** * @file LaneChangesPanel.test.tsx * @description Pins the Changes panel's five modes and its three ranges: the * overview lists files and nothing else, clicking one opens that file's diff * (and asks the server for THAT path), Review asks for every file's hunks at * once, the range toggle changes which comparison is requested, History opens * one commit, and Files quick-opens a whole file. Also pins the per-file git * writes — including discard needing two clicks before the server hears * `confirm` — and the degrade for a cwd git cannot read. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; 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"; import type { LaneChanges } from "../../../lib/types"; vi.mock("../../../lib/api", () => ({ api: { lanes: { git: vi.fn(), gitBranches: vi.fn(), changes: vi.fn(), gitAction: vi.fn(), gitLog: vi.fn(), gitFile: vi.fn(), gitLs: vi.fn(), gitTree: vi.fn(), gitTreeAll: vi.fn(), }, }, })); const HUNK = { header: "@@ -1,2 +1,3 @@", 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: "old line" }, { type: "add" as const, oldLine: null, newLine: 2, text: "new line" }, ], }; function changes(over: Partial> = {}): LaneChanges { return { available: true, range: "work", base: "development", baseRef: "origin/development", sha: null, context: 3, truncated: false, files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, unstaged: true, added: 12, deleted: 3, binary: false, }, { path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false }, { path: "docs/API.md", status: "M ", untracked: false, staged: true, added: 2, deleted: 1, binary: false, }, ], ...over, }; } beforeEach(() => { vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false }); vi.mocked(api.lanes.changes).mockReset(); vi.mocked(api.lanes.changes).mockResolvedValue(changes()); vi.mocked(api.lanes.gitAction).mockReset(); vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" }); vi.mocked(api.lanes.gitLog).mockResolvedValue({ available: true, commits: [ { sha: "a".repeat(40), short: "aaaaaaa", subject: "feat: the commit", author: "t", date: "2026-09-22T01:00:00Z", refs: ["HEAD -> feat/thing"], }, ], }); 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: "", entries: [{ name: "src", path: "src", dir: true }], truncated: false, }); vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: false, lines: ["const whole = 1;"], }); }); describe("LaneChangesPanel", () => { it("opens on the overview: a summary request, files listed, no diff bodies", async () => { render(); expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument(); expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" }); expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument(); expect(screen.getByText("+12")).toBeInTheDocument(); expect(screen.queryByText(/new line/)).toBeNull(); }); it("opens one file's diff when its row is clicked, asking for that path only", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); vi.mocked(api.lanes.changes).mockResolvedValue( changes({ files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, added: 12, deleted: 3, binary: false, hunks: [HUNK], }, ], }) ); await user.click(screen.getByText("server/lib/git-ops.js")); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", path: "server/lib/git-ops.js", context: 3, }) ); // The highlighter splits a line into one span per token, so assert on the // rendered text of the whole view rather than on a single node. const view = await screen.findByTestId("lane-diff-view"); expect(view).toHaveTextContent("new line"); // Both gutters are rendered, so a reviewer can cite a line number. expect(view).toHaveTextContent("keep"); }); it("asks for every file's hunks in review mode", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "Review" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true }) ); expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument(); }); it("switches the comparison when the range toggle is used", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "vs base" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" })); }); it("says so, quietly, when the lane's directory is not a git repo", async () => { vi.mocked(api.lanes.changes).mockResolvedValue({ available: false, range: "work", files: [], }); render(); expect( await screen.findByText("This lane's directory is not a readable git repository.") ).toBeInTheDocument(); }); it("stages one file and asks the server for that path alone", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); // Both rows offer it (one modified, one untracked); the first is the // modified file the assertion below names. const [stage] = screen.getAllByRole("button", { name: "Stage this file" }); await user.click(stage!); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["server/lib/git-ops.js"], }); }); it("heads the panel with the lane's git line, and a stage re-reads it", async () => { vi.mocked(api.lanes.git).mockResolvedValue({ available: true, branch: "feat/thing", head: "9b3e74a", subject: "wip", dirty: 2, untracked: 1, files: [], hasRemote: false, upstream: null, ahead: null, behind: null, }); const user = userEvent.setup(); render(); expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing"); expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument(); expect(api.lanes.git).toHaveBeenCalledTimes(1); const [stage] = screen.getAllByRole("button", { name: "Stage this file" }); await user.click(stage!); // The header's counts moved with the index: waiting out its 30s poll // would leave them wrong beside the list that just changed. await waitFor(() => expect(api.lanes.git).toHaveBeenCalledTimes(2)); }); it("re-reads the diff after a git bar action", async () => { vi.mocked(api.lanes.git).mockResolvedValue({ available: true, branch: "feat/thing", head: "9b3e74a", subject: "wip", dirty: 0, untracked: 0, files: [], hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 1, }); const user = userEvent.setup(); render(); await screen.findByTestId("lane-git-bar"); const before = vi.mocked(api.lanes.changes).mock.calls.length; await user.click(screen.getByRole("button", { name: /Pull/ })); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "pull", {}); // A pull rewrites the working tree the list below describes. await waitFor(() => expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before) ); }); it("re-reads history too when Refresh is pressed there", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "History" })); await screen.findByText("feat: the commit"); vi.mocked(api.lanes.gitLog).mockClear(); await user.click(screen.getByRole("button", { name: "Refresh" })); // A pull or a switch from the git bar moves the commit list as well. await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1)); }); it("stages every file the index lacks in one call, from above the list", async () => { const user = userEvent.setup(); render(); const list = await screen.findByTestId("lane-changes-overview"); const actions = screen.getByTestId("lane-changes-actions"); // Commit box and Stage all come BEFORE the file list. expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Stage all (2)" })); // The already-staged docs/API.md is not sent again. expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["server/lib/git-ops.js", "notes.txt"], }); }); it("needs two clicks to discard, and only the second one reaches the server", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" }); await user.click(discard!); expect(api.lanes.gitAction).not.toHaveBeenCalled(); await user.click( screen.getByRole("button", { name: "Click again to discard — this cannot be undone" }) ); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", { paths: ["server/lib/git-ops.js"], confirm: true, }); }); 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(); 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(); 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(); 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((r) => (resolveOld = r)) : Promise.resolve( changes({ files: [ { path: "lane8.txt", status: "??", untracked: true, added: 1, deleted: 0, binary: false, }, ], }) ) ); const { rerender } = render(); rerender(); 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(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "History" })); await user.click(await screen.findByText("feat: the commit")); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "commit", sha: "a".repeat(40), full: true, }) ); expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa"); }); it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => path === "src" ? { available: true, path: "src", entries: [{ name: "main.ts", path: "src/main.ts", dir: false }], truncated: false, } : { available: true, path: "", entries: [{ name: "src", path: "src", dir: true }], truncated: false, } ); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "Files" })); await user.click(await screen.findByText("src")); expect(await screen.findByText("main.ts")).toBeInTheDocument(); // The root's "src" row is still there — expanding did not replace the list. expect(screen.getByText("src")).toBeInTheDocument(); }); it("expands every folder at once from one whole-tree read", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.gitTreeAll).mockResolvedValue({ available: true, truncated: false, levels: { "": [{ name: "src", path: "src", dir: true }], src: [{ name: "main.ts", path: "src/main.ts", dir: false }], }, }); vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => { if (path === "src") return { available: true, path: "src", entries: [{ name: "main.ts", path: "src/main.ts", dir: false }], truncated: false, }; return { available: true, path: "", entries: [{ name: "src", path: "src", dir: true }], truncated: false, }; }); render(); 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" })); expect(await screen.findByText("main.ts")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Collapse all folders" })); expect(screen.queryByText("main.ts")).not.toBeInTheDocument(); expect(screen.getByText("src")).toBeInTheDocument(); }); it("quick-opens a tracked file as its whole content, not a diff", async () => { const user = userEvent.setup(); render(); 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 user.click(await screen.findByText("src/main.ts")); await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work")); expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); }); it("switches the diff to side-by-side", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); vi.mocked(api.lanes.changes).mockResolvedValue( changes({ files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, added: 12, deleted: 3, binary: false, hunks: [HUNK], }, ], }) ); await user.click(screen.getByText("server/lib/git-ops.js")); expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified"); await user.click(screen.getByRole("button", { name: "Side-by-side" })); expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split"); }); it("expands and then collapses the context again", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByText("server/lib/git-ops.js")); await screen.findByRole("button", { name: "Show more surrounding lines" }); // Collapse is dead at the tightest step, and live once expanded — an // expanded diff that cannot be collapsed sends the reader back out of the // file to get the tight view. expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled(); await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", path: "server/lib/git-ops.js", context: 12, }) ); await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", path: "server/lib/git-ops.js", context: 3, }) ); }); it("asks for more context when the diff is expanded", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByText("server/lib/git-ops.js")); await screen.findByText(/server\/lib\/git-ops\.js/); await user.click(screen.getByRole("button", { name: "Show more surrounding lines" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", path: "server/lib/git-ops.js", context: 12, }) ); }); it("commits the index from the overview, never the whole tree", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); // The box exists only because a file is staged — with a clean index there // is nothing this button could mean. const box = await screen.findByTestId("lane-changes-commit"); await user.type(screen.getByLabelText("Commit message"), "fix: the staged half"); await user.click(screen.getByRole("button", { name: "Commit 1 staged" })); expect(box).toBeInTheDocument(); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", { message: "fix: the staged half", only: "staged", }); }); it("drops a half-typed commit message on a lane switch", async () => { const user = userEvent.setup(); const { rerender } = render(); await screen.findByTestId("lane-changes-commit"); await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7"); rerender(); 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({ files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, unstaged: true, added: 1, deleted: 0, binary: false, }, ], }) ); render(); await screen.findByTestId("lane-changes-overview"); expect(screen.queryByTestId("lane-changes-commit")).toBeNull(); }); it("puts Files first in the mode row", async () => { render(); await screen.findByTestId("lane-changes-overview"); const modes = ["Files", "Overview", "Review", "History"].map((name) => screen.getByRole("button", { name }) ); // Source order is the reading order here, so comparing positions pins it. for (let i = 1; i < modes.length; i += 1) { expect( modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); } }); it("folds one file shut in review mode, and all of them at once", async () => { const user = userEvent.setup(); const withHunks = changes({ files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, added: 1, deleted: 1, binary: false, hunks: [HUNK], }, { path: "notes.txt", status: "??", untracked: true, added: 1, deleted: 0, binary: false, hunks: [HUNK], }, ], }); render(); await screen.findByTestId("lane-changes-overview"); vi.mocked(api.lanes.changes).mockResolvedValue(withHunks); await user.click(screen.getByRole("button", { name: "Review" })); await screen.findByTestId("lane-changes-review"); expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2); await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })); expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1); // The header stays, so a folded file is still a row you can reopen. expect( screen.getByRole("button", { name: "Expand server/lib/git-ops.js" }) ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Collapse every file" })); expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0); await user.click(screen.getByRole("button", { name: "Expand every file" })); expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2); }); describe("review actions", () => { const reviewed = () => changes({ files: [ { path: "server/lib/git-ops.js", status: " M", untracked: false, unstaged: true, added: 1, deleted: 1, binary: false, hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }], }, { path: "notes.txt", status: "??", untracked: true, added: 1, deleted: 0, binary: false, hunks: [HUNK], }, ], }); const openReview = async (user: ReturnType) => { render(); await screen.findByTestId("lane-changes-overview"); vi.mocked(api.lanes.changes).mockResolvedValue(reviewed()); await user.click(screen.getByRole("button", { name: "Review" })); await screen.findByTestId("lane-changes-review"); }; it("stages one hunk by its @@ line, at the context the diff was read with", async () => { const user = userEvent.setup(); await openReview(user); // Two hunks on the tracked file; the untracked one has none to act on. const stage = screen.getAllByRole("button", { name: "Stage this hunk" }); expect(stage).toHaveLength(2); await user.click(stage[1]!); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", { path: "server/lib/git-ops.js", hunk: "@@ -9,2 +10,3 @@", context: 3, }); }); it("needs two clicks to undo a hunk, and arms only that hunk", async () => { const user = userEvent.setup(); await openReview(user); const [first] = screen.getAllByRole("button", { name: "Undo this hunk" }); await user.click(first!); expect(api.lanes.gitAction).not.toHaveBeenCalled(); // The other hunk is still at its first click. expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1); await user.click( screen.getByRole("button", { name: "Click again to undo this hunk — this cannot be undone", }) ); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", { path: "server/lib/git-ops.js", hunk: "@@ -1,2 +1,3 @@", context: 3, confirm: true, }); }); it("stages a whole file from its review header without folding it", async () => { const user = userEvent.setup(); await openReview(user); const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" }); await user.click(stageFile!); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["server/lib/git-ops.js"], }); expect( screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }) ).toBeInTheDocument(); }); it("keeps each file's header pinned while its diff scrolls", async () => { const user = userEvent.setup(); await openReview(user); expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass( "sticky", "top-0" ); }); }); it("opens a file only from its path, and folds it from anywhere else", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "Review" })); await screen.findByTestId("lane-changes-review"); // Anywhere on the header but the path: fold, do not navigate. await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })); expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Expand server/lib/git-ops.js" }) ).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" })); await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", path: "server/lib/git-ops.js", context: 3, }) ); }); it("opens, not folds, when Enter is pressed on the path", async () => { const user = userEvent.setup(); render(); 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 ); const { rerender } = render(); 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(); 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 ) .mockResolvedValueOnce({ available: true, paths: ["new/mainx.ts"], truncated: false, total: 1, }); render(); 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(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "Review" })); await screen.findByTestId("lane-changes-review"); // jsdom lays nothing out, so the scrollable box is described by hand — // the restore deliberately waits until the list is tall enough again. const body = screen.getByTestId("lane-changes-body"); Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true }); Object.defineProperty(body, "clientHeight", { value: 400, configurable: true }); body.scrollTop = 640; await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" })); await screen.findByRole("button", { name: /All files/ }); await user.click(screen.getByRole("button", { name: /All files/ })); // Back to REVIEW (where the file was opened from), not to the overview. 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(); 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(); 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 ONE request, not one per folder", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.gitTree).mockClear(); vi.mocked(api.lanes.gitTreeAll) .mockClear() .mockResolvedValue({ available: true, truncated: false, levels: { "": [{ name: "src", path: "src", dir: true }], src: [{ name: "main.ts", path: "src/main.ts", dir: false }], }, }); render(); 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" })); expect(await screen.findByText("main.ts")).toBeInTheDocument(); expect(api.lanes.gitTreeAll).toHaveBeenCalledTimes(1); // Only the root read the Files tab made on entry — no per-folder walk. expect(api.lanes.gitTree).toHaveBeenCalledTimes(1); }); 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 ); 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(); await screen.findByTestId("lane-changes-overview"); const [stage] = screen.getAllByRole("button", { name: "Stage this file" }); await user.click(stage!); rerender(); 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 read outlives a lane switch", async () => { const user = userEvent.setup(); let resolveSrc: () => void = () => {}; vi.mocked(api.lanes.gitTreeAll).mockImplementation( () => new Promise( (r) => (resolveSrc = () => r({ available: true, truncated: false, levels: { "": [{ name: "src", path: "src", dir: true }], src: [{ name: "main.ts", path: "src/main.ts", dir: false }], }, })) ) ); vi.mocked(api.lanes.gitTree).mockImplementation((id) => { 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(); 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(); 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(); }); });