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
@@ -5,7 +5,9 @@
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +18,9 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
@@ -56,6 +60,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() {
const loc = useLocation();
@@ -180,4 +185,70 @@ describe("SettingsHub", () => {
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const field = document.createElement("input");
dialog.appendChild(field);
document.body.appendChild(dialog);
field.focus();
const ev = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(ev).toBe(true); // not prevented
expect(field).toHaveFocus();
dialog.remove();
});
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");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
const second = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(second).toBe(false); // default prevented
expect(box).toHaveFocus();
expect([box.selectionStart, box.selectionEnd]).toEqual([0, 3]);
});
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");
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]!);
expect(screen.getByTestId("where").textContent).toBe(
"/settings/agents/claude-code?tab=marketplaces"
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", 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"]);
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
});