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
+193 -20
View File
@@ -4,6 +4,8 @@
* `rankSettings` scores sections against a query. Per field, exact beats
* prefix beats substring: section title 900/850/800, entry title 700/650/600,
* description 500/450/400, keywords 300/250/200; a section scores its best hit.
* `rankSettingsEntries` ranks the rows themselves, for the result list that
* deep-links a row's sub-tab and anchor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,9 +16,14 @@ export interface SettingsEntry {
keywords?: string[];
/** The section's own title (scored in the top tier). */
sectionTitle?: boolean;
/** Appended to `/settings/<section>`: a sub-tab (`?tab=skills`) and/or an
* anchor (`#cleanup`) the hub scrolls to and highlights. */
to?: string;
}
/** One searchable row per section, by i18n key in the `settings` namespace. A
/** One searchable row per section, by i18n key (`settings` namespace unless
* the key names another, `ccConfig:tabs.skills`). `to` targets a sub-tab and/or
* a row anchor — an element with that `id` or `data-settings-anchor`. A
* section whose id starts with `agents/` and is not listed here is a catalog
* tool's page and takes the `agents/*` entries. */
export const SETTINGS_INDEX: {
@@ -24,16 +31,64 @@ export const SETTINGS_INDEX: {
titleKey: string;
descKey?: string;
keywords?: string[];
to?: string;
}[] = [
{
section: "general",
titleKey: "language.title",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] },
{
section: "data",
titleKey: "lanesSection.title",
descKey: "lanesSection.description",
to: "#lanes",
},
{
section: "data",
titleKey: "lanesSection.runHousekeeping",
keywords: ["gc", "logs", "processes"],
to: "#lanes",
},
{
section: "data",
titleKey: "import.title",
keywords: ["history", "transcripts"],
to: "#import",
},
{
section: "data",
titleKey: "remoteSources.title",
keywords: ["ssh", "machines"],
to: "#remote-sources",
},
{ section: "data", titleKey: "data.title", descKey: "data.description", to: "#data" },
{ section: "data", titleKey: "exportData", keywords: ["backup", "download"], to: "#data" },
{
section: "data",
titleKey: "data.sessionCleanup",
descKey: "data.cleanupDesc",
keywords: ["purge", "abandon", "retention"],
to: "#cleanup",
},
{
section: "data",
titleKey: "prefsBackup.title",
descKey: "prefsBackup.description",
keywords: ["export", "import", "browser", "preferences"],
to: "#prefs",
},
{
section: "data",
titleKey: "danger.title",
keywords: ["clear", "delete", "wipe"],
to: "#danger",
},
{
section: "about",
titleKey: "about.title",
keywords: ["version", "server", "database", "uptime"],
},
{
section: "pet",
titleKey: "sagi.title",
@@ -43,17 +98,41 @@ export const SETTINGS_INDEX: {
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
{ section: "general", titleKey: "data.title", descKey: "data.description" },
{ section: "general", titleKey: "danger.title", keywords: ["clear", "delete", "export"] },
{ section: "general", titleKey: "about.title", keywords: ["version", "server", "database"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
{ section: "notifications", titleKey: "notifications.enable", keywords: ["push", "browser"] },
{ section: "notifications", titleKey: "notifications.awaitingInput" },
{ section: "notifications", titleKey: "notifications.turnComplete" },
{ section: "notifications", titleKey: "alertsHub.title", descKey: "alertsHub.description" },
{ section: "notifications", titleKey: "webhooks.title", keywords: ["slack", "discord"] },
{
section: "notifications",
titleKey: "notifications.enable",
keywords: ["push", "browser"],
to: "#notifications",
},
{ section: "notifications", titleKey: "notifications.awaitingInput", to: "#notifications" },
{ section: "notifications", titleKey: "notifications.turnComplete", to: "#notifications" },
{
section: "notifications",
titleKey: "notifications.sound.title",
keywords: ["chime", "audio"],
to: "#notifications",
},
{
section: "notifications",
titleKey: "notifications.sendTest",
keywords: ["test"],
to: "#notifications",
},
{
section: "notifications",
titleKey: "alertsHub.title",
descKey: "alertsHub.description",
to: "#alerts",
},
{
section: "notifications",
titleKey: "webhooks.title",
keywords: ["slack", "discord", "telegram", "channels"],
to: "#alerts",
},
{
section: "agents",
titleKey: "aiTools.title",
@@ -62,15 +141,89 @@ export const SETTINGS_INDEX: {
},
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title", keywords: ["profiles"] },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.agents", to: "?tab=agents" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.commands", to: "?tab=commands" },
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.memory",
keywords: ["claude.md"],
to: "?tab=memory",
},
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.plugins", to: "?tab=plugins" },
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.marketplaces",
to: "?tab=marketplaces",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.mcp",
keywords: ["mcp"],
to: "?tab=mcp",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.hooks",
keywords: ["hooks"],
to: "?tab=hooks",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.keybindings",
keywords: ["shortcuts"],
to: "?tab=keybindings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.outputStyles",
to: "?tab=outputStyles",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:tabs.settings",
keywords: ["settings.json", "statusline", "permissions"],
to: "?tab=settings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:statusline.title",
keywords: ["statusline"],
to: "?tab=settings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:profiles.title",
keywords: ["account", "login", "quota", "switch"],
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.skills", to: "?tab=skills" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.agents", to: "?tab=agents" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.rules", to: "?tab=rules" },
{
section: "agents/codex",
titleKey: "codexConfig:tabs.memory",
keywords: ["agents.md"],
to: "?tab=memory",
},
{
section: "agents/codex",
titleKey: "codexConfig:tabs.config",
keywords: ["config.toml"],
to: "?tab=config",
},
{ section: "agents/codex", titleKey: "codexConfig:tabs.mcp", keywords: ["mcp"], to: "?tab=mcp" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.plugins", to: "?tab=plugins" },
{ section: "agents/antigravity", titleKey: "antigravityHooks.title", keywords: ["agy", "hooks"] },
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "sidebar"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
{ section: "worktrees", titleKey: "worktrees.claude" },
{ section: "worktrees", titleKey: "worktrees.agenthub" },
];
@@ -84,6 +237,30 @@ function tier(hay: string | undefined, q: string, exact: number): number {
return 0;
}
/** Score of one entry against an already-lowercased query (0 = no match). */
function scoreEntry(e: SettingsEntry, q: string): number {
return Math.max(
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
}
/** Entries matching `query`, best first (ties keep order), at most `limit`. */
export function rankSettingsEntries(
query: string,
entries: SettingsEntry[],
limit = 8
): (SettingsEntry & { score: number })[] {
const q = query.trim().toLowerCase();
if (!q) return [];
return entries
.map((e) => ({ ...e, score: scoreEntry(e, q) }))
.filter((e) => e.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
/** Sections matching `query`, best first; ties keep the entries' order. */
export function rankSettings(
query: string,
@@ -93,11 +270,7 @@ export function rankSettings(
if (!q) return [];
const best = new Map<string, number>();
for (const e of entries) {
const score = Math.max(
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
const score = scoreEntry(e, q);
if (score > (best.get(e.section) ?? 0)) best.set(e.section, score);
}
return [...best]