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
+38 -10
View File
@@ -15,6 +15,9 @@
* An API key is write-only here by design — the server never returns one, so
* the form shows whether a key is saved, never the key.
*
* The form is a real dialog (focus trap, Escape) and shows a failed save
* inside itself, not behind its scrim; a remove asks through `confirmDialog`.
*
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
* as a menu; the buttons stay.
*
@@ -25,11 +28,14 @@
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
@@ -54,6 +60,10 @@ export function AiRouterConfig() {
const [form, setForm] = useState(emptyForm());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
// A failed save, shown INSIDE the dialog (the page-level `error` sits behind it).
const [formError, setFormError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(false));
// The router's own verdict on the form as it currently stands. Cleared on
// every field change: a green "works" left sitting above an edited base URL
// is a claim about a configuration that no longer exists.
@@ -98,6 +108,7 @@ export function AiRouterConfig() {
const startAdd = () => {
setEditing(null);
setForm(emptyForm());
setFormError(null);
setOpen(true);
};
@@ -111,6 +122,7 @@ export function AiRouterConfig() {
model: item.model,
models: { ...item.models },
});
setFormError(null);
setOpen(true);
};
@@ -148,7 +160,7 @@ export function AiRouterConfig() {
const submit = async () => {
setBusy(true);
setError(null);
setFormError(null);
try {
// Send only the tiers that differ from the headline model, so the server
// keeps filling the rest from it — that is what makes one symbolic name
@@ -172,14 +184,20 @@ export function AiRouterConfig() {
setOpen(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setFormError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const remove = async (item: AiRouter) => {
if (!window.confirm(t("aiRouter.removeConfirm", { label: item.label }))) return;
if (
!(await confirmDialog({
title: t("aiRouter.removeConfirm", { label: item.label }),
confirmLabel: t("aiRouter.remove"),
}))
)
return;
setError(null);
try {
await api.aiRouters.remove(item.name);
@@ -288,7 +306,7 @@ export function AiRouterConfig() {
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
{ctx.menu.item.baseUrl && (
<MenuItem onSelect={pick((item) => void navigator.clipboard.writeText(item.baseUrl))}>
<MenuItem onSelect={pick((item) => void copyText(item.baseUrl))}>
{t("rowMenu.copyUrl")}
</MenuItem>
)}
@@ -301,16 +319,17 @@ export function AiRouterConfig() {
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={editing ? t("aiRouter.edit") : t("aiRouter.add")}
aria-labelledby="router-form-title"
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3 className="mb-3 text-sm font-semibold text-fg-primary">
<h3 id="router-form-title" className="mb-3 text-sm font-semibold text-fg-primary">
{editing ? t("aiRouter.edit") : t("aiRouter.add")}
</h3>
@@ -375,6 +394,15 @@ export function AiRouterConfig() {
</details>
</div>
{formError && (
<p
role="alert"
className="mt-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger"
>
{formError}
</p>
)}
{testResult && (
<p
data-testid="router-test-result"
@@ -409,7 +437,7 @@ export function AiRouterConfig() {
type="button"
onClick={submit}
disabled={busy || !form.label.trim() || !form.baseUrl.trim() || !form.model.trim()}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
className="btn-primary px-3 py-1.5 text-xs"
>
{t("aiRouter.save")}
</button>
@@ -451,7 +479,7 @@ function Field({
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none ${
className={`w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none ${
mono ? "font-mono" : ""
}`}
/>
+58 -6
View File
@@ -7,7 +7,10 @@
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
* model flag; its vendor's pricing table; and the config files its catalog
* file lists, each opened over a sha and saved by the server only if it is
* still that sha, valid, and backed up. Antigravity adds its hooks card.
* still that sha, valid, and backed up — a refusal because the file changed
* (ESTALE) offers a reload and keeps the user's text to copy, and an edited
* file asks before another file or Close drops it. Antigravity adds its hooks
* card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +19,10 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { useUnsavedGuard } from "../hooks/useUnsavedGuard";
import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
@@ -39,6 +46,7 @@ function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
setError(null);
try {
await aiTools.update(toolId, { defaultModel: value || null });
toast.success(t("toolTab.defaultModelSaved"));
} catch (err) {
setError(message(err));
}
@@ -70,8 +78,14 @@ function ConfigFiles({ toolId }: { toolId: string }) {
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState("");
// The text as opened/saved: an edit differs from it.
const [base, setBase] = useState("");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
// Set when a save was refused because the file changed on disk.
const [stale, setStale] = useState(false);
const dirty = open !== null && text !== base;
useUnsavedGuard(dirty);
useEffect(() => {
api.aiTools
@@ -80,26 +94,42 @@ function ConfigFiles({ toolId }: { toolId: string }) {
.catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]);
const openFile = async (f: AiToolConfigFile) => {
const load = async (index: number) => {
setNote(null);
setStale(false);
try {
const r = await api.aiTools.readConfig(toolId, f.index);
setOpen({ index: f.index, path: r.path, sha: r.sha });
const r = await api.aiTools.readConfig(toolId, index);
setOpen({ index, path: r.path, sha: r.sha });
setText(r.text);
setBase(r.text);
} catch (err) {
setNote({ ok: false, text: message(err) });
}
};
const openFile = async (f: AiToolConfigFile) => {
if (dirty && !(await confirmDiscard())) return;
await load(f.index);
};
const close = async () => {
if (dirty && !(await confirmDiscard())) return;
setOpen(null);
setStale(false);
};
const save = async () => {
if (!open) return;
setBusy(true);
setNote(null);
setStale(false);
try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha });
setBase(text);
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) {
setStale((err as { code?: string }).code === "ESTALE");
setNote({ ok: false, text: message(err) });
} finally {
setBusy(false);
@@ -141,16 +171,38 @@ function ConfigFiles({ toolId }: { toolId: string }) {
<button
type="button"
onClick={save}
disabled={busy}
disabled={busy || !dirty}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")}
</button>
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
<button type="button" onClick={() => void close()} className="btn-ghost text-xs">
{t("toolTab.close")}
</button>
</div>
{stale && (
<div
role="alert"
className="flex flex-wrap items-center gap-2 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-primary"
>
<span className="flex-1">{t("toolTab.staleBody")}</span>
<button
type="button"
onClick={() => void copyText(text)}
className="btn-ghost text-xs"
>
{t("toolTab.copyMine")}
</button>
<button
type="button"
onClick={() => void load(open.index)}
className="btn-ghost text-xs"
>
{t("toolTab.reloadDiscard")}
</button>
</div>
)}
</div>
)}
{note && (
File diff suppressed because it is too large Load Diff
+164 -110
View File
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
@@ -53,7 +54,10 @@ import {
ConfirmDeleteModal,
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { showToast, type Toast } from "../components/config/ToastNotice";
import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog";
import { isPluginCachePath, restoreCommand } from "./CcConfig";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { FileItemMenu } from "../components/config/MdItemList";
import {
@@ -98,17 +102,19 @@ interface TabDef {
key: TabKey;
icon: typeof Sparkles;
i18nKey: string;
/** i18n key (in ccConfig) of the tab's group label. */
group?: string;
}
const TABS: TabDef[] = [
{ key: "overview", icon: Boxes, i18nKey: "tabs.overview" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
{ key: "skills", icon: Sparkles, i18nKey: "tabs.skills", group: "tabGroups.content" },
{ key: "agents", icon: UserRound, i18nKey: "tabs.agents", group: "tabGroups.content" },
{ key: "rules", icon: FileText, i18nKey: "tabs.rules", group: "tabGroups.content" },
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory", group: "tabGroups.content" },
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp", group: "tabGroups.extensions" },
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins", group: "tabGroups.extensions" },
{ key: "config", icon: SettingsIcon, i18nKey: "tabs.config", group: "tabGroups.config" },
];
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
@@ -159,7 +165,24 @@ interface NamedArtifactItem {
export function CodexConfig() {
const { t } = useTranslation("codexConfig");
const { t: tSettings } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("overview");
// The tab lives in the URL (`?tab=`, replaced) so search results, deep links
// and Back land on it.
const [params, setParams] = useSearchParams();
const rawTab = params.get("tab");
const tab: TabKey = TABS.some((d) => d.key === rawTab) ? (rawTab as TabKey) : "overview";
const setTab = useCallback(
(k: TabKey) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (k === "overview") next.delete("tab");
else next.set("tab", k);
return next;
},
{ replace: true }
),
[setParams]
);
// Bumped by Refresh: the AgentHub cards load on mount, so a new key reloads them.
const [agenthubKey, setAgenthubKey] = useState(0);
// Bumped after a CODEX_HOME save: the hooks card reads the new home's hooks.json.
@@ -175,7 +198,10 @@ export function CodexConfig() {
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
// One search box per tab: a filter typed on Skills must not hide Plugins.
const [searches, setSearches] = useState<Partial<Record<TabKey, string>>>({});
const search = searches[tab] ?? "";
const setSearch = (v: string) => setSearches((s) => ({ ...s, [tab]: v }));
const [viewer, setViewer] = useState<{
path: string;
data: CodexFileResponse | null;
@@ -184,13 +210,7 @@ export function CodexConfig() {
const [editor, setEditor] = useState<EditorState<CodexArtifactType, "user" | "project">>(null);
const [confirmDelete, setConfirmDelete] =
useState<ConfirmDeleteState<CodexArtifactType, "user" | "project">>(null);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(id);
}, [toast]);
const setToast = showToast;
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -269,6 +289,12 @@ export function CodexConfig() {
}, []);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
const openViewer = useCallback(async (path: string) => {
setViewer({ path, data: null, error: null });
@@ -285,10 +311,10 @@ export function CodexConfig() {
(type: CodexArtifactType, overrideScope?: "user" | "project") => {
const template = t(`edit.templates.${type}`);
const defaultScope: "user" | "project" =
overrideScope ?? (scope === "project" ? "project" : "user");
overrideScope ?? (scope === "project" && !projectDisabled ? "project" : "user");
setEditor({ mode: "create", type, defaultScope, template });
},
[scope, t]
[scope, t, projectDisabled]
);
const openEdit = useCallback(
@@ -381,8 +407,6 @@ export function CodexConfig() {
<Header
loading={loading}
lastUpdated={lastUpdated}
scope={scope}
onScopeChange={setScope}
onRefresh={() => {
void fetchAll();
setAgenthubKey((k) => k + 1);
@@ -400,29 +424,46 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
<div
id="codex-tabpanel"
role="tabpanel"
aria-labelledby={`codex-tab-${tab}`}
className="rounded-xl border border-border bg-surface-1"
>
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<div className="flex flex-1 min-w-[12rem] items-center gap-2 rounded-md px-1 focus-within:ring-2 focus-within:ring-accent/40">
<Search className="w-4 h-4 text-fg-muted" aria-hidden />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.search")}
aria-label={t("common.search")}
className="h-7 bg-transparent text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none flex-1"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title={t("common.clearSearch")}
aria-label={t("common.clearSearch")}
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-fg-secondary hover:bg-surface-3 focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{isNamedArtifact(tab) && (
<ScopeToggle
value={scope}
onChange={setScope}
projectRoot={projectRoot}
projectDisabled={projectDisabled}
/>
)}
{isNamedArtifact(tab) && (
<button
type="button"
onClick={() => openCreate(tab)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
@@ -500,10 +541,11 @@ export function CodexConfig() {
{editor && (
<EditorModal<CodexArtifactType, "user" | "project">
state={editor}
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
fetchContent={(p) => api.codexConfig.file(p)}
onClose={() => setEditor(null)}
onSave={handleSave}
namelessTypes={NAMELESS_TYPES}
projectDisabled={projectDisabled}
/>
)}
{confirmDelete && (
@@ -522,7 +564,6 @@ export function CodexConfig() {
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
}
@@ -532,22 +573,12 @@ export function CodexConfig() {
interface HeaderProps {
loading: boolean;
lastUpdated: Date | null;
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
function Header({
loading,
lastUpdated,
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
const { t } = useTranslation("codexConfig");
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
@@ -559,32 +590,25 @@ function Header({
: "-";
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Boxes className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
{/* No title of its own: the Settings hub already draws one. */}
<div className="flex items-start gap-2 min-w-0 flex-1">
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
type="button"
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
@@ -592,6 +616,7 @@ function Header({
{t("backups.openButton")}
</button>
<button
type="button"
onClick={onRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
@@ -617,34 +642,62 @@ function Header({
);
}
/** All / User / Project for the three scoped lists, naming the folder
* "Project" resolves to — and refusing it when that is the plugin cache. */
function ScopeToggle({
value,
onChange,
projectRoot,
projectDisabled,
}: {
value: CodexScope;
onChange: (s: CodexScope) => void;
projectRoot: string | null;
projectDisabled: boolean;
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const opts: { v: CodexScope; label: string }[] = [
{ v: "all", label: t("scope.all") },
{ v: "user", label: t("scope.user") },
{ v: "project", label: t("scope.project") },
];
return (
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{opts.map((o) => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
<div className="flex items-center gap-2 min-w-0">
<div
role="group"
aria-label={tCc("common.scopeLabel")}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 flex-shrink-0"
>
{opts.map((o) => {
const disabled = o.v === "project" && projectDisabled;
return (
<button
key={o.v}
type="button"
aria-pressed={value === o.v}
disabled={disabled}
title={disabled ? tCc("scope.projectInCache") : undefined}
onClick={() => onChange(o.v)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
value === o.v
? "bg-accent/20 text-accent border border-accent/30"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{o.label}
</button>
);
})}
</div>
{projectRoot && (
<span
className={`truncate font-mono text-[10px] ${projectDisabled ? "text-status-warning" : "text-fg-muted"}`}
title={projectDisabled ? tCc("scope.projectInCache") : projectRoot}
>
{o.label}
</button>
))}
{tCc("scope.projectIs", { path: projectRoot })}
</span>
)}
</div>
);
}
@@ -661,6 +714,7 @@ function Tabs({
counts?: CodexOverview["counts"];
}) {
const { t } = useTranslation("codexConfig");
const { t: tCc } = useTranslation("ccConfig");
const countFor = (key: TabKey): number | null => {
if (!counts) return null;
switch (key) {
@@ -680,15 +734,17 @@ function Tabs({
};
return (
<ConfigTabBar
tabs={TABS.map(({ key, icon, i18nKey }) => ({
tabs={TABS.map(({ key, icon, i18nKey, group }) => ({
key,
icon,
label: t(i18nKey),
count: countFor(key),
group: group ? tCc(group) : undefined,
}))}
current={current}
onSelect={onSelect}
testIdPrefix="codex-tab"
panelId="codex-tabpanel"
ariaLabel={t("title")}
/>
);
@@ -1300,7 +1356,7 @@ function CliRowMenu({
<MenuItem
onSelect={() => {
onClose();
void navigator.clipboard.writeText(item.label);
void copyText(item.label);
}}
>
{t("list.copyName")}
@@ -1756,27 +1812,29 @@ function BackupsModal({
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="codex-backups-title"
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<span id="codex-backups-title" className="text-sm font-medium text-fg-primary flex-1">
{t("backups.title")}
</span>
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
@@ -1818,7 +1876,11 @@ function BackupRow({
}) {
const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots);
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
const restoreCmd = restoreCommand(
backup.file,
deriveActivePath(backup, roots),
backup.type === "skills"
);
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
@@ -1851,13 +1913,11 @@ function CommandSnippet({ command }: { command: string }) {
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
if (await copyText(command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
@@ -1905,9 +1965,3 @@ function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefi
const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}
+37 -11
View File
@@ -6,7 +6,9 @@
* adds one, deletes one. A token is write-only here: the server never returns
* one, so the form starts blank and the list shows only that one is saved.
* The git bar's login form saves into the same store. A right-click on a row
* opens the same Replace / Remove (plus Copy host) as a menu.
* opens the same Replace / Remove (plus Copy host) as a menu. A bare host
* (`git.example.com`) means https; Replace keeps the host fixed (it replaces
* that host's login, never moves it), and a remove asks through `confirmDialog`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
@@ -14,6 +16,9 @@ import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
@@ -22,7 +27,15 @@ export function GitCredentialsConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<GitCredential[]>([]);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
// `replacing`: the host is the saved one's and stays fixed.
const [form, setForm] = useState<{
url: string;
username: string;
token: string;
replacing?: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const ctx = useContextMenu<GitCredential>();
// A menu item: close the menu, then act on the row it was opened on.
const pick = (fn: (c: GitCredential) => unknown) => () => {
@@ -36,6 +49,8 @@ export function GitCredentialsConfig() {
setItems((await api.gitCredentials.list()).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
@@ -46,12 +61,15 @@ export function GitCredentialsConfig() {
if (!form) return;
setError(null);
let url: URL;
const typed = form.url.trim();
try {
url = new URL(form.url.trim());
// A bare host (no scheme) is the common case: take it as https.
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`);
} catch {
setError(t("gitCreds.badUrl"));
return;
}
setSaving(true);
try {
const r = await api.gitCredentials.save({
protocol: url.protocol.replace(/:$/, ""),
@@ -61,16 +79,25 @@ export function GitCredentialsConfig() {
});
setItems(r.items);
setForm(null);
toast.success(t("gitCreds.saved", { host: url.host }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const replace = (c: GitCredential) =>
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "" });
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "", replacing: true });
const remove = async (c: GitCredential) => {
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
if (
!(await confirmDialog({
title: t("gitCreds.removeConfirm"),
confirmLabel: t("gitCreds.remove"),
}))
)
return;
try {
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
} catch (e) {
@@ -111,8 +138,9 @@ export function GitCredentialsConfig() {
aria-label={t("gitCreds.fieldHost")}
placeholder="https://git.example.com"
value={form.url}
readOnly={form.replacing}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className={`${INPUT} font-mono`}
className={`${INPUT} font-mono read-only:opacity-70`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
@@ -134,7 +162,7 @@ export function GitCredentialsConfig() {
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={!form.url.trim() || !form.username.trim() || !form.token}
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
@@ -199,7 +227,7 @@ export function GitCredentialsConfig() {
{items.length === 0 && (
<tr className="border-t border-border">
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
{t("gitCreds.empty")}
{loading ? t("gitCreds.loading") : t("gitCreds.empty")}
</td>
</tr>
)}
@@ -209,9 +237,7 @@ export function GitCredentialsConfig() {
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="git-cred-menu">
<MenuItem onSelect={pick(replace)}>{t("gitCreds.replace")}</MenuItem>
<MenuItem onSelect={pick((c) => void navigator.clipboard.writeText(c.host))}>
{t("rowMenu.copyHost")}
</MenuItem>
<MenuItem onSelect={pick((c) => void copyText(c.host))}>{t("rowMenu.copyHost")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick((c) => void remove(c))}>
{t("gitCreds.remove")}
File diff suppressed because it is too large Load Diff
+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);
@@ -7,10 +7,11 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
@@ -158,8 +159,18 @@ describe("AiRouterConfig", () => {
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
expect(await screen.findByText(/must be http/)).toBeInTheDocument();
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
@@ -236,7 +247,8 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
@@ -246,12 +258,14 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
confirm.mockRestore();
off();
});
});
@@ -14,6 +14,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
vi.mock("../../lib/api", () => ({
api: {
@@ -138,4 +140,54 @@ describe("AiToolConfig", () => {
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
it("toasts a saved default model", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("offers a reload on ESTALE and keeps the user's text until they take it", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(
Object.assign(new Error("changed on disk"), { code: "ESTALE", status: 409 })
);
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{mine}" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
const reload = await screen.findByRole("button", { name: /reload/i });
expect(box).toHaveValue("{mine}");
vi.mocked(api.aiTools.readConfig).mockResolvedValueOnce({
path: "/h/.gemini/settings.json",
format: "json",
text: "{disk}",
sha: "s2",
});
fireEvent.click(reload);
await waitFor(() => expect(box).toHaveValue("{disk}"));
});
it("asks before Close discards an edited file", async () => {
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{edited}" } });
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
await waitFor(() => expect(pending).not.toBeNull());
expect(box).toBeInTheDocument();
settleConfirm(false);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByRole("textbox", { name: "/h/.gemini/settings.json" })).toHaveValue(
"{edited}"
);
off();
});
});
@@ -9,6 +9,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm } from "../../lib/confirm";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -99,7 +101,11 @@ afterEach(() => vi.clearAllMocks());
describe("CcConfig — right-click menus", () => {
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -121,7 +127,11 @@ describe("CcConfig — right-click menus", () => {
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -129,12 +139,20 @@ describe("CcConfig — right-click menus", () => {
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
// A saved login is never deleted on one click: it asks first.
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -0,0 +1,285 @@
/**
* @file Data-safety tests for the Claude Code config page: the settings editor
* unsets only keys from the snapshot it opened (never one written meanwhile),
* a renamed key keeps its value, one failing endpoint blanks only its own tab,
* the tab lives in `?tab=`, the Project scope is refused under the plugin
* cache, and the pure helpers (change → keys, restore command) are right.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { eventBus } from "../../lib/eventBus";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 1,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ items: [] }),
mcp: r({ user: [], projectScoped: [] }),
hooks: r({ items: [] }),
settings: r({
items: [{ scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1 } }],
}),
keybindings: r({ groups: [] }),
statusline: r({ config: null, scripts: [] }),
hookScripts: r({ items: [] }),
patchSettings: r({ ok: true }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
import { CcConfig, isPluginCachePath, keysForChange, restoreCommand } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — settings editor", () => {
it("unsets only keys from its own snapshot, never one written after Edit", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/claude?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
// The live CLI writes key `b` while the editor is open.
ccConfigMock.settings.mockResolvedValue({
items: [
{ scope: "user", file: "/home/.claude/settings.json", exists: true, data: { a: 1, b: 2 } },
],
});
vi.useFakeTimers({ shouldAdvanceTime: true });
act(() =>
eventBus.publish({
type: "cc_config_changed",
data: { source: "fs", paths: ["/home/.claude/settings.json"] },
} as never)
);
await act(async () => {
vi.advanceTimersByTime(300);
});
vi.useRealTimers();
await settle();
// Remove `a` and save.
await user.click(screen.getAllByRole("button", { name: /remove key/i })[0]!);
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
// The file changed underneath: it asks before writing.
expect(pending).not.toBeNull();
await act(async () => settleConfirm(true));
await settle();
off();
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({ scope: "user", unset: ["a"] });
});
it("a renamed key is sent under its new name, not dropped", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
const keyBox = screen.getByDisplayValue("a");
await user.clear(keyBox);
await user.type(keyBox, "z");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).toHaveBeenCalledWith({
scope: "user",
set: { z: 1 },
unset: ["a"],
});
});
});
describe("CcConfig — redacted and read-only entries", () => {
it("refuses a new settings key that names a redacted one (it would replace the secret)", async () => {
ccConfigMock.settings.mockResolvedValue({
items: [
{
scope: "user",
file: "/home/.claude/settings.json",
exists: true,
data: { a: 1, env: { API_KEY: "<redacted>" } },
},
],
});
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=settings"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getByRole("button", { name: /add key/i }));
const keys = screen.getAllByRole("textbox", { name: /^key$/i });
await user.type(keys[keys.length - 1]!, "env");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).not.toHaveBeenCalled();
expect(screen.getByText(/redacted/i, { selector: "span" })).toBeInTheDocument();
});
it("refuses a new hook event that names a read-only one (it would replace its entries)", async () => {
ccConfigMock.hooks.mockResolvedValue({
items: [
{
scope: "user",
file: "/home/.claude/settings.json",
exists: true,
hooks: { Stop: [{ type: "prompt", prompt: "x" }] },
},
],
});
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=hooks"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /^edit$/i }));
await user.click(screen.getByRole("button", { name: /add event/i }));
await user.type(screen.getByRole("textbox", { name: /event/i }), "Stop");
await user.type(screen.getByRole("textbox", { name: /command/i }), "echo hi");
await user.click(screen.getByRole("button", { name: /^save$/i }));
await settle();
expect(ccConfigMock.patchSettings).not.toHaveBeenCalled();
expect(
screen.getByText(/can't be edited here|read-only/i, { selector: "span" })
).toBeInTheDocument();
});
});
describe("CcConfig — page", () => {
it("one failing endpoint shows its error on its own tab and leaves the rest working", async () => {
ccConfigMock.mcp.mockRejectedValueOnce(new Error("mcp down"));
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/?tab=skills"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
expect(screen.queryByText(/mcp down/)).toBeNull();
expect(screen.getByRole("tab", { name: /skills/i })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("tab", { name: /mcp/i }));
expect(await screen.findByText(/mcp down/)).toBeInTheDocument();
});
it("arrow keys move between tabs (roving tabindex)", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
const list = screen.getByRole("tablist");
const overview = within(list).getByRole("tab", { name: /overview/i });
expect(overview).toHaveAttribute("tabindex", "0");
overview.focus();
await user.keyboard("{ArrowRight}");
expect(within(list).getByRole("tab", { name: /skills/i })).toHaveAttribute(
"aria-selected",
"true"
);
expect(document.activeElement).toBe(within(list).getByRole("tab", { name: /skills/i }));
});
it("refuses the Project scope when the project root is the plugin cache", async () => {
ccConfigMock.overview.mockResolvedValueOnce({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13/.claude",
projectRoot: "/home/u/.claude/plugins/cache/agenthub/agenthub/1.8.13",
},
counts: (await ccConfigMock.overview()).counts,
});
render(
<MemoryRouter initialEntries={["/?tab=skills"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
expect(screen.getByRole("button", { name: /^project$/i })).toBeDisabled();
});
});
describe("CcConfig helpers", () => {
it("maps a change to the keys it touched", () => {
expect(keysForChange({ source: "fs", paths: ["/h/.claude/skills/x/SKILL.md"] }).sort()).toEqual(
["overview", "skills"].sort()
);
expect(keysForChange({ source: "dashboard", type: "keybindings" }).sort()).toEqual(
["keybindings", "overview"].sort()
);
// Something unplaceable reloads everything.
expect(keysForChange({ source: "fs", paths: ["/h/.claude/weird.txt"] })).toContain("mcp");
});
it("knows the plugin cache", () => {
expect(isPluginCachePath("/home/u/.claude/plugins/cache/a/b/1.0")).toBe(true);
expect(isPluginCachePath("/home/u/projects/app")).toBe(false);
expect(isPluginCachePath(null)).toBe(false);
});
it("restores a skill directory by copying its contents, never mv into it", () => {
expect(restoreCommand("/b/s.2026.bak", "/h/skills/s", true)).toBe(
"mkdir -p /h/skills/s && cp -a /b/s.2026.bak/. /h/skills/s/"
);
expect(restoreCommand("/b/a.md.2026.bak", "/h/agents/a.md", false)).toBe(
"cp -a /b/a.md.2026.bak /h/agents/a.md"
);
});
});
@@ -12,6 +12,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -109,7 +110,11 @@ async function settle() {
}
async function openProfiles() {
render(<CcConfig />);
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
@@ -13,6 +13,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "../../components/Toaster";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -100,7 +102,13 @@ async function settle() {
/** Open the plugins tab, where the Update all card lives. */
async function openPlugins() {
render(<CcConfig />);
// Toasts go through the app-wide queue, so the test mounts its renderer.
render(
<MemoryRouter>
<CcConfig />
<Toaster />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
// vi.mock factories are hoisted above the rest of the module, so the mock
// object they reference must be created inside vi.hoisted() too.
@@ -131,7 +132,11 @@ describe("CodexConfig", () => {
codex_home: "/work/.codex",
env_owned: false,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
const overviewCalls = codexConfigMock.overview.mock.calls.length;
@@ -151,7 +156,11 @@ describe("CodexConfig", () => {
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
stubCodexApi({ skills: [], mcp: { servers: [] } });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
@@ -167,7 +176,11 @@ describe("CodexConfig", () => {
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
write,
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
@@ -181,7 +194,11 @@ describe("CodexConfig", () => {
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
@@ -199,7 +216,11 @@ describe("CodexConfig", () => {
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
@@ -228,7 +249,11 @@ describe("CodexConfig", () => {
},
],
});
render(<CodexConfig />);
render(
<MemoryRouter>
<CodexConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
@@ -11,6 +11,19 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitCredentialsConfig } from "../GitCredentialsConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
/** Answer the next confirm dialogs in order; returns the titles asked. */
function answerConfirms(...answers: boolean[]) {
const asked: string[] = [];
const off = subscribeConfirm((p: PendingConfirm | null) => {
if (!p) return;
asked.push(p.title);
queueMicrotask(() => settleConfirm(answers.shift() ?? false));
});
return { asked, off };
}
vi.mock("../../lib/api", () => ({
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
@@ -84,24 +97,22 @@ describe("GitCredentialsConfig", () => {
});
it("deletes only after a confirm", async () => {
const confirm = vi
.spyOn(window, "confirm")
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const confirm = answerConfirms(false, true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
await user.click(del);
await waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
await user.click(del);
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<GitCredentialsConfig />);
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
@@ -113,10 +124,41 @@ describe("GitCredentialsConfig", () => {
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
await waitFor(() => expect(confirm.asked).toEqual(["gitCreds.removeConfirm"]));
await waitFor(() =>
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
);
confirm.mockRestore();
confirm.off();
});
it("takes a bare host as https, and toasts the save", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
await user.type(screen.getByLabelText("gitCreds.fieldHost"), "git.example.com");
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
await waitFor(() =>
expect(api.gitCredentials.save).toHaveBeenCalledWith({
protocol: "https",
host: "git.example.com",
username: "bot",
password: "t",
})
);
await waitFor(() => expect(seen.some((x) => x.kind === "success")).toBe(true));
off();
});
it("does not let Replace change the host", async () => {
const user = userEvent.setup();
render(<GitCredentialsConfig />);
await user.click(
await screen.findByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" })
);
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveAttribute("readonly");
});
});
@@ -0,0 +1,125 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
* Clear all data stays disabled until the confirmation phrase is typed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../components/ImportHistory", () => ({ ImportHistory: () => null }));
vi.mock("../../components/RemoteSources", () => ({ RemoteSources: () => null }));
vi.mock("../../components/AlertsNotifications", () => ({ AlertsNotifications: () => null }));
const info = vi.fn(async () => ({
db: { path: "/tmp/x.db", size: 1, counts: { sessions: 1 } },
hooks: { installed: true, path: "", hooks: {} },
server: { version: "1", uptime: 1, node_version: "v1", platform: "linux", ws_connections: 0 },
}));
const cleanup = vi.fn(async (p: { dry_run?: boolean }) => ({
ok: true,
dry_run: p.dry_run,
abandoned: 0,
purged_sessions: 2,
purged_events: 5,
purged_agents: 1,
}));
const clearData = vi.fn(async () => ({ ok: true, cleared: { sessions: 1 } }));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
},
},
};
});
import { Settings } from "../Settings";
import { eventBus } from "../../lib/eventBus";
beforeEach(() => {
info.mockClear();
cleanup.mockClear();
clearData.mockClear();
});
const burst = () => {
for (let i = 0; i < 5; i++)
eventBus.publish({ type: "new_event", data: {} } as unknown as Parameters<
typeof eventBus.publish
>[0]);
};
describe("Settings", () => {
it("never reads server info on Notifications or General, even on activity", async () => {
const { unmount } = render(<Settings section="notifications" />);
burst();
await new Promise((r) => setTimeout(r, 30));
unmount();
render(<Settings section="general" />);
burst();
await new Promise((r) => setTimeout(r, 30));
expect(info).not.toHaveBeenCalled();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
expect(info).toHaveBeenCalledTimes(1);
burst();
await new Promise((r) => setTimeout(r, 50));
expect(info).toHaveBeenCalledTimes(1);
});
it("refuses a fractional purge and confirms a dry-run count before the real run", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
const days = await screen.findByLabelText(/purge completed/i);
const run = screen.getByRole("button", { name: /run cleanup/i });
await user.clear(days);
await user.type(days, "0.5");
expect(run).toBeDisabled();
await user.clear(days);
await user.type(days, "30");
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
await user.click(run);
await waitFor(() => expect(pending).not.toBeNull());
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup.mock.calls[0]![0]).toMatchObject({ purge_days: 30, dry_run: true });
expect(pending!.message).toMatch(/2 old session/);
settleConfirm(true);
off();
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(2));
expect(cleanup.mock.calls[1]![0]).not.toHaveProperty("dry_run");
});
it("enables Clear all data only once the phrase is typed", async () => {
const user = userEvent.setup();
render(<Settings section="data" />);
await user.click(await screen.findByRole("button", { name: /clear all data/i }));
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
expect(dialog).not.toBeInTheDocument();
});
});
@@ -5,7 +5,9 @@
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +18,9 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
@@ -56,6 +60,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
import { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() {
const loc = useLocation();
@@ -180,4 +185,70 @@ describe("SettingsHub", () => {
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const field = document.createElement("input");
dialog.appendChild(field);
document.body.appendChild(dialog);
field.focus();
const ev = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(ev).toBe(true); // not prevented
expect(field).toHaveFocus();
dialog.remove();
});
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
const second = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(second).toBe(false); // default prevented
expect(box).toHaveFocus();
expect([box.selectionStart, box.selectionEnd]).toEqual([0, 3]);
});
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
expect(screen.getByTestId("where").textContent).toBe(
"/settings/agents/claude-code?tab=marketplaces"
);
});
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("toasts when an unknown section falls back to General", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("settings-general");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
});