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