/** * @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, within } 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(), gitSaveFile: 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, }; } /** The overview row's discard button for one path. */ function discardOf(path: string) { return within(screen.getByText(path).closest("li")!).getByRole("button", { name: "Discard this file's changes", }); } 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("lists staged files apart from the changes the next commit leaves behind", async () => { render(); const list = await screen.findByTestId("lane-changes-overview"); const staged = list.querySelector('[data-group="staged"]') as HTMLElement; const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement; expect(within(staged).getByText("docs/API.md")).toBeInTheDocument(); expect(within(staged).queryByText("notes.txt")).toBeNull(); expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument(); expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument(); expect(within(unstaged).queryByText("docs/API.md")).toBeNull(); expect(screen.getByText("Staged Changes")).toBeInTheDocument(); }); it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => { vi.mocked(api.lanes.gitTree).mockResolvedValue({ available: true, path: "", entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }], truncated: false, }); vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: false, lines: ["const whole = 1;"], sha: "b".repeat(40), }); const user = userEvent.setup(); render(); const sidebar = await screen.findByTestId("lane-changes-sidebar"); // The sidebar IS the Files view: its finder lives there, and the tab is gone. expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Files" })).toBeNull(); expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1); await user.click(await within(sidebar).findByText("main.ts")); // Opening a file only shows it: no editor until the text itself is clicked. const view = await screen.findByTestId("lane-file-view"); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-edit")).toBeNull(); await user.click(view); const editor = await screen.findByTestId("lane-file-editor"); expect(editor).toHaveValue("const whole = 1;"); expect(editor).toHaveFocus(); expect(screen.getByTestId("lane-file-save")).toBeDisabled(); await user.type(editor, "x"); expect(screen.getByTestId("lane-file-save")).toBeEnabled(); // Cancel drops the edit and goes back to reading. await user.click(screen.getByRole("button", { name: "Cancel" })); expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;"); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); it("at full width opens a changed file from the overview for reading, not editing", async () => { vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "server/lib/git-ops.js", rev: "work", binary: false, truncated: false, lines: ["a"], sha: "b".repeat(40), }); const user = userEvent.setup(); render(); const list = await screen.findByTestId("lane-changes-overview"); await user.click(within(list).getByText("server/lib/git-ops.js")); expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a"); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); it("does not enter the editor when a click only ends a text selection", async () => { vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "server/lib/git-ops.js", rev: "work", binary: false, truncated: false, lines: ["copy me"], sha: "b".repeat(40), }); const user = userEvent.setup(); render(); const list = await screen.findByTestId("lane-changes-overview"); await user.click(within(list).getByText("server/lib/git-ops.js")); const view = await screen.findByTestId("lane-file-view"); const spy = vi.spyOn(window, "getSelection").mockReturnValue({ toString: () => "copy", } as unknown as Selection); await user.click(view); spy.mockRestore(); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); 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"); await user.click(discardOf("server/lib/git-ops.js")); 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"); await user.click(discardOf("server/lib/git-ops.js")); // 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("highlights modified and untracked files in the Files tree, and folders holding them", async () => { vi.mocked(api.lanes.gitTree).mockResolvedValue({ available: true, path: "", entries: [ { name: "src", path: "src", dir: true, changed: true }, { name: "docs", path: "docs", dir: true }, { name: "a.txt", path: "a.txt", dir: false, status: " M" }, { name: "new.txt", path: "new.txt", dir: false, status: "??" }, { name: "keep.txt", path: "keep.txt", dir: false }, ], truncated: false, }); const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); await user.click(screen.getByRole("button", { name: "Files" })); const row = async (name: string) => (await screen.findByText(name)).closest("button")!; expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M"); expect(await row("a.txt")).toHaveClass("text-status-warning"); expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U"); expect(await row("new.txt")).toHaveClass("text-status-success"); expect(await row("src")).toHaveAttribute("data-tree-status", "dir"); expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status"); expect(await row("docs")).not.toHaveAttribute("data-tree-status"); }); 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("edits a whole file in place and saves over the exact version it opened", async () => { vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: false, lines: ["const whole = 1;"], sha: "b".repeat(40), }); vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: false, lines: ["const whole = 2;"], sha: "c".repeat(40), }); 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 user.click(await screen.findByTestId("lane-file-view")); const editor = await screen.findByTestId("lane-file-editor"); expect(editor).toHaveValue("const whole = 1;"); await user.clear(editor); await user.type(editor, "const whole = 2;"); await user.click(screen.getByTestId("lane-file-save")); expect(api.lanes.gitSaveFile).toHaveBeenCalledWith( 7, "src/main.ts", "const whole = 2;", "b".repeat(40) ); // Back to reading, showing what was saved. expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;"); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); it("says why a file cannot be edited instead of offering a dead Edit button", async () => { vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save lines: ["partial"], }); 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")); expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent( "Too large to edit here (over 1 MB)" ); expect(screen.queryByTestId("lane-file-edit")).toBeNull(); }); it("keeps the draft and says why when the file changed on disk meanwhile", async () => { vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "src/main.ts", rev: "work", binary: false, truncated: false, lines: ["x"], sha: "b".repeat(40), }); vi.mocked(api.lanes.gitSaveFile).mockRejectedValue( Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" }) ); 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 user.click(await screen.findByTestId("lane-file-view")); await user.type(await screen.findByTestId("lane-file-editor"), "y"); await user.click(screen.getByTestId("lane-file-save")); expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/); expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy"); }); it("unstages every staged file in one call, on the same row as Stage all and commit", async () => { const user = userEvent.setup(); render(); await screen.findByTestId("lane-changes-overview"); const row = screen.getByTestId("lane-changes-actions"); expect(row).toContainElement(screen.getByTestId("lane-changes-commit")); expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" })); await user.click(screen.getByRole("button", { name: "Unstage all (1)" })); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] }); }); 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 @@", block: 0, context: 3, }); }); it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", 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, unstaged: true, added: 2, deleted: 1, binary: false, hunks: [ { header: "@@ -1,4 +1,5 @@", context: "", oldStart: 1, newStart: 1, lines: [ { type: "del" as const, oldLine: 1, newLine: null, text: "a" }, { type: "add" as const, oldLine: null, newLine: 1, text: "A" }, { type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" }, { type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" }, { type: "add" as const, oldLine: null, newLine: 4, text: "new" }, { type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" }, ], }, ], }, ], }) ); await user.click(screen.getByRole("button", { name: "Review" })); await screen.findByTestId("lane-changes-review"); const bars = screen.getAllByTestId("lane-hunk-bar"); expect(bars.map((b) => b.textContent)).toEqual([ expect.stringContaining("1 of 2"), expect.stringContaining("2 of 2"), ]); await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!); expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", { path: "server/lib/git-ops.js", hunk: "@@ -1,4 +1,5 @@", block: 1, context: 3, }); }); it("hangs one review bar under each actionable hunk, counting them", async () => { const user = userEvent.setup(); await openReview(user); // Two hunks on the tracked file; the untracked file gets no bar. const bars = screen.getAllByTestId("lane-hunk-bar"); expect(bars.map((b) => b.textContent)).toEqual([ expect.stringContaining("1 of 2"), expect.stringContaining("2 of 2"), ]); // Undo sits beside Keep inside the bar — not on the @@ line. expect(bars[0]).toContainElement( screen.getAllByRole("button", { name: "Undo this hunk" })[0]! ); expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled(); expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled(); }); 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 @@", block: 0, 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(); }); }); describe("a merge in progress (issue #45)", () => { const merging = (conflicts: number) => ({ available: true as const, branch: "main", head: "abc1234", subject: "main", dirty: 1, untracked: 0, files: [], hasRemote: false, upstream: null, ahead: null, behind: null, merging: true, conflicts, }); const conflicted = changes({ files: [ { path: "a.txt", status: "UU", untracked: false, staged: true, unstaged: true, conflicted: true, added: 3, deleted: 0, binary: false, }, { path: "b.txt", status: "A ", untracked: false, staged: true, added: 1, deleted: 0, binary: false, }, ], }); it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => { vi.mocked(api.lanes.git).mockResolvedValue(merging(1)); vi.mocked(api.lanes.changes).mockResolvedValue(conflicted); const user = userEvent.setup(); render(); const group = await screen.findByTestId("lane-changes-conflicts"); expect(within(group).getByText("a.txt")).toBeInTheDocument(); // Not offered as an ordinary stage: that would mark it resolved blind. expect(screen.getAllByText("a.txt")).toHaveLength(1); expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled(); await user.click(within(group).getByRole("button", { name: "Take theirs" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", { path: "a.txt", side: "theirs", }) ); }); it("opens a conflicted file in the conflict view, not the diff", async () => { vi.mocked(api.lanes.git).mockResolvedValue(merging(1)); vi.mocked(api.lanes.changes).mockResolvedValue(conflicted); vi.mocked(api.lanes.gitFile).mockResolvedValue({ available: true, path: "a.txt", rev: "work", lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"], truncated: false, binary: false, sha: "s1", } as never); const user = userEvent.setup(); render(); await user.click( within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt") ); expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)"); expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work"); }); it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => { vi.mocked(api.lanes.git).mockResolvedValue(merging(0)); const user = userEvent.setup(); render(); await user.click(await screen.findByRole("button", { name: "Complete merge" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {})); await user.click(screen.getByRole("button", { name: "Abort merge" })); expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything()); await user.click(screen.getByRole("button", { name: "Click again to abort the merge" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true }) ); }); });