feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
@@ -0,0 +1,125 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
* Clear all data stays disabled until the confirmation phrase is typed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../components/ImportHistory", () => ({ ImportHistory: () => null }));
vi.mock("../../components/RemoteSources", () => ({ RemoteSources: () => null }));
vi.mock("../../components/AlertsNotifications", () => ({ AlertsNotifications: () => null }));
const info = vi.fn(async () => ({
db: { path: "/tmp/x.db", size: 1, counts: { sessions: 1 } },
hooks: { installed: true, path: "", hooks: {} },
server: { version: "1", uptime: 1, node_version: "v1", platform: "linux", ws_connections: 0 },
}));
const cleanup = vi.fn(async (p: { dry_run?: boolean }) => ({
ok: true,
dry_run: p.dry_run,
abandoned: 0,
purged_sessions: 2,
purged_events: 5,
purged_agents: 1,
}));
const clearData = vi.fn(async () => ({ ok: true, cleared: { sessions: 1 } }));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
},
},
};
});
import { Settings } from "../Settings";
import { eventBus } from "../../lib/eventBus";
beforeEach(() => {
info.mockClear();
cleanup.mockClear();
clearData.mockClear();
});
const burst = () => {
for (let i = 0; i < 5; i++)
eventBus.publish({ type: "new_event", data: {} } as unknown as Parameters<
typeof eventBus.publish
>[0]);
};
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" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
expect(info).toHaveBeenCalledTimes(1);
burst();
await new Promise((r) => setTimeout(r, 50));
expect(info).toHaveBeenCalledTimes(1);
});
it("refuses a fractional purge and confirms a dry-run count before the real run", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
const days = await screen.findByLabelText(/purge completed/i);
const run = screen.getByRole("button", { name: /run cleanup/i });
await user.clear(days);
await user.type(days, "0.5");
expect(run).toBeDisabled();
await user.clear(days);
await user.type(days, "30");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(run);
await waitFor(() => expect(pending).not.toBeNull());
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup.mock.calls[0]![0]).toMatchObject({ purge_days: 30, dry_run: true });
expect(pending!.message).toMatch(/2 old session/);
settleConfirm(true);
off();
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(2));
expect(cleanup.mock.calls[1]![0]).not.toHaveProperty("dry_run");
});
it("enables Clear all data only once the phrase is typed", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
await user.click(await screen.findByRole("button", { name: /clear all data/i }));
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
expect(dialog).not.toBeInTheDocument();
});
});