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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user