ad6e80192d
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
1914 lines
67 KiB
TypeScript
1914 lines
67 KiB
TypeScript
/**
|
|
* @file CodexConfig.tsx
|
|
* @description Codex CLI configuration explorer - the Codex-side counterpart
|
|
* of `CcConfig.tsx`. Surfaces skills, agents, rules, AGENTS.md memory,
|
|
* config.toml, MCP servers, plugins, and marketplaces the `codex` CLI knows
|
|
* about. Create/edit/delete for the low-risk text-file surfaces (skills,
|
|
* agents, rules, memory, config); MCP servers, plugins, and marketplaces are
|
|
* CLI-owned and only offer add/remove, mirroring the Claude Code panel's
|
|
* split between editable artifacts and CLI-driven surfaces. Codex has no
|
|
* "project-local" scope and no per-project file-based memory - its scope
|
|
* union is exactly "user" | "project". Its **Overview** also holds the
|
|
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
|
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
|
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
|
* Every card with action buttons also opens a right-click menu repeating
|
|
* them (same handlers — Delete still asks for confirmation) plus Copy
|
|
* name/path.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { eventBus } from "../lib/eventBus";
|
|
import {
|
|
Boxes,
|
|
RefreshCw,
|
|
Search,
|
|
Sparkles,
|
|
UserRound,
|
|
FolderTree,
|
|
FileText,
|
|
BookOpen,
|
|
Settings as SettingsIcon,
|
|
PlugZap,
|
|
Server,
|
|
Store,
|
|
ExternalLink,
|
|
Pencil,
|
|
Trash2,
|
|
Plus,
|
|
X,
|
|
AlertCircle,
|
|
Info,
|
|
History,
|
|
Terminal,
|
|
Copy,
|
|
Check,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { FileViewer } from "../components/config/FileViewer";
|
|
import { EditorModal, type EditorState } from "../components/config/EditorModal";
|
|
import {
|
|
ConfirmDeleteModal,
|
|
type ConfirmDeleteState,
|
|
} from "../components/config/ConfirmDeleteModal";
|
|
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
|
import { ScopeBadge } from "../components/config/ScopeBadge";
|
|
import { FileItemMenu } from "../components/config/MdItemList";
|
|
import {
|
|
ContextMenu,
|
|
MenuItem,
|
|
MenuSeparator,
|
|
useContextMenu,
|
|
type MenuPoint,
|
|
} from "../components/ContextMenu";
|
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
|
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
|
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
|
|
import {
|
|
Empty,
|
|
SkeletonRows,
|
|
CopyButton,
|
|
formatBytes,
|
|
} from "../components/config/configPrimitives";
|
|
import type {
|
|
CodexAgent,
|
|
CodexArtifactType,
|
|
CodexBackup,
|
|
CodexConfigFile,
|
|
CodexFileResponse,
|
|
CodexMarketplace,
|
|
CodexMcpAddArgs,
|
|
CodexMcpServer,
|
|
CodexMemoryFile,
|
|
CodexMutationResult,
|
|
CodexOverview,
|
|
CodexPlugin,
|
|
CodexRule,
|
|
CodexScope,
|
|
CodexSkill,
|
|
} from "../lib/api";
|
|
|
|
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
|
|
|
interface TabDef {
|
|
key: TabKey;
|
|
icon: typeof Sparkles;
|
|
i18nKey: 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" },
|
|
];
|
|
|
|
function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
|
|
return tab === "skills" || tab === "agents" || tab === "rules";
|
|
}
|
|
|
|
// Both memory (AGENTS.md) and config (config.toml) are a single fixed file
|
|
// per scope - unlike Claude, where only memory is nameless.
|
|
const NAMELESS_TYPES = ["memory", "config"];
|
|
|
|
interface PageState {
|
|
overview: CodexOverview | null;
|
|
skills: CodexSkill[] | null;
|
|
agents: CodexAgent[] | null;
|
|
rules: CodexRule[] | null;
|
|
memory: CodexMemoryFile[] | null;
|
|
config: CodexConfigFile[] | null;
|
|
mcp: CodexMcpServer[] | null;
|
|
plugins: CodexPlugin[] | null;
|
|
marketplaces: CodexMarketplace[] | null;
|
|
}
|
|
|
|
const EMPTY_STATE: PageState = {
|
|
overview: null,
|
|
skills: null,
|
|
agents: null,
|
|
rules: null,
|
|
memory: null,
|
|
config: null,
|
|
mcp: null,
|
|
plugins: null,
|
|
marketplaces: null,
|
|
};
|
|
|
|
/** Common shape shared by CodexSkill/CodexAgent/CodexRule, enough to render
|
|
* a list card without knowing which one it is. */
|
|
interface NamedArtifactItem {
|
|
scope: "user" | "project";
|
|
name: string;
|
|
file: string;
|
|
size: number;
|
|
mtime: number;
|
|
preview: string;
|
|
frontmatter?: Record<string, string>;
|
|
meta?: Record<string, unknown>;
|
|
}
|
|
|
|
export function CodexConfig() {
|
|
const { t } = useTranslation("codexConfig");
|
|
const { t: tSettings } = useTranslation("settings");
|
|
const [tab, setTab] = useState<TabKey>("overview");
|
|
// 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.
|
|
const [hooksKey, setHooksKey] = useState(0);
|
|
const [scope, setScope] = useState<CodexScope>("all");
|
|
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// mcp/plugins/marketplaces shell out to the `codex` CLI, which may not be
|
|
// installed - that's a normal state for those two tabs, not a page error
|
|
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
|
|
const [cliError, setCliError] = useState<string | null>(null);
|
|
const [backupsOpen, setBackupsOpen] = useState(false);
|
|
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [viewer, setViewer] = useState<{
|
|
path: string;
|
|
data: CodexFileResponse | null;
|
|
error: string | null;
|
|
} | null>(null);
|
|
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 fetchAll = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
// File-backed reads (skills/agents/rules/memory/config/overview) and the
|
|
// CLI-backed ones (mcp/plugins/marketplaces) fail independently: a
|
|
// missing `codex` binary must not take down the file-backed tabs too.
|
|
const filesDone = Promise.all([
|
|
api.codexConfig.overview(),
|
|
api.codexConfig.skills(scope),
|
|
api.codexConfig.agents(scope),
|
|
api.codexConfig.rules(scope),
|
|
api.codexConfig.memory(),
|
|
api.codexConfig.config(),
|
|
])
|
|
.then(([overview, skills, agents, rules, memory, config]) => {
|
|
setData((d) => ({
|
|
...d,
|
|
overview,
|
|
skills: skills.skills,
|
|
agents: agents.agents,
|
|
rules: rules.rules,
|
|
memory: memory.memory,
|
|
config: config.config,
|
|
}));
|
|
})
|
|
.catch((err: unknown) => {
|
|
setError(err instanceof Error ? err.message : "unknown error");
|
|
});
|
|
|
|
const cliDone = Promise.all([
|
|
api.codexConfig.mcp(),
|
|
api.codexConfig.plugins(),
|
|
api.codexConfig.marketplaces(),
|
|
])
|
|
.then(([mcp, plugins, marketplaces]) => {
|
|
setCliError(null);
|
|
setData((d) => ({
|
|
...d,
|
|
mcp: mcp.mcp,
|
|
plugins: plugins.plugins,
|
|
marketplaces: marketplaces.marketplaces,
|
|
}));
|
|
})
|
|
.catch((err: unknown) => {
|
|
setCliError(err instanceof Error ? err.message : "unknown error");
|
|
});
|
|
|
|
await Promise.all([filesDone, cliDone]);
|
|
setLastUpdated(new Date());
|
|
setLoading(false);
|
|
}, [scope]);
|
|
|
|
useEffect(() => {
|
|
void fetchAll();
|
|
}, [fetchAll]);
|
|
|
|
// Live updates - refetch whenever the server broadcasts a codex config
|
|
// change (dashboard-driven mutations here). Debounced like CcConfig's
|
|
// equivalent subscription, since one save can touch a file plus its backup.
|
|
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
return eventBus.subscribe((msg) => {
|
|
if (msg.type !== "codex_config_changed") return;
|
|
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
|
refetchTimerRef.current = setTimeout(() => {
|
|
refetchTimerRef.current = null;
|
|
void fetchAll();
|
|
}, 250);
|
|
});
|
|
}, [fetchAll]);
|
|
useEffect(() => {
|
|
return () => {
|
|
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
|
|
|
const openViewer = useCallback(async (path: string) => {
|
|
setViewer({ path, data: null, error: null });
|
|
try {
|
|
const file = await api.codexConfig.file(path);
|
|
setViewer({ path, data: file, error: null });
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
setViewer({ path, data: null, error: msg });
|
|
}
|
|
}, []);
|
|
|
|
const openCreate = useCallback(
|
|
(type: CodexArtifactType, overrideScope?: "user" | "project") => {
|
|
const template = t(`edit.templates.${type}`);
|
|
const defaultScope: "user" | "project" =
|
|
overrideScope ?? (scope === "project" ? "project" : "user");
|
|
setEditor({ mode: "create", type, defaultScope, template });
|
|
},
|
|
[scope, t]
|
|
);
|
|
|
|
const openEdit = useCallback(
|
|
(
|
|
type: CodexArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => {
|
|
setEditor({
|
|
mode: "edit",
|
|
type,
|
|
scope: item.scope,
|
|
name: item.name,
|
|
filePath: item.filePath,
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const openDelete = useCallback(
|
|
(
|
|
type: CodexArtifactType,
|
|
scopeArg: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => {
|
|
setConfirmDelete({ type, scope: scopeArg, name, path });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSave = useCallback(
|
|
async (args: {
|
|
type: CodexArtifactType;
|
|
targetScope: "user" | "project";
|
|
name: string | undefined;
|
|
content: string;
|
|
}) => {
|
|
const result: CodexMutationResult = await api.codexConfig.write({
|
|
scope: args.targetScope,
|
|
type: args.type,
|
|
name: args.name,
|
|
content: args.content,
|
|
});
|
|
setEditor(null);
|
|
setToast({
|
|
kind: "success",
|
|
message: result.created
|
|
? t("edit.saveSuccessNew")
|
|
: t("edit.saveSuccess", { path: result.file }),
|
|
});
|
|
void fetchAll();
|
|
},
|
|
[fetchAll, t]
|
|
);
|
|
|
|
// Create AGENTS.md in ANOTHER project's directory — the Codex twin of
|
|
// CcConfig's handler; `handleSave` above only ever writes this machine's
|
|
// user/project layers.
|
|
const handleCreateProjectMemory = useCallback(
|
|
async (args: { dir: string; content: string }) => {
|
|
const result = await api.codexConfig.createProjectMemory(args);
|
|
setProjectMemoryOpen(false);
|
|
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
|
|
void fetchAll();
|
|
return result.file;
|
|
},
|
|
[fetchAll, t]
|
|
);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
const result = await api.codexConfig.remove({
|
|
scope: confirmDelete.scope,
|
|
type: confirmDelete.type,
|
|
name: confirmDelete.name,
|
|
});
|
|
setConfirmDelete(null);
|
|
setToast({ kind: "success", message: t("edit.deleteSuccess", { path: result.file }) });
|
|
void fetchAll();
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
setConfirmDelete(null);
|
|
setToast({ kind: "error", message: t("edit.deleteError", { message: msg }) });
|
|
}
|
|
}, [confirmDelete, fetchAll, t]);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<Header
|
|
loading={loading}
|
|
lastUpdated={lastUpdated}
|
|
scope={scope}
|
|
onScopeChange={setScope}
|
|
onRefresh={() => {
|
|
void fetchAll();
|
|
setAgenthubKey((k) => k + 1);
|
|
}}
|
|
onOpenBackups={() => setBackupsOpen(true)}
|
|
wsConnected={wsConnected}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
<span>{t("loadError", { message: error })}</span>
|
|
</div>
|
|
)}
|
|
|
|
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
|
|
|
|
<div 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>
|
|
)}
|
|
{isNamedArtifact(tab) && (
|
|
<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"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t("list.newButton")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* The memory tab hides the search bar, but still needs its own
|
|
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
|
the user/project files listed below. */}
|
|
{tab === "memory" && (
|
|
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
|
<button
|
|
onClick={() => setProjectMemoryOpen(true)}
|
|
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"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t("projectMemory.newButton")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="p-4">
|
|
<TabPanel
|
|
tab={tab}
|
|
data={data}
|
|
search={search}
|
|
cliError={cliError}
|
|
onOpenFile={openViewer}
|
|
onEdit={openEdit}
|
|
onDelete={openDelete}
|
|
onCreateScopeFile={openCreate}
|
|
onChanged={fetchAll}
|
|
onToast={setToast}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AgentHub's own Codex settings follow the overview. A new CODEX_HOME
|
|
re-reads every list, since they all live under it. */}
|
|
{tab === "overview" && (
|
|
<div key={agenthubKey} className="space-y-8">
|
|
<HomeDirSetting
|
|
id="codex-home"
|
|
title={tSettings("codexHome.title")}
|
|
description={tSettings("codexHome.description")}
|
|
note={tSettings("codexHome.restartNote")}
|
|
placeholder={tSettings("codexHome.placeholder")}
|
|
load={() =>
|
|
api.settings.codexHome
|
|
.get()
|
|
.then((r) => ({ value: r.codex_home, envOwned: r.env_owned }))
|
|
}
|
|
save={(p) =>
|
|
api.settings.codexHome.set(p).then((r) => {
|
|
void fetchAll();
|
|
setHooksKey((k) => k + 1);
|
|
return {
|
|
value: r.codex_home,
|
|
envOwned: r.env_owned,
|
|
};
|
|
})
|
|
}
|
|
/>
|
|
<CodexHooksSettings key={hooksKey} />
|
|
<PricingSettings vendor="openai" id="codex-pricing" />
|
|
</div>
|
|
)}
|
|
|
|
{backupsOpen && (
|
|
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
|
|
)}
|
|
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
|
{editor && (
|
|
<EditorModal<CodexArtifactType, "user" | "project">
|
|
state={editor}
|
|
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
|
|
onClose={() => setEditor(null)}
|
|
onSave={handleSave}
|
|
namelessTypes={NAMELESS_TYPES}
|
|
/>
|
|
)}
|
|
{confirmDelete && (
|
|
<ConfirmDeleteModal<CodexArtifactType, "user" | "project">
|
|
state={confirmDelete}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
)}
|
|
{projectMemoryOpen && (
|
|
<ProjectMemoryModal
|
|
ns="codexConfig"
|
|
filename="AGENTS.md"
|
|
template={t("projectMemory.template")}
|
|
onClose={() => setProjectMemoryOpen(false)}
|
|
onCreate={handleCreateProjectMemory}
|
|
/>
|
|
)}
|
|
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────
|
|
|
|
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) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const { t: tCommon } = useTranslation("common");
|
|
const formatted = lastUpdated
|
|
? lastUpdated.toLocaleTimeString(undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
})
|
|
: "-";
|
|
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>
|
|
</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
|
|
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"
|
|
>
|
|
<History className="w-3.5 h-3.5" />
|
|
{t("backups.openButton")}
|
|
</button>
|
|
<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"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
|
<span className="grid">
|
|
<span aria-hidden className="invisible col-start-1 row-start-1 whitespace-nowrap">
|
|
{t("refreshing")}
|
|
</span>
|
|
<span className="col-start-1 row-start-1 whitespace-nowrap">
|
|
{loading ? t("refreshing") : t("refresh")}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{lastUpdated && (
|
|
<span className="text-[11px] text-fg-muted self-end">
|
|
{t("lastUpdated", { time: formatted })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function ScopeToggle({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: CodexScope;
|
|
onChange: (s: CodexScope) => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
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"
|
|
}`}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────
|
|
|
|
function Tabs({
|
|
current,
|
|
onSelect,
|
|
counts,
|
|
}: {
|
|
current: TabKey;
|
|
onSelect: (k: TabKey) => void;
|
|
counts?: CodexOverview["counts"];
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const countFor = (key: TabKey): number | null => {
|
|
if (!counts) return null;
|
|
switch (key) {
|
|
case "skills":
|
|
return counts.skills.user + counts.skills.project;
|
|
case "agents":
|
|
return counts.agents.user + counts.agents.project;
|
|
case "rules":
|
|
return counts.rules.user + counts.rules.project;
|
|
case "memory":
|
|
return counts.memory.user + counts.memory.project;
|
|
case "config":
|
|
return counts.config.user + counts.config.project;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
return (
|
|
<ConfigTabBar
|
|
tabs={TABS.map(({ key, icon, i18nKey }) => ({
|
|
key,
|
|
icon,
|
|
label: t(i18nKey),
|
|
count: countFor(key),
|
|
}))}
|
|
current={current}
|
|
onSelect={onSelect}
|
|
testIdPrefix="codex-tab"
|
|
ariaLabel={t("title")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── Tab panel switch ─────────────────────────────────────────────────
|
|
|
|
interface TabPanelProps {
|
|
tab: TabKey;
|
|
data: PageState;
|
|
search: string;
|
|
cliError: string | null;
|
|
onOpenFile: (path: string) => void;
|
|
onEdit: (
|
|
type: CodexArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CodexArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
onCreateScopeFile: (type: CodexArtifactType, scope: "user" | "project") => void;
|
|
onChanged: () => void;
|
|
onToast: (toast: NonNullable<Toast>) => void;
|
|
}
|
|
|
|
function TabPanel({
|
|
tab,
|
|
data,
|
|
search,
|
|
cliError,
|
|
onOpenFile,
|
|
onEdit,
|
|
onDelete,
|
|
onCreateScopeFile,
|
|
onChanged,
|
|
onToast,
|
|
}: TabPanelProps) {
|
|
switch (tab) {
|
|
case "overview":
|
|
return <OverviewPanel overview={data.overview} />;
|
|
case "skills":
|
|
return (
|
|
<ArtifactList
|
|
items={data.skills}
|
|
kind="skills"
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
/>
|
|
);
|
|
case "agents":
|
|
return (
|
|
<ArtifactList
|
|
items={data.agents}
|
|
kind="agents"
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
/>
|
|
);
|
|
case "rules":
|
|
return (
|
|
<ArtifactList
|
|
items={data.rules}
|
|
kind="rules"
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
/>
|
|
);
|
|
case "memory":
|
|
return (
|
|
<ScopeFilePanel
|
|
type="memory"
|
|
items={data.memory}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onCreate={onCreateScopeFile}
|
|
/>
|
|
);
|
|
case "config":
|
|
return (
|
|
<ScopeFilePanel
|
|
type="config"
|
|
items={data.config}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onCreate={onCreateScopeFile}
|
|
/>
|
|
);
|
|
case "mcp":
|
|
return (
|
|
<McpPanel
|
|
data={data.mcp}
|
|
search={search}
|
|
cliError={cliError}
|
|
onToast={onToast}
|
|
onChanged={onChanged}
|
|
/>
|
|
);
|
|
case "plugins":
|
|
return (
|
|
<PluginsPanel
|
|
plugins={data.plugins}
|
|
marketplaces={data.marketplaces}
|
|
search={search}
|
|
cliError={cliError}
|
|
onToast={onToast}
|
|
onChanged={onChanged}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Overview ──────────────────────────────────────────────────────────
|
|
|
|
function OverviewPanel({ overview }: { overview: CodexOverview | null }) {
|
|
const { t } = useTranslation("codexConfig");
|
|
if (!overview) return <SkeletonRows n={4} />;
|
|
const { roots, counts } = overview;
|
|
return (
|
|
<div className="space-y-5">
|
|
<section>
|
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
|
{t("overview.rootsTitle")}
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<RootRow
|
|
icon={FolderTree}
|
|
label={t("overview.projectCodexDir")}
|
|
value={roots.projectCodexDir}
|
|
/>
|
|
<RootRow icon={FolderTree} label={t("overview.projectRoot")} value={roots.projectRoot} />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
|
{t("overview.summary")}
|
|
</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
|
<SummaryStat
|
|
icon={Sparkles}
|
|
label={t("tabs.skills")}
|
|
user={counts.skills.user}
|
|
project={counts.skills.project}
|
|
/>
|
|
<SummaryStat
|
|
icon={UserRound}
|
|
label={t("tabs.agents")}
|
|
user={counts.agents.user}
|
|
project={counts.agents.project}
|
|
/>
|
|
<SummaryStat
|
|
icon={FileText}
|
|
label={t("tabs.rules")}
|
|
user={counts.rules.user}
|
|
project={counts.rules.project}
|
|
/>
|
|
<SummaryStat
|
|
icon={BookOpen}
|
|
label={t("tabs.memory")}
|
|
user={counts.memory.user}
|
|
project={counts.memory.project}
|
|
/>
|
|
<SummaryStat
|
|
icon={SettingsIcon}
|
|
label={t("tabs.config")}
|
|
user={counts.config.user}
|
|
project={counts.config.project}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryStat({
|
|
icon: Icon,
|
|
label,
|
|
user,
|
|
project,
|
|
}: {
|
|
icon: typeof Sparkles;
|
|
label: string;
|
|
user: number;
|
|
project: number;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const total = user + project;
|
|
return (
|
|
<div className="relative rounded-lg border border-border bg-surface-2 overflow-hidden">
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent/40" aria-hidden />
|
|
<div className="pl-3.5 pr-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-6 h-6 rounded-md bg-accent/10 flex items-center justify-center flex-shrink-0">
|
|
<Icon className="w-3.5 h-3.5 text-accent" />
|
|
</span>
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary truncate">
|
|
{label}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1.5 flex items-baseline gap-2">
|
|
<span className="text-xl font-semibold text-fg-primary tabular-nums">{total}</span>
|
|
<span className="text-[10px] text-fg-muted truncate">
|
|
{user} {t("overview.user")} · {project} {t("overview.project")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RootRow({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
}: {
|
|
icon: typeof FolderTree;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="relative flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2 min-w-0 overflow-hidden">
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent/40" aria-hidden />
|
|
<span className="w-7 h-7 rounded-md bg-accent/10 flex items-center justify-center flex-shrink-0 ml-1.5">
|
|
<Icon className="w-3.5 h-3.5 text-accent" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-secondary">
|
|
{label}
|
|
</div>
|
|
<div className="font-mono text-[11px] text-fg-secondary truncate">{value}</div>
|
|
</div>
|
|
<CopyButton value={value} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Named artifacts (skills / agents / rules) ────────────────────────
|
|
|
|
function ArtifactList({
|
|
items,
|
|
kind,
|
|
search,
|
|
onOpen,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
items: NamedArtifactItem[] | null;
|
|
kind: "skills" | "agents" | "rules";
|
|
search: string;
|
|
onOpen: (path: string) => void;
|
|
onEdit: (
|
|
type: CodexArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CodexArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
}) {
|
|
const filtered = useMemo(() => {
|
|
if (!items) return null;
|
|
const q = search.toLowerCase();
|
|
if (!q) return items;
|
|
return items.filter((it) => {
|
|
const blob = [it.name, it.frontmatter?.description, it.preview].filter(Boolean).join(" ");
|
|
return blob.toLowerCase().includes(q);
|
|
});
|
|
}, [items, search]);
|
|
const { t } = useTranslation("codexConfig");
|
|
const ctx = useContextMenu<NamedArtifactItem>();
|
|
|
|
if (!filtered) return <SkeletonRows n={6} />;
|
|
if (filtered.length === 0) return <Empty />;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{filtered.map((it) => (
|
|
<ArtifactCard
|
|
key={`${it.scope}:${it.name}`}
|
|
item={it}
|
|
kind={kind}
|
|
onOpen={onOpen}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onMenu={ctx.open}
|
|
/>
|
|
))}
|
|
{ctx.menu && (
|
|
<FileItemMenu
|
|
x={ctx.menu.x}
|
|
y={ctx.menu.y}
|
|
onClose={ctx.close}
|
|
path={ctx.menu.item.file}
|
|
labels={fileMenuLabels(t)}
|
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
|
onEdit={() => {
|
|
const it = ctx.menu!.item;
|
|
onEdit(kind, { scope: it.scope, name: it.name, filePath: it.file });
|
|
}}
|
|
onDelete={() => {
|
|
const it = ctx.menu!.item;
|
|
onDelete(kind, it.scope, it.name, it.file);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The Codex page's labels for the shared file-card menu — its buttons' own. */
|
|
const fileMenuLabels = (t: (key: string) => string) => ({
|
|
open: t("list.viewButton"),
|
|
edit: t("list.editButton"),
|
|
delete: t("list.deleteButton"),
|
|
copyPath: t("list.copyPath"),
|
|
});
|
|
|
|
function ArtifactCard({
|
|
item,
|
|
kind,
|
|
onOpen,
|
|
onEdit,
|
|
onDelete,
|
|
onMenu,
|
|
}: {
|
|
item: NamedArtifactItem;
|
|
kind: "skills" | "agents" | "rules";
|
|
onMenu: (e: React.MouseEvent, item: NamedArtifactItem) => void;
|
|
onOpen: (path: string) => void;
|
|
onEdit: (
|
|
type: CodexArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CodexArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const description =
|
|
item.frontmatter?.description ||
|
|
(typeof item.meta?.description === "string" ? (item.meta.description as string) : "") ||
|
|
(item.preview || "")
|
|
.replace(/^#+\s.*\n/, "")
|
|
.trim()
|
|
.slice(0, 200);
|
|
return (
|
|
<div
|
|
onContextMenu={(e) => onMenu(e, item)}
|
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
|
|
<ScopeBadge scope={item.scope} />
|
|
</div>
|
|
{description && (
|
|
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
|
|
{description}
|
|
</p>
|
|
)}
|
|
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{item.file}</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
|
<button
|
|
onClick={() => onOpen(item.file)}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
{t("list.viewButton")}
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
onEdit(kind, { scope: item.scope, name: item.name, filePath: item.file })
|
|
}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
{t("list.editButton")}
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(kind, item.scope, item.name, item.file)}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{t("list.deleteButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Memory / config (singleton file per scope) ───────────────────────
|
|
|
|
function ScopeFilePanel({
|
|
type,
|
|
items,
|
|
onOpen,
|
|
onEdit,
|
|
onDelete,
|
|
onCreate,
|
|
}: {
|
|
type: "memory" | "config";
|
|
items: (CodexMemoryFile | CodexConfigFile)[] | null;
|
|
onOpen: (path: string) => void;
|
|
onEdit: (
|
|
type: CodexArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CodexArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
onCreate: (type: CodexArtifactType, scope: "user" | "project") => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const ctx = useContextMenu<CodexMemoryFile | CodexConfigFile>();
|
|
if (!items) return <SkeletonRows n={2} />;
|
|
|
|
const present = items.filter((m) => m.exists);
|
|
const missing = (["user", "project"] as const).filter(
|
|
(s) => !items.some((m) => m.scope === s && m.exists)
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{ctx.menu && (
|
|
<FileItemMenu
|
|
x={ctx.menu.x}
|
|
y={ctx.menu.y}
|
|
onClose={ctx.close}
|
|
path={ctx.menu.item.file}
|
|
labels={fileMenuLabels(t)}
|
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
|
onEdit={() => {
|
|
const m = ctx.menu!.item;
|
|
onEdit(type, { scope: m.scope, name: "", filePath: m.file });
|
|
}}
|
|
onDelete={() => {
|
|
const m = ctx.menu!.item;
|
|
onDelete(type, m.scope, undefined, m.file);
|
|
}}
|
|
/>
|
|
)}
|
|
{present.map((m) => {
|
|
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
|
|
return (
|
|
<div
|
|
key={m.scope}
|
|
onContextMenu={(e) => ctx.open(e, m)}
|
|
className="rounded-lg border border-border bg-surface-2"
|
|
>
|
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
|
|
<ScopeBadge scope={m.scope} />
|
|
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
|
{m.file}
|
|
</span>
|
|
{m.size !== undefined && (
|
|
<span className="text-[10px] text-fg-muted">{formatBytes(m.size)}</span>
|
|
)}
|
|
<button
|
|
onClick={() => onOpen(m.file)}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
{t("list.viewButton")}
|
|
</button>
|
|
<button
|
|
onClick={() => onEdit(type, { scope: m.scope, name: "", filePath: m.file })}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
{t("list.editButton")}
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(type, m.scope, undefined, m.file)}
|
|
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{t("list.deleteButton")}
|
|
</button>
|
|
</div>
|
|
{preview && (
|
|
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
|
|
{preview}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{missing.map((s) => (
|
|
<div
|
|
key={`missing-${s}`}
|
|
className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-4 flex items-center justify-between gap-3"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ScopeBadge scope={s} />
|
|
<span className="text-xs text-fg-muted">{t(`${type}.missing`)}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => onCreate(type, s)}
|
|
className="text-[11px] font-medium px-2.5 py-1 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t("list.newButton")}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── CLI-driven actions (MCP / plugins / marketplaces) ────────────────
|
|
|
|
/** Shared runner for the `codex`-CLI-backed add/remove actions. Tracks which
|
|
* row is mid-flight so its buttons can disable, and funnels both outcomes
|
|
* into the page's toast. Mirrors CcConfig's useConfigAction. */
|
|
function useConfigAction(onToast: (t: NonNullable<Toast>) => void, onChanged: () => void) {
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const run = useCallback(
|
|
async (id: string, fn: () => Promise<unknown>, successMessage: string) => {
|
|
setBusy(id);
|
|
try {
|
|
await fn();
|
|
onToast({ kind: "success", message: successMessage });
|
|
onChanged();
|
|
return true;
|
|
} catch (err: unknown) {
|
|
onToast({ kind: "error", message: err instanceof Error ? err.message : "unknown error" });
|
|
return false;
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
},
|
|
[onChanged, onToast]
|
|
);
|
|
return { busy, run };
|
|
}
|
|
|
|
/** Best-effort label/keys for the loosely-typed CLI JSON blobs
|
|
* (`CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are all `[key: string]: unknown`).
|
|
* `real: false` means no stable identifier was found - the synthetic label
|
|
* (e.g. "MCP servers 1") can't be sent to remove/*, so callers must disable
|
|
* the remove action rather than let it 400. */
|
|
function itemLabel(
|
|
item: Record<string, unknown>,
|
|
index: number,
|
|
fallback: string
|
|
): { label: string; real: boolean } {
|
|
const v = item.name ?? item.id ?? item.spec;
|
|
return typeof v === "string" && v
|
|
? { label: v, real: true }
|
|
: { label: `${fallback} ${index + 1}`, real: false };
|
|
}
|
|
|
|
/** Shown in place of a tab's content when its `codex`-CLI-backed read failed
|
|
* (most likely: `codex` isn't installed). Mirrors the "no plugins" warning
|
|
* banner style already used lower on this page. */
|
|
function CliUnavailable({ message }: { message: string }) {
|
|
const { t } = useTranslation("codexConfig");
|
|
return (
|
|
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
|
|
<Info className="w-3.5 h-3.5 flex-shrink-0" />
|
|
{t("cli.unavailable", { message })}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A CLI-backed row as its right-click menu sees it: its name, and its
|
|
* Remove button's own action and label. */
|
|
type CliRow = { label: string; real: boolean; removeLabel: string; remove: () => void };
|
|
|
|
/** Copy the row's name, and run its Remove button's action — disabled exactly
|
|
* when the button is. */
|
|
function CliRowMenu({
|
|
x,
|
|
y,
|
|
item,
|
|
busy,
|
|
onClose,
|
|
}: MenuPoint & { item: CliRow; busy: string | null; onClose: () => void }) {
|
|
const { t } = useTranslation("codexConfig");
|
|
return (
|
|
<ContextMenu x={x} y={y} onClose={onClose} testId="codex-row-menu">
|
|
{item.real && (
|
|
<>
|
|
<MenuItem
|
|
onSelect={() => {
|
|
onClose();
|
|
void navigator.clipboard.writeText(item.label);
|
|
}}
|
|
>
|
|
{t("list.copyName")}
|
|
</MenuItem>
|
|
<MenuSeparator />
|
|
</>
|
|
)}
|
|
<MenuItem
|
|
danger
|
|
disabled={busy === item.label || !item.real}
|
|
testId="codex-row-menu-remove"
|
|
onSelect={() => {
|
|
onClose();
|
|
item.remove();
|
|
}}
|
|
>
|
|
{item.removeLabel}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
|
|
const entries = Object.entries(item).filter(
|
|
([k, v]) =>
|
|
!skip.includes(k) &&
|
|
(typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
);
|
|
if (entries.length === 0) return null;
|
|
return (
|
|
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
|
|
{entries.map(([k, v]) => (
|
|
<div key={k} className="truncate">
|
|
<span className="text-fg-muted">{k}:</span>{" "}
|
|
<span className="font-mono text-fg-secondary">{String(v)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function McpPanel({
|
|
data,
|
|
search,
|
|
cliError,
|
|
onToast,
|
|
onChanged,
|
|
}: {
|
|
data: CodexMcpServer[] | null;
|
|
search: string;
|
|
cliError: string | null;
|
|
onToast: (t: NonNullable<Toast>) => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
|
const ctx = useContextMenu<CliRow>();
|
|
const [mode, setMode] = useState<"command" | "url">("command");
|
|
const [name, setName] = useState("");
|
|
const [command, setCommand] = useState("");
|
|
const [argsStr, setArgsStr] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [bearerVar, setBearerVar] = useState("");
|
|
|
|
const submit = useCallback(async () => {
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName) return;
|
|
const args: CodexMcpAddArgs =
|
|
mode === "url"
|
|
? { name: trimmedName, url: url.trim(), bearerTokenEnvVar: bearerVar.trim() || undefined }
|
|
: {
|
|
name: trimmedName,
|
|
command: command.trim(),
|
|
args: argsStr.trim() ? argsStr.trim().split(/\s+/) : undefined,
|
|
};
|
|
const ok = await run(
|
|
"add",
|
|
() => api.codexConfig.mcpAdd(args),
|
|
t("mcp.added", { name: trimmedName })
|
|
);
|
|
if (ok) {
|
|
setName("");
|
|
setCommand("");
|
|
setArgsStr("");
|
|
setUrl("");
|
|
setBearerVar("");
|
|
}
|
|
}, [argsStr, bearerVar, command, mode, name, run, t, url]);
|
|
|
|
if (!data) return cliError ? <CliUnavailable message={cliError} /> : <SkeletonRows n={3} />;
|
|
const needle = search.toLowerCase();
|
|
const filtered = data.filter((s) => !needle || JSON.stringify(s).toLowerCase().includes(needle));
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 space-y-2">
|
|
<div className="text-[11px] font-semibold text-fg-secondary">{t("mcp.addTitle")}</div>
|
|
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
|
|
{(["command", "url"] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setMode(m)}
|
|
className={`px-2.5 py-1 text-[11px] font-medium rounded ${
|
|
mode === m
|
|
? "bg-accent/20 text-accent border border-accent/30"
|
|
: "text-fg-secondary hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{m === "command" ? t("mcp.modeCommand") : t("mcp.modeUrl")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("mcp.nameLabel")}
|
|
aria-label={t("mcp.nameLabel")}
|
|
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
{mode === "command" ? (
|
|
<>
|
|
<input
|
|
value={command}
|
|
onChange={(e) => setCommand(e.target.value)}
|
|
placeholder={t("mcp.commandLabel")}
|
|
aria-label={t("mcp.commandLabel")}
|
|
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<input
|
|
value={argsStr}
|
|
onChange={(e) => setArgsStr(e.target.value)}
|
|
placeholder={t("mcp.argsLabel")}
|
|
aria-label={t("mcp.argsLabel")}
|
|
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40 md:col-span-1"
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<input
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder={t("mcp.urlLabel")}
|
|
aria-label={t("mcp.urlLabel")}
|
|
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<input
|
|
value={bearerVar}
|
|
onChange={(e) => setBearerVar(e.target.value)}
|
|
placeholder={t("mcp.bearerLabel")}
|
|
aria-label={t("mcp.bearerLabel")}
|
|
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void submit()}
|
|
disabled={!name.trim() || busy === "add"}
|
|
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{busy === "add" ? t("manage.working") : t("mcp.addButton")}
|
|
</button>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<Empty />
|
|
) : (
|
|
filtered.map((s, i) => {
|
|
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
|
|
const remove = () =>
|
|
void run(
|
|
label,
|
|
() => api.codexConfig.mcpRemove(label),
|
|
t("mcp.removed", { name: label })
|
|
);
|
|
return (
|
|
<div
|
|
key={label + i}
|
|
onContextMenu={(e) =>
|
|
ctx.open(e, { label, real, remove, removeLabel: t("mcp.removeButton") })
|
|
}
|
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
|
|
<KvRows item={s} skip={["name"]} />
|
|
</div>
|
|
<button
|
|
onClick={remove}
|
|
disabled={busy === label || !real}
|
|
title={real ? undefined : t("manage.noIdentifier")}
|
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{busy === label ? t("manage.working") : t("mcp.removeButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PluginsPanel({
|
|
plugins,
|
|
marketplaces,
|
|
search,
|
|
cliError,
|
|
onToast,
|
|
onChanged,
|
|
}: {
|
|
plugins: CodexPlugin[] | null;
|
|
marketplaces: CodexMarketplace[] | null;
|
|
search: string;
|
|
cliError: string | null;
|
|
onToast: (t: NonNullable<Toast>) => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
|
const ctx = useContextMenu<CliRow>();
|
|
const [spec, setSpec] = useState("");
|
|
const [mktUrl, setMktUrl] = useState("");
|
|
|
|
const submitPlugin = useCallback(async () => {
|
|
const trimmed = spec.trim();
|
|
if (!trimmed) return;
|
|
const ok = await run(
|
|
"add-plugin",
|
|
() => api.codexConfig.pluginAction({ spec: trimmed, action: "add" }),
|
|
t("plugins.installed", { name: trimmed })
|
|
);
|
|
if (ok) setSpec("");
|
|
}, [run, spec, t]);
|
|
|
|
const submitMarketplace = useCallback(async () => {
|
|
const trimmed = mktUrl.trim();
|
|
if (!trimmed) return;
|
|
const ok = await run(
|
|
"add-marketplace",
|
|
() => api.codexConfig.marketplaceAdd(trimmed),
|
|
t("plugins.marketplaceAdded")
|
|
);
|
|
if (ok) setMktUrl("");
|
|
}, [mktUrl, run, t]);
|
|
|
|
const needle = search.toLowerCase();
|
|
const filteredPlugins = (plugins ?? []).filter(
|
|
(p) => !needle || JSON.stringify(p).toLowerCase().includes(needle)
|
|
);
|
|
const filteredMarketplaces = (marketplaces ?? []).filter(
|
|
(m) => !needle || JSON.stringify(m).toLowerCase().includes(needle)
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="space-y-3">
|
|
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
|
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
|
{t("plugins.installTitle")}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<input
|
|
value={spec}
|
|
onChange={(e) => setSpec(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") void submitPlugin();
|
|
}}
|
|
placeholder={t("plugins.installPlaceholder")}
|
|
aria-label={t("plugins.installTitle")}
|
|
className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void submitPlugin()}
|
|
disabled={!spec.trim() || busy === "add-plugin"}
|
|
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{busy === "add-plugin" ? t("manage.working") : t("plugins.installButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{plugins === null ? (
|
|
cliError ? (
|
|
<CliUnavailable message={cliError} />
|
|
) : (
|
|
<SkeletonRows n={3} />
|
|
)
|
|
) : filteredPlugins.length === 0 ? (
|
|
<Empty />
|
|
) : (
|
|
filteredPlugins.map((p, i) => {
|
|
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
|
|
const remove = () =>
|
|
void run(
|
|
label,
|
|
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
|
|
t("plugins.removed", { name: label })
|
|
);
|
|
return (
|
|
<div
|
|
key={label + i}
|
|
onContextMenu={(e) =>
|
|
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
|
|
}
|
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
|
|
<KvRows item={p} skip={["name"]} />
|
|
</div>
|
|
<button
|
|
onClick={remove}
|
|
disabled={busy === label || !real}
|
|
title={real ? undefined : t("manage.noIdentifier")}
|
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{busy === label ? t("manage.working") : t("plugins.removeButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Marketplaces live inside the Plugins tab: the CLI/API treat them as
|
|
one small add/remove surface, not worth a dedicated top-level tab. */}
|
|
<section>
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2 flex items-center gap-1.5">
|
|
<Store className="w-3.5 h-3.5" />
|
|
{t("plugins.marketplacesTitle")}
|
|
</h3>
|
|
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 mb-2">
|
|
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
|
{t("plugins.addMarketplaceTitle")}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<input
|
|
value={mktUrl}
|
|
onChange={(e) => setMktUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") void submitMarketplace();
|
|
}}
|
|
placeholder={t("plugins.addMarketplacePlaceholder")}
|
|
aria-label={t("plugins.addMarketplaceTitle")}
|
|
className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void submitMarketplace()}
|
|
disabled={!mktUrl.trim() || busy === "add-marketplace"}
|
|
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{busy === "add-marketplace" ? t("manage.working") : t("plugins.addMarketplaceButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{marketplaces === null ? (
|
|
cliError ? (
|
|
<CliUnavailable message={cliError} />
|
|
) : (
|
|
<SkeletonRows n={2} />
|
|
)
|
|
) : filteredMarketplaces.length === 0 ? (
|
|
<Empty />
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredMarketplaces.map((m, i) => {
|
|
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
|
|
const remove = () =>
|
|
void run(
|
|
label,
|
|
() => api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
|
|
t("plugins.marketplaceRemoved", { name: label })
|
|
);
|
|
return (
|
|
<div
|
|
key={label + i}
|
|
onContextMenu={(e) =>
|
|
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
|
|
}
|
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
|
|
<KvRows item={m} skip={["name"]} />
|
|
</div>
|
|
<button
|
|
onClick={remove}
|
|
disabled={busy === label || !real}
|
|
title={real ? undefined : t("manage.noIdentifier")}
|
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
{busy === label ? t("manage.working") : t("plugins.removeButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{plugins !== null && plugins.length === 0 && filteredPlugins.length === 0 && !search && (
|
|
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
|
|
<Info className="w-3.5 h-3.5 flex-shrink-0" />
|
|
{t("plugins.noPlugins")}
|
|
</div>
|
|
)}
|
|
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Backups modal ─────────────────────────────────────────────────────
|
|
//
|
|
// Ported from CcConfig.tsx's BackupsModal. `CodexBackup` carries no `scope`
|
|
// (the server route doesn't report one - see server/lib/codex-mutate.js's
|
|
// listBackups), so scope and the restore path are both derived here from the
|
|
// backup file's own path against the known roots from the overview response.
|
|
|
|
function BackupsModal({
|
|
roots,
|
|
onClose,
|
|
}: {
|
|
roots: CodexOverview["roots"] | undefined;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const [items, setItems] = useState<CodexBackup[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.codexConfig
|
|
.backups()
|
|
.then((r) => setItems(r.items))
|
|
.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]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
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>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
|
aria-label={t("common.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<p className="text-[11px] text-fg-muted leading-relaxed">{t("backups.subtitle")}</p>
|
|
</div>
|
|
<div className="overflow-auto p-4 space-y-2">
|
|
{error && (
|
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
{items === null && !error && <SkeletonRows n={4} />}
|
|
{items !== null && items.length === 0 && (
|
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
|
{t("backups.empty")}
|
|
</div>
|
|
)}
|
|
{items?.map((b) => (
|
|
<BackupRow key={b.file} backup={b} roots={roots} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BackupRow({
|
|
backup,
|
|
roots,
|
|
}: {
|
|
backup: CodexBackup;
|
|
roots: CodexOverview["roots"] | undefined;
|
|
}) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const scope = deriveScope(backup.file, roots);
|
|
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
|
|
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">
|
|
{scope && <ScopeBadge scope={scope} />}
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
|
{backup.type}
|
|
</span>
|
|
<span className="font-mono text-xs text-fg-primary truncate flex-1 min-w-0">
|
|
{backup.name}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted">{new Date(backup.mtime).toLocaleString()}</span>
|
|
{backup.size != null && (
|
|
<span className="text-[10px] text-fg-muted">{formatBytes(backup.size)}</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1.5 font-mono text-[10px] text-fg-muted truncate">{backup.file}</div>
|
|
<div className="mt-2">
|
|
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
|
|
<CommandSnippet command={restoreCmd} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandSnippet({ command }: { command: string }) {
|
|
const { t } = useTranslation("codexConfig");
|
|
const [copied, setCopied] = useState(false);
|
|
return (
|
|
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
|
|
<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
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(command);
|
|
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"
|
|
title={t("snippet.copy")}
|
|
>
|
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Best-effort: which scope a backup file belongs to, by prefix-matching it
|
|
* against the known roots. Returns null (badge omitted) if `roots` hasn't
|
|
* loaded yet or the path doesn't match either root. */
|
|
function deriveScope(
|
|
filePath: string,
|
|
roots: CodexOverview["roots"] | undefined
|
|
): "user" | "project" | null {
|
|
if (!roots) return null;
|
|
if (filePath.startsWith(roots.codexHome)) return "user";
|
|
if (filePath.startsWith(roots.projectCodexDir) || filePath.startsWith(roots.projectRoot)) {
|
|
return "project";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Backups live at <root>/codex-config-backups/<type>/<name>.<ts>.bak (see
|
|
// backupRootFor in server/lib/codex-mutate.js). memory/config are fixed
|
|
// single files (AGENTS.md / config.toml) rather than a <type>/<name> subdir,
|
|
// so those two get their well-known active path instead.
|
|
function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefined): string {
|
|
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
|
|
const baseName = m ? m[1] : b.name;
|
|
const scope = deriveScope(b.file, roots);
|
|
if (!scope || !roots) return "<active path>";
|
|
if (b.type === "memory") {
|
|
return scope === "user" ? `${roots.codexHome}/AGENTS.md` : `${roots.projectRoot}/AGENTS.md`;
|
|
}
|
|
if (b.type === "config") {
|
|
return scope === "user"
|
|
? `${roots.codexHome}/config.toml`
|
|
: `${roots.projectCodexDir}/config.toml`;
|
|
}
|
|
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, `'\\''`)}'`;
|
|
}
|