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:
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file prefsBackup.ts
|
||||
* @description Export / import of THIS browser's preferences (theme, language,
|
||||
* notification prefs, pet, sidebar, workspace layout …) as one JSON file, for
|
||||
* Settings → Data. Only allowlisted `localStorage` keys travel — never the
|
||||
* dashboard access token or a cache — and an import writes only those keys,
|
||||
* as strings, so a crafted file cannot plant anything else.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const PREFS_FORMAT = "agenthub-browser-prefs";
|
||||
|
||||
const EXACT = new Set([
|
||||
"theme",
|
||||
"i18nextLng",
|
||||
"agent-monitor-notifications",
|
||||
"dashboard_tab",
|
||||
"sidebar-collapsed",
|
||||
"agent-dashboard-sagi-enabled",
|
||||
"agent-dashboard-sagi-muted",
|
||||
"agent-dashboard-sagi-pos",
|
||||
"agenthub-data-scope",
|
||||
]);
|
||||
|
||||
/** Whether a `localStorage` key is a preference that may be exported/imported. */
|
||||
export function isPrefKey(key: string): boolean {
|
||||
return EXACT.has(key) || key.startsWith("agenthub.");
|
||||
}
|
||||
|
||||
export interface PrefsFile {
|
||||
format: typeof PREFS_FORMAT;
|
||||
version: 1;
|
||||
exported_at: string;
|
||||
prefs: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Every preference key this browser holds. */
|
||||
export function exportPrefs(): PrefsFile {
|
||||
const prefs: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && isPrefKey(k)) prefs[k] = localStorage.getItem(k) ?? "";
|
||||
}
|
||||
return { format: PREFS_FORMAT, version: 1, exported_at: new Date().toISOString(), prefs };
|
||||
}
|
||||
|
||||
/** Write a prefs file's allowlisted string values; returns how many. Throws on a foreign file. */
|
||||
export function importPrefs(text: string): number {
|
||||
const data = JSON.parse(text) as Partial<PrefsFile>;
|
||||
if (!data || data.format !== PREFS_FORMAT || typeof data.prefs !== "object" || !data.prefs)
|
||||
throw new Error("not an AgentHub preferences file");
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(data.prefs)) {
|
||||
if (!isPrefKey(k) || typeof v !== "string") continue;
|
||||
localStorage.setItem(k, v);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user