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:
2026-10-06 15:38:25 +07:00
parent f0500dc592
commit daf5b7be92
65 changed files with 2414 additions and 587 deletions
+29 -20
View File
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../../components/settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
});
it.each([
["/settings", "/settings/general"],
["/settings", "/settings/appearance"],
["/settings/general", "/settings/appearance"],
["/settings/pet", "/settings/appearance"],
["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("where").textContent).toBe(to);
});
it("sends an unknown section to General once the lists have loaded", async () => {
it("sends an unknown section to Appearance once the lists have loaded", async () => {
renderAt("/settings/nope");
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
});
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" },
});
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing");
const links = within(nav())
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus();
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
renderAt("/settings/data");
await screen.findByTestId("settings-data");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links).not.toContain("Pet");
});
it("scrolls to and highlights the anchor the URL names", async () => {
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to General", async () => {
it("toasts when an unknown section falls back to Appearance", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});