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
+2 -6
View File
@@ -66,12 +66,8 @@ const burst = () => {
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
it("never reads server info on Notifications, even on activity", async () => {
render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
+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();
});
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
</div>
`;
exports[`screen snapshots > Settings 1`] = `
<div>
<div
class="animate-fade-in space-y-8"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Language
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
The language of the whole dashboard, remembered in this browser.
</p>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Interface language
</p>
</div>
<div
class="shrink-0"
>
<div
aria-label="Interface language"
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
role="radiogroup"
>
<button
aria-checked="true"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
role="radio"
tabindex="0"
type="button"
>
English
</button>
<button
aria-checked="false"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
role="radio"
tabindex="-1"
type="button"
>
Tiếng Việt
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
`;
exports[`screen snapshots > Settings — about 1`] = `
<div>
<div
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.2.0 (rebuild client to match)
UI build v2.2.1 (rebuild client to match)
</p>
</div>
<div
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act, screen, fireEvent, within } from "@testing-library/react";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
it("Run", async () => {
await snapshot(<Workspace />, "/run");
});
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Settings — notifications", async () => {
await snapshot(<Settings section="notifications" />, "/settings/notifications");
});
// General split into General / Data / About: keep the moved content covered.
it("Settings — data", async () => {
await snapshot(<Settings section="data" />, "/settings/data");
});
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
});
});
// The language picker moved off the sidebar into Settings → General.
describe("Settings — language", () => {
it("switches the interface language from General", async () => {
render(
<MemoryRouter initialEntries={["/settings/general"]}>
<Settings section="general" />
</MemoryRouter>
);
await settle();
const group = screen.getByRole("radiogroup", { name: "Interface language" });
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true"
);
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
await settle();
expect(i18n.language).toBe("vi");
await act(async () => {
await i18n.changeLanguage("en");
});
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.