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