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:
+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, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user