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,43 @@
/**
* @file Tests for the browser-preferences backup: only allowlisted keys leave
* (never the access token), an import writes only allowlisted keys, and a file
* that is not one of ours is refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import { exportPrefs, importPrefs, PREFS_FORMAT } from "../prefsBackup";
beforeEach(() => localStorage.clear());
describe("prefsBackup", () => {
it("exports only preference keys, never the access token", () => {
localStorage.setItem("theme", "dark");
localStorage.setItem("agenthub.pet.size", "2");
localStorage.setItem("agent-monitor-notifications", "{}");
localStorage.setItem("dashboard_token", "secret");
localStorage.setItem("sidebar-connection-stats", "{}");
const out = exportPrefs();
expect(out.format).toBe(PREFS_FORMAT);
expect(Object.keys(out.prefs).sort()).toEqual([
"agent-monitor-notifications",
"agenthub.pet.size",
"theme",
]);
});
it("imports allowlisted keys only and refuses a foreign file", () => {
const n = importPrefs(
JSON.stringify({
format: PREFS_FORMAT,
version: 1,
prefs: { theme: "light", dashboard_token: "evil", "agenthub.x": 5 },
})
);
expect(n).toBe(1);
expect(localStorage.getItem("theme")).toBe("light");
expect(localStorage.getItem("dashboard_token")).toBeNull();
expect(() => importPrefs(JSON.stringify({ format: "other", prefs: {} }))).toThrow();
expect(() => importPrefs("not json")).toThrow();
});
});
@@ -2,12 +2,19 @@
* @file settingsSearch.test.ts
* @description `rankSettings`: an empty query matches nothing, a section title
* outranks an entry title, which outranks a description and a keyword, exact
* beats prefix beats substring, and a query matching nothing returns nothing.
* beats prefix beats substring, and a query matching nothing returns nothing;
* `rankSettingsEntries` ranks rows with their deep-link target, and the index
* covers the sub-tabs and Data rows the search must reach.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { rankSettings, type SettingsEntry } from "../settingsSearch";
import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../settingsSearch";
const entries: SettingsEntry[] = [
{ section: "pricing", title: "Pricing", sectionTitle: true },
@@ -38,3 +45,42 @@ describe("rankSettings", () => {
expect(rankSettings("zzz", entries)).toEqual([]);
});
});
describe("rankSettingsEntries", () => {
it("ranks rows, keeps their deep-link target and caps the list", () => {
const rows: SettingsEntry[] = [
{ section: "agents/claude-code", title: "Skills", to: "?tab=skills" },
{ section: "data", title: "Session cleanup", keywords: ["purge"], to: "#cleanup" },
{ section: "pet", title: "Pet", sectionTitle: true },
];
expect(rankSettingsEntries("skill", rows).map((r) => r.to)).toEqual(["?tab=skills"]);
expect(rankSettingsEntries("purge", rows)[0]).toMatchObject({
section: "data",
to: "#cleanup",
});
expect(rankSettingsEntries("e", rows, 2)).toHaveLength(2);
});
it("indexes every Claude Code sub-tab and Data row the audit named", () => {
const tos = SETTINGS_INDEX.map((e) => `${e.section}${e.to ?? ""}`);
for (const t of [
"skills",
"agents",
"commands",
"memory",
"plugins",
"marketplaces",
"mcp",
"hooks",
"keybindings",
"outputStyles",
"settings",
])
expect(tos).toContain(`agents/claude-code?tab=${t}`);
expect(tos).toContain("data#cleanup");
expect(tos).toContain("data#prefs");
expect(SETTINGS_INDEX.find((e) => e.section === "worktrees")?.keywords).not.toContain(
"sidebar"
);
});
});