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
+160 -13
View File
@@ -1,22 +1,36 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git (a `<select>` below `md`), a
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. A tool's own settings live under `agents/<id>` — Claude Code and
* focuses it — and selects its text when it already has focus —, a result list
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
* clears), and one page per section. A `#anchor` in the URL scrolls to the
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import {
useEffect,
useRef,
useState,
type ComponentType,
type MouseEvent,
type ReactNode,
} from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Bell,
Database,
Info,
Bot,
Boxes,
Coins,
@@ -43,9 +57,16 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { toast } from "../lib/toast";
const GROUPS = ["general", "agents", "routing", "git"] as const;
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -66,6 +87,24 @@ const LEGACY_TOOL: Record<string, string> = {
"git-credentials": "git-credentials",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
const HIGHLIGHT = ["ring-2", "ring-accent/60", "rounded-lg"];
/** A history entry's index as react-router's BrowserRouter records it. */
const historyIdx = (): number => {
const idx = (window.history.state as { idx?: unknown } | null)?.idx;
return typeof idx === "number" ? idx : 0;
};
/** An unknown section: say so, then show General. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to="/settings/general" replace />;
}
export function SettingsHub() {
const { t } = useTranslation("settings");
const location = useLocation();
@@ -73,6 +112,11 @@ export function SettingsHub() {
const aiTools = useAiTools();
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
// The history index Settings was entered at: Back unwinds to the entry
// before it rather than always landing on /workspace.
const entryIdx = useRef<number | null>(null);
if (entryIdx.current === null) entryIdx.current = historyIdx();
const canGoBack = entryIdx.current > 0;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -81,13 +125,53 @@ export function SettingsHub() {
const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
return;
// An open modal owns the keyboard: never pull focus to a box behind it.
if (document.querySelector('[aria-modal="true"]')) return;
e.preventDefault();
searchRef.current?.focus();
const box = searchRef.current;
if (!box) return;
// A second press while the box has focus selects its text, so the
// shortcut always does something visible (and never opens the browser's).
if (document.activeElement === box) box.select();
else box.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Deep link to a row: scroll to the anchor once the page has rendered it
// (content loads async, so poll briefly) and highlight it for a moment.
useEffect(() => {
const id = decodeURIComponent(location.hash.slice(1));
if (!id) return;
let timer: number | undefined;
let lit: Element | null = null;
let tries = 0;
const find = () =>
document.getElementById(id) ??
document.querySelector(`[data-settings-anchor="${id.replace(/["\\]/g, "")}"]`);
const tick = () => {
const el = find();
if (!el) {
if (++tries < 30) timer = window.setTimeout(tick, 100);
return;
}
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView?.({ block: "start", behavior: reduce ? "auto" : "smooth" });
el.classList.add(...HIGHLIGHT);
lit = el;
timer = window.setTimeout(() => {
el.classList.remove(...HIGHLIGHT);
lit = null;
}, 1600);
};
tick();
return () => {
window.clearTimeout(timer);
lit?.classList.remove(...HIGHLIGHT);
};
}, [location.key, location.hash]);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
@@ -101,6 +185,20 @@ export function SettingsHub() {
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "data",
group: "general",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "about",
group: "general",
Icon: Info,
...s("about"),
render: () => <Settings section="about" />,
},
{
id: "appearance",
group: "general",
@@ -172,7 +270,7 @@ export function SettingsHub() {
},
{
id: "pricing",
group: "routing",
group: "cost",
Icon: Coins,
...s("pricing"),
render: () => (
@@ -225,6 +323,7 @@ export function SettingsHub() {
title: t(e.titleKey),
description: e.descKey ? t(e.descKey) : undefined,
keywords: e.keywords,
to: e.to,
})),
]);
@@ -243,23 +342,39 @@ export function SettingsHub() {
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null;
return <Navigate to="/settings/general" replace />;
if (!aiTools.loaded)
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-40 w-full" />
</div>
);
return <UnknownSection name={rest} />;
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
const first = ranked?.[0]?.section ?? null;
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => `/settings/${e.section}${e.to ?? ""}`;
const go = (id: string) => navigate(`/settings/${id}`);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
navigate(-(historyIdx() - (entryIdx.current ?? 0) + 1));
};
return (
<div className="space-y-4">
<Link
to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("settingsNav.back")}
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<label className="relative block md:w-56">
@@ -270,7 +385,7 @@ export function SettingsHub() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && first) go(first);
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
@@ -279,6 +394,38 @@ export function SettingsHub() {
/>
</label>
{ranked && (
<div className="md:w-96">
{results.length === 0 ? (
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
{t("search.noMatch", { query })}
</p>
) : (
<ul
aria-label={t("search.results")}
className="card divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
i === 0 ? "bg-surface-2" : ""
}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-6">
<nav
aria-label={t("settingsNav.label")}
@@ -323,7 +470,7 @@ export function SettingsHub() {
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="mb-4 w-full rounded-lg border border-border bg-surface-1 px-2 py-1.5 text-sm text-fg-primary md:hidden"
className="select mb-4 w-full py-1.5 md:hidden"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);