feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||
Range.prototype.getClientRects ??= () => EMPTY;
|
||||
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||
|
||||
describe("keepsText", () => {
|
||||
it("is false for a line whose every non-blank character changed", () => {
|
||||
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||
expect(keepsText("", 0, [])).toBe(false);
|
||||
// Only a `;` survived: not the same line.
|
||||
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when some text outside the changes survives", () => {
|
||||
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffEditor", () => {
|
||||
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"keep\nconst x = 2;\nbrand new"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||
expect(left).toHaveAttribute("contenteditable", "false");
|
||||
expect(right).toHaveAttribute("contenteditable", "true");
|
||||
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert");
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,36 @@ import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
original: string;
|
||||
layout?: string;
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
data-original={p.original}
|
||||
data-layout={p.layout}
|
||||
data-collapse={String(!!p.collapse)}
|
||||
aria-label={p.label}
|
||||
autoFocus
|
||||
value={p.value}
|
||||
onChange={(e) => p.onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
p.onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
@@ -90,6 +120,11 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -98,6 +133,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -113,6 +149,7 @@ beforeEach(() => {
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -192,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
await toDiff(user);
|
||||
// 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");
|
||||
@@ -247,6 +285,45 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
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("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -393,12 +470,12 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -427,7 +504,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -452,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
||||
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);
|
||||
// Opening a file is editing it, diffed against its HEAD text.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it 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();
|
||||
// …and "discard" reverts it, still editing.
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -488,31 +565,40 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
// Changes is the editor folded to its changes, side by side at full width.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("a");
|
||||
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||
expect(editor).toHaveAttribute("data-layout", "split");
|
||||
await user.type(editor, "b");
|
||||
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||
vi.mocked(confirmDiscard).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
const whole = await screen.findByTestId("lane-file-editor");
|
||||
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||
expect(whole).toHaveValue("ab");
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
it("opens diffs unified at the narrow width", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("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();
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -640,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -785,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
||||
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);
|
||||
@@ -798,9 +884,9 @@ describe("LaneChangesPanel", () => {
|
||||
"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();
|
||||
// Still editing, on what was saved: nothing left to save.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
@@ -844,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
||||
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"));
|
||||
|
||||
@@ -885,6 +970,7 @@ describe("LaneChangesPanel", () => {
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
@@ -896,6 +982,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
@@ -926,7 +1013,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
await toDiff(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const fillers = [
|
||||
...screen
|
||||
.getByTestId("lane-diff-split-right")
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
// A line added or removed whole: nothing in common with its pair.
|
||||
const WHOLE = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||
]);
|
||||
|
||||
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const add = rowOf(container, "xyz");
|
||||
const del = rowOf(container, "abc");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||
});
|
||||
|
||||
it("tints a line edited in place blue on both sides", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
for (const text of ["x = 1", "x = 2"]) {
|
||||
const row = rowOf(container, text);
|
||||
expect(row.className).toContain("bg-diff-mod/20");
|
||||
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const plus = marker(rowOf(container, "xyz"));
|
||||
const minus = marker(rowOf(container, "abc"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
it("renders the live branch, head and subject", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
||||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
const onShowAll = vi.fn();
|
||||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||
expect(onShowAll).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.focus(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
await screen.findByTestId("lane-git");
|
||||
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the rail's counts vertical", async () => {
|
||||
|
||||
Reference in New Issue
Block a user