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
@@ -13,6 +13,12 @@ import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -89,8 +95,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.clear(input);
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
const hooks = container.querySelector("section#hooks") as HTMLElement;
await user.click(within(hooks).getByRole("button"));
@@ -106,8 +114,10 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
beforeEach(() => {
i18n.changeLanguage("en");
@@ -80,4 +81,42 @@ describe("HomeDirSetting", () => {
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
});
it("saves on Enter", async () => {
const user = userEvent.setup();
const { saveFn } = setup({ value: "/a", envOwned: false }, (p) =>
Promise.resolve({ value: p, envOwned: false })
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b{Enter}");
expect(saveFn).toHaveBeenCalledWith("/b");
});
it("asks first when given a confirm, and saves nothing on cancel", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
const saveFn = vi.fn((p: string) => Promise.resolve({ value: p, envOwned: false }));
render(
<HomeDirSetting
id="x-home"
title="X_HOME"
description="desc"
placeholder="/path"
confirm={{ title: "Change X_HOME?", message: "sure?" }}
load={() => Promise.resolve({ value: "/a", envOwned: false })}
save={saveFn}
/>
);
const input = await screen.findByDisplayValue("/a");
await user.clear(input);
await user.type(input, "/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(pending?.title).toBe("Change X_HOME?"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(saveFn).not.toHaveBeenCalled();
off();
});
});
@@ -15,6 +15,18 @@ import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
/** Answer every confirm dialog with `ok`; returns the asked titles + unsubscribe. */
function answerConfirms(ok: boolean) {
const asked: PendingConfirm[] = [];
const off = subscribeConfirm((p) => {
if (!p) return;
asked.push(p);
queueMicrotask(() => settleConfirm(ok));
});
return { asked, off };
}
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -113,16 +125,56 @@ describe("PricingSettings", () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
await vi.waitFor(() => expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%"));
confirm.off();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
});
it("asks before deleting a rule, saying the sync will not bring it back", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
expect(api.pricing.delete).not.toHaveBeenCalled();
confirm.off();
});
it("refreshes every pricing table on the page after a change in one", async () => {
vi.mocked(api.pricing.delete)
.mockReset()
.mockResolvedValue({} as never);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(
<>
<PricingSettings vendor="claude" id="a" />
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
confirm.off();
});
});
@@ -0,0 +1,45 @@
/**
* @file SettingsRow.test.tsx
* @description `SettingsSegmented` is a real radio group: only the checked
* option is in the tab order, and the arrow keys move (and select) within it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { SettingsSegmented } from "../SettingsRow";
function Group() {
const [v, setV] = useState<"a" | "b" | "c">("a");
return (
<SettingsSegmented
ariaLabel="mode"
value={v}
onChange={setV}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
]}
/>
);
}
describe("SettingsSegmented", () => {
it("roves the tab stop and moves with the arrow keys", async () => {
const user = userEvent.setup();
render(<Group />);
const [a, b, c] = screen.getAllByRole("radio");
expect(a).toHaveAttribute("tabindex", "0");
expect(b).toHaveAttribute("tabindex", "-1");
a!.focus();
await user.keyboard("{ArrowRight}");
expect(b).toHaveAttribute("aria-checked", "true");
expect(b).toHaveFocus();
await user.keyboard("{ArrowLeft}{ArrowLeft}");
expect(c).toHaveAttribute("aria-checked", "true");
expect(c).toHaveFocus();
});
});