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:
@@ -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" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+605
-240
File diff suppressed because it is too large
Load Diff
+164
-110
@@ -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, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
+760
-461
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user