b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
3691 lines
132 KiB
TypeScript
3691 lines
132 KiB
TypeScript
/**
|
|
* @file CcConfig.tsx
|
|
* @description Claude Code configuration explorer. Surfaces every plugin,
|
|
* skill, subagent, slash command, MCP server, hook, settings file, memory
|
|
* file, marketplace, keybinding, and statusline script Claude Code knows
|
|
* about. Read access for all surfaces; create / edit / delete for the
|
|
* low-risk text-file surfaces (skills, agents, commands, output styles,
|
|
* CLAUDE.md memory, and per-project file-based memory files). Plugins, MCP,
|
|
* hooks-in-settings, and settings.json files stay read-only - those have
|
|
* concurrent-write races with the live CLI.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Observability
|
|
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `../lib/eventBus`
|
|
* - `../lib/api`
|
|
*
|
|
* ## Public surface
|
|
* - `CcConfig` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **CcConfig**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
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,
|
|
Wrench,
|
|
Slash,
|
|
Palette,
|
|
PlugZap,
|
|
Server,
|
|
Webhook,
|
|
Settings as SettingsIcon,
|
|
BookOpen,
|
|
FileText,
|
|
Copy,
|
|
Check,
|
|
AlertCircle,
|
|
ExternalLink,
|
|
X,
|
|
Info,
|
|
Pencil,
|
|
Trash2,
|
|
Plus,
|
|
Save,
|
|
ShieldAlert,
|
|
Lock,
|
|
History,
|
|
Terminal,
|
|
Store,
|
|
Keyboard,
|
|
CircleDot,
|
|
CircleSlash,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import type {
|
|
CcArtifactType,
|
|
CcBackup,
|
|
CcFileResponse,
|
|
CcHookScripts,
|
|
CcHookSource,
|
|
CcKeybindings,
|
|
CcKeybindingGroup,
|
|
CcMarketplacesResponse,
|
|
CcMcpResponse,
|
|
CcMcpServer,
|
|
CcMdItem,
|
|
CcMemoryItem,
|
|
CcMutationResult,
|
|
CcOverview,
|
|
CcPlugin,
|
|
CcPluginsResponse,
|
|
CcScope,
|
|
CcSettingsSource,
|
|
CcStatusline,
|
|
} from "../lib/api";
|
|
|
|
function isMutable(
|
|
tab: TabKey
|
|
): tab is "skills" | "agents" | "commands" | "outputStyles" | "memory" {
|
|
return (
|
|
tab === "skills" ||
|
|
tab === "agents" ||
|
|
tab === "commands" ||
|
|
tab === "outputStyles" ||
|
|
tab === "memory"
|
|
);
|
|
}
|
|
|
|
function tabToArtifactType(
|
|
tab: "skills" | "agents" | "commands" | "outputStyles" | "memory"
|
|
): CcArtifactType {
|
|
return tab === "outputStyles" ? "output-styles" : tab;
|
|
}
|
|
|
|
type TabKey =
|
|
| "overview"
|
|
| "skills"
|
|
| "agents"
|
|
| "commands"
|
|
| "outputStyles"
|
|
| "plugins"
|
|
| "marketplaces"
|
|
| "mcp"
|
|
| "hooks"
|
|
| "keybindings"
|
|
| "settings"
|
|
| "memory";
|
|
|
|
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: "commands", icon: Slash, i18nKey: "tabs.commands" },
|
|
{ key: "memory", icon: BookOpen, i18nKey: "tabs.memory" },
|
|
{ key: "plugins", icon: PlugZap, i18nKey: "tabs.plugins" },
|
|
{ key: "marketplaces", icon: Store, i18nKey: "tabs.marketplaces" },
|
|
{ key: "mcp", icon: Server, i18nKey: "tabs.mcp" },
|
|
{ key: "hooks", icon: Webhook, i18nKey: "tabs.hooks" },
|
|
{ key: "keybindings", icon: Keyboard, i18nKey: "tabs.keybindings" },
|
|
{ key: "settings", icon: SettingsIcon, i18nKey: "tabs.settings" },
|
|
{ key: "outputStyles", icon: Palette, i18nKey: "tabs.outputStyles" },
|
|
];
|
|
|
|
interface PageState {
|
|
overview: CcOverview | null;
|
|
skills: CcMdItem[] | null;
|
|
agents: CcMdItem[] | null;
|
|
commands: CcMdItem[] | null;
|
|
outputStyles: CcMdItem[] | null;
|
|
plugins: CcPluginsResponse | null;
|
|
marketplaces: CcMarketplacesResponse | null;
|
|
mcp: CcMcpResponse | null;
|
|
hooks: CcHookSource[] | null;
|
|
keybindings: CcKeybindings | null;
|
|
settings: CcSettingsSource[] | null;
|
|
memory: CcMemoryItem[] | null;
|
|
statusline: CcStatusline | null;
|
|
hookScripts: CcHookScripts | null;
|
|
}
|
|
|
|
const EMPTY_STATE: PageState = {
|
|
overview: null,
|
|
skills: null,
|
|
agents: null,
|
|
commands: null,
|
|
outputStyles: null,
|
|
plugins: null,
|
|
marketplaces: null,
|
|
mcp: null,
|
|
hooks: null,
|
|
keybindings: null,
|
|
settings: null,
|
|
memory: null,
|
|
statusline: null,
|
|
hookScripts: null,
|
|
};
|
|
|
|
type EditorState =
|
|
| {
|
|
mode: "create";
|
|
type: CcArtifactType;
|
|
defaultScope: "user" | "project";
|
|
template: string;
|
|
project?: string; // set for type === "auto-memory"
|
|
}
|
|
| {
|
|
mode: "edit";
|
|
type: CcArtifactType;
|
|
scope: "user" | "project" | "auto-memory";
|
|
name: string;
|
|
filePath: string;
|
|
project?: string; // set for type === "auto-memory"
|
|
}
|
|
| null;
|
|
|
|
type ConfirmDeleteState = {
|
|
type: CcArtifactType;
|
|
scope: "user" | "project" | "auto-memory";
|
|
name?: string;
|
|
path: string;
|
|
project?: string; // set for type === "auto-memory"
|
|
} | null;
|
|
|
|
type Toast = { kind: "success" | "error"; message: string } | null;
|
|
|
|
export function CcConfig() {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [tab, setTab] = useState<TabKey>("overview");
|
|
const [scope, setScope] = useState<CcScope>("all");
|
|
const [data, setData] = useState<PageState>(EMPTY_STATE);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [viewer, setViewer] = useState<{
|
|
path: string;
|
|
data: CcFileResponse | null;
|
|
error: string | null;
|
|
} | null>(null);
|
|
const [editor, setEditor] = useState<EditorState>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
|
const [toast, setToast] = useState<Toast>(null);
|
|
const [backupsOpen, setBackupsOpen] = useState(false);
|
|
|
|
// Auto-dismiss toasts after 5s
|
|
useEffect(() => {
|
|
if (!toast) return;
|
|
const id = setTimeout(() => setToast(null), 5000);
|
|
return () => clearTimeout(id);
|
|
}, [toast]);
|
|
|
|
const fetchAll = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [
|
|
overview,
|
|
skills,
|
|
agents,
|
|
commands,
|
|
outputStyles,
|
|
plugins,
|
|
marketplaces,
|
|
mcp,
|
|
hooks,
|
|
keybindings,
|
|
settings,
|
|
memory,
|
|
statusline,
|
|
hookScripts,
|
|
] = await Promise.all([
|
|
api.ccConfig.overview(),
|
|
api.ccConfig.skills(scope),
|
|
api.ccConfig.agents(scope),
|
|
api.ccConfig.commands(scope),
|
|
api.ccConfig.outputStyles(scope),
|
|
api.ccConfig.plugins(),
|
|
api.ccConfig.marketplaces(),
|
|
api.ccConfig.mcp(),
|
|
api.ccConfig.hooks(),
|
|
api.ccConfig.keybindings(),
|
|
api.ccConfig.settings(),
|
|
api.ccConfig.memory(),
|
|
api.ccConfig.statusline(),
|
|
api.ccConfig.hookScripts(),
|
|
]);
|
|
setData({
|
|
overview,
|
|
skills: skills.items,
|
|
agents: agents.items,
|
|
commands: commands.items,
|
|
outputStyles: outputStyles.items,
|
|
plugins,
|
|
marketplaces,
|
|
mcp,
|
|
hooks: hooks.items,
|
|
keybindings,
|
|
settings: settings.items,
|
|
memory: memory.items,
|
|
statusline,
|
|
hookScripts,
|
|
});
|
|
setLastUpdated(new Date());
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
setError(msg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [scope]);
|
|
|
|
useEffect(() => {
|
|
void fetchAll();
|
|
}, [fetchAll]);
|
|
|
|
// Live updates - refetch whenever the server broadcasts that a config
|
|
// surface has changed (either via dashboard mutations or external file
|
|
// edits picked up by the cc-watcher). Debounced because a single user
|
|
// action can write multiple files (e.g. a skill backup + the skill itself
|
|
// + the file-history snapshot all land within tens of ms).
|
|
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
return eventBus.subscribe((msg) => {
|
|
if (msg.type !== "cc_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.ccConfig.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: CcArtifactType, overrideScope?: "user" | "project") => {
|
|
const tplKey = `edit.templates.${type}`;
|
|
const template = t(tplKey);
|
|
const defaultScope: "user" | "project" =
|
|
overrideScope ?? (scope === "project" ? "project" : "user");
|
|
setEditor({ mode: "create", type, defaultScope, template });
|
|
},
|
|
[scope, t]
|
|
);
|
|
|
|
const openEdit = useCallback(
|
|
(type: CcArtifactType, 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: CcArtifactType,
|
|
scopeArg: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => {
|
|
setConfirmDelete({ type, scope: scopeArg, name, path });
|
|
},
|
|
[]
|
|
);
|
|
|
|
// ── Auto-memory (per-project file-based memory) create / edit / delete ──
|
|
const openCreateAuto = useCallback(
|
|
(project: string) => {
|
|
setEditor({
|
|
mode: "create",
|
|
type: "auto-memory",
|
|
defaultScope: "user", // unused for auto-memory; scope is fixed
|
|
template: t("edit.templates.auto-memory"),
|
|
project,
|
|
});
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const openEditAuto = useCallback((item: CcMemoryItem) => {
|
|
if (!item.project || !item.name) return;
|
|
setEditor({
|
|
mode: "edit",
|
|
type: "auto-memory",
|
|
scope: "auto-memory",
|
|
name: item.name,
|
|
filePath: item.file,
|
|
project: item.project,
|
|
});
|
|
}, []);
|
|
|
|
const openDeleteAuto = useCallback((item: CcMemoryItem) => {
|
|
if (!item.project || !item.name) return;
|
|
setConfirmDelete({
|
|
type: "auto-memory",
|
|
scope: "auto-memory",
|
|
name: item.name,
|
|
path: item.file,
|
|
project: item.project,
|
|
});
|
|
}, []);
|
|
|
|
const handleSave = useCallback(
|
|
async (args: {
|
|
type: CcArtifactType;
|
|
targetScope: "user" | "project" | "auto-memory";
|
|
name: string | undefined;
|
|
content: string;
|
|
project?: string;
|
|
}) => {
|
|
const result: CcMutationResult = await api.ccConfig.write({
|
|
scope: args.targetScope,
|
|
type: args.type,
|
|
name: args.name,
|
|
content: args.content,
|
|
project: args.project,
|
|
});
|
|
setEditor(null);
|
|
setToast({
|
|
kind: "success",
|
|
message: result.created
|
|
? t("edit.saveSuccessNew")
|
|
: t("edit.saveSuccess", { path: result.backupPath || "-" }),
|
|
});
|
|
void fetchAll();
|
|
},
|
|
[fetchAll, t]
|
|
);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
const result = await api.ccConfig.delete({
|
|
scope: confirmDelete.scope,
|
|
type: confirmDelete.type,
|
|
name: confirmDelete.name,
|
|
project: confirmDelete.project,
|
|
});
|
|
setConfirmDelete(null);
|
|
setToast({
|
|
kind: "success",
|
|
message: t("edit.deleteSuccess", { path: result.backupPath || "-" }),
|
|
});
|
|
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={fetchAll}
|
|
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" && (
|
|
<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>
|
|
)}
|
|
{isMutable(tab) && tab !== "memory" && (
|
|
<button
|
|
onClick={() => openCreate(tabToArtifactType(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("edit.newButton")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="p-4">
|
|
<TabPanel
|
|
tab={tab}
|
|
data={data}
|
|
search={search}
|
|
onOpenFile={openViewer}
|
|
onEdit={openEdit}
|
|
onDelete={openDelete}
|
|
onCreateMemory={(s) => openCreate("memory", s)}
|
|
onEditAuto={openEditAuto}
|
|
onDeleteAuto={openDeleteAuto}
|
|
onCreateAuto={openCreateAuto}
|
|
onKeybindingsSaved={fetchAll}
|
|
onToast={setToast}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
|
|
{editor && <EditorModal state={editor} onClose={() => setEditor(null)} onSave={handleSave} />}
|
|
{confirmDelete && (
|
|
<ConfirmDeleteModal
|
|
state={confirmDelete}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
)}
|
|
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
|
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────
|
|
|
|
interface HeaderProps {
|
|
loading: boolean;
|
|
lastUpdated: Date | null;
|
|
scope: CcScope;
|
|
onScopeChange: (s: CcScope) => void;
|
|
onRefresh: () => void;
|
|
onOpenBackups: () => void;
|
|
wsConnected: boolean;
|
|
}
|
|
|
|
function Header({
|
|
loading,
|
|
lastUpdated,
|
|
scope,
|
|
onScopeChange,
|
|
onRefresh,
|
|
onOpenBackups,
|
|
wsConnected,
|
|
}: HeaderProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
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" : ""}`} />
|
|
{loading ? t("refreshing") : t("refresh")}
|
|
</button>
|
|
</div>
|
|
{lastUpdated && (
|
|
<span className="text-[11px] text-fg-muted self-end">
|
|
{t("lastUpdated", { time: formatted })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function ScopeToggle({ value, onChange }: { value: CcScope; onChange: (s: CcScope) => void }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const opts: { v: CcScope; 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 ──────────────────────────────────────────────────────────────
|
|
|
|
interface TabsProps {
|
|
current: TabKey;
|
|
onSelect: (k: TabKey) => void;
|
|
counts?: CcOverview["counts"];
|
|
}
|
|
|
|
function Tabs({ current, onSelect, counts }: TabsProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
|
|
// Update scroll affordances when content size or scroll position changes.
|
|
const updateAffordances = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
setCanScrollLeft(el.scrollLeft > 1);
|
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
updateAffordances();
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.addEventListener("scroll", updateAffordances, { passive: true });
|
|
const ro = new ResizeObserver(updateAffordances);
|
|
ro.observe(el);
|
|
return () => {
|
|
el.removeEventListener("scroll", updateAffordances);
|
|
ro.disconnect();
|
|
};
|
|
}, [updateAffordances]);
|
|
|
|
// Scroll the active tab into view when it changes (e.g. user picks a tab
|
|
// that's offscreen, or window resize hides the active one).
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
|
|
if (!active) return;
|
|
const elRect = el.getBoundingClientRect();
|
|
const activeRect = active.getBoundingClientRect();
|
|
if (activeRect.left < elRect.left + 8) {
|
|
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
|
|
} else if (activeRect.right > elRect.right - 8) {
|
|
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
|
|
}
|
|
}, [current]);
|
|
|
|
const scrollByButton = (dir: 1 | -1) => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
|
|
};
|
|
|
|
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 "commands":
|
|
return counts.commands.user + counts.commands.project;
|
|
case "outputStyles":
|
|
return counts.outputStyles.user + counts.outputStyles.project;
|
|
case "plugins":
|
|
return counts.plugins;
|
|
case "marketplaces":
|
|
return counts.marketplaces;
|
|
case "keybindings":
|
|
return counts.keybindings;
|
|
case "mcp":
|
|
return counts.mcpServers.user + counts.mcpServers.project;
|
|
case "hooks":
|
|
return Object.values(counts.hooks).reduce((a, b) => a + b, 0);
|
|
case "settings":
|
|
return counts.settingsFiles;
|
|
case "memory":
|
|
return counts.memory;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
return (
|
|
<div className="relative rounded-xl border border-border bg-surface-1">
|
|
{/* Left edge gradient + chevron */}
|
|
<div
|
|
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
|
|
canScrollLeft ? "opacity-100" : "opacity-0"
|
|
}`}
|
|
/>
|
|
{canScrollLeft && (
|
|
<button
|
|
onClick={() => scrollByButton(-1)}
|
|
aria-label="scroll tabs left"
|
|
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
|
style={{ scrollbarWidth: "none" }}
|
|
>
|
|
{TABS.map(({ key, icon: Icon, i18nKey }) => {
|
|
const c = countFor(key);
|
|
const active = current === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
data-tab-active={active ? "true" : undefined}
|
|
onClick={() => onSelect(key)}
|
|
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
|
|
active
|
|
? "bg-accent/15 text-accent border border-accent/30"
|
|
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
<span>{t(i18nKey)}</span>
|
|
{c !== null && (
|
|
<span
|
|
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
|
|
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
|
|
}`}
|
|
>
|
|
{c}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Right edge gradient + chevron */}
|
|
<div
|
|
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
|
|
canScrollRight ? "opacity-100" : "opacity-0"
|
|
}`}
|
|
/>
|
|
{canScrollRight && (
|
|
<button
|
|
onClick={() => scrollByButton(1)}
|
|
aria-label="scroll tabs right"
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab panel switch ──────────────────────────────────────────────────
|
|
|
|
interface TabPanelProps {
|
|
tab: TabKey;
|
|
data: PageState;
|
|
search: string;
|
|
onOpenFile: (path: string) => void;
|
|
onEdit: (
|
|
type: CcArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CcArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
onCreateMemory: (scope: "user" | "project") => void;
|
|
onEditAuto: (item: CcMemoryItem) => void;
|
|
onDeleteAuto: (item: CcMemoryItem) => void;
|
|
onCreateAuto: (project: string) => void;
|
|
onKeybindingsSaved: () => void;
|
|
onToast: (toast: NonNullable<Toast>) => void;
|
|
}
|
|
|
|
function TabPanel({
|
|
tab,
|
|
data,
|
|
search,
|
|
onOpenFile,
|
|
onEdit,
|
|
onDelete,
|
|
onCreateMemory,
|
|
onEditAuto,
|
|
onDeleteAuto,
|
|
onCreateAuto,
|
|
onKeybindingsSaved,
|
|
onToast,
|
|
}: TabPanelProps) {
|
|
switch (tab) {
|
|
case "overview":
|
|
return <OverviewPanel overview={data.overview} />;
|
|
case "skills":
|
|
return (
|
|
<MdItemList
|
|
items={data.skills}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
kind="skills"
|
|
/>
|
|
);
|
|
case "agents":
|
|
return (
|
|
<MdItemList
|
|
items={data.agents}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
kind="agents"
|
|
/>
|
|
);
|
|
case "commands":
|
|
return (
|
|
<MdItemList
|
|
items={data.commands}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
kind="commands"
|
|
/>
|
|
);
|
|
case "outputStyles":
|
|
return (
|
|
<MdItemList
|
|
items={data.outputStyles}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
kind="outputStyles"
|
|
/>
|
|
);
|
|
case "plugins":
|
|
return <PluginsPanel data={data.plugins} search={search} />;
|
|
case "marketplaces":
|
|
return <MarketplacesPanel data={data.marketplaces} search={search} />;
|
|
case "mcp":
|
|
return <McpPanel data={data.mcp} search={search} />;
|
|
case "hooks":
|
|
return (
|
|
<HooksPanel
|
|
sources={data.hooks}
|
|
scripts={data.hookScripts}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
/>
|
|
);
|
|
case "keybindings":
|
|
return (
|
|
<KeybindingsPanel
|
|
data={data.keybindings}
|
|
search={search}
|
|
onSaved={onKeybindingsSaved}
|
|
onToast={onToast}
|
|
/>
|
|
);
|
|
case "settings":
|
|
return (
|
|
<SettingsPanel sources={data.settings} statusline={data.statusline} onOpen={onOpenFile} />
|
|
);
|
|
case "memory":
|
|
return (
|
|
<MemoryPanel
|
|
items={data.memory}
|
|
search={search}
|
|
onOpen={onOpenFile}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onCreate={onCreateMemory}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
onCreateAuto={onCreateAuto}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Overview ──────────────────────────────────────────────────────────
|
|
|
|
// Tone palette - each tone is { iconBg, iconText, border, accentBar }.
|
|
// Used by both root rows and summary stat tiles for a consistent color story.
|
|
type Tone =
|
|
| "sky"
|
|
| "emerald"
|
|
| "violet"
|
|
| "amber"
|
|
| "fuchsia"
|
|
| "cyan"
|
|
| "pink"
|
|
| "indigo"
|
|
| "orange"
|
|
| "teal"
|
|
| "slate"
|
|
| "rose";
|
|
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
|
|
sky: {
|
|
iconBg: "bg-sky-500/10",
|
|
iconText: "text-sky-300",
|
|
bar: "bg-sky-500/40",
|
|
ring: "ring-sky-500/20",
|
|
},
|
|
emerald: {
|
|
iconBg: "bg-status-success/10",
|
|
iconText: "text-status-success",
|
|
bar: "bg-status-success/40",
|
|
ring: "ring-status-success/20",
|
|
},
|
|
violet: {
|
|
iconBg: "bg-violet-500/10",
|
|
iconText: "text-violet-300",
|
|
bar: "bg-violet-500/40",
|
|
ring: "ring-violet-500/20",
|
|
},
|
|
amber: {
|
|
iconBg: "bg-status-warning/10",
|
|
iconText: "text-status-warning",
|
|
bar: "bg-status-warning/40",
|
|
ring: "ring-status-warning/20",
|
|
},
|
|
fuchsia: {
|
|
iconBg: "bg-fuchsia-500/10",
|
|
iconText: "text-fuchsia-300",
|
|
bar: "bg-fuchsia-500/40",
|
|
ring: "ring-fuchsia-500/20",
|
|
},
|
|
cyan: {
|
|
iconBg: "bg-cyan-500/10",
|
|
iconText: "text-cyan-300",
|
|
bar: "bg-cyan-500/40",
|
|
ring: "ring-cyan-500/20",
|
|
},
|
|
pink: {
|
|
iconBg: "bg-pink-500/10",
|
|
iconText: "text-pink-300",
|
|
bar: "bg-pink-500/40",
|
|
ring: "ring-pink-500/20",
|
|
},
|
|
indigo: {
|
|
iconBg: "bg-indigo-500/10",
|
|
iconText: "text-indigo-300",
|
|
bar: "bg-indigo-500/40",
|
|
ring: "ring-indigo-500/20",
|
|
},
|
|
orange: {
|
|
iconBg: "bg-orange-500/10",
|
|
iconText: "text-orange-300",
|
|
bar: "bg-orange-500/40",
|
|
ring: "ring-orange-500/20",
|
|
},
|
|
teal: {
|
|
iconBg: "bg-teal-500/10",
|
|
iconText: "text-teal-300",
|
|
bar: "bg-teal-500/40",
|
|
ring: "ring-teal-500/20",
|
|
},
|
|
slate: {
|
|
iconBg: "bg-surface-4/10",
|
|
iconText: "text-fg-secondary",
|
|
bar: "bg-surface-4/40",
|
|
ring: "ring-border-light/20",
|
|
},
|
|
rose: {
|
|
iconBg: "bg-rose-500/10",
|
|
iconText: "text-rose-300",
|
|
bar: "bg-rose-500/40",
|
|
ring: "ring-rose-500/20",
|
|
},
|
|
};
|
|
|
|
function OverviewPanel({ overview }: { overview: CcOverview | null }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
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}
|
|
tone="sky"
|
|
label={t("overview.claudeHome")}
|
|
value={roots.claudeHome}
|
|
/>
|
|
<RootRow
|
|
icon={FolderTree}
|
|
tone="emerald"
|
|
label={t("overview.projectClaudeDir")}
|
|
value={roots.projectClaudeDir}
|
|
/>
|
|
<RootRow
|
|
icon={FolderTree}
|
|
tone="violet"
|
|
label={t("overview.projectRoot")}
|
|
value={roots.projectRoot}
|
|
/>
|
|
<RootRow
|
|
icon={FileText}
|
|
tone="amber"
|
|
label={t("overview.claudeJson")}
|
|
value={roots.claudeJson}
|
|
/>
|
|
</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
|
|
tone="fuchsia"
|
|
icon={Sparkles}
|
|
label={t("tabs.skills")}
|
|
user={counts.skills.user}
|
|
project={counts.skills.project}
|
|
/>
|
|
<SummaryStat
|
|
tone="sky"
|
|
icon={UserRound}
|
|
label={t("tabs.agents")}
|
|
user={counts.agents.user}
|
|
project={counts.agents.project}
|
|
/>
|
|
<SummaryStat
|
|
tone="cyan"
|
|
icon={Slash}
|
|
label={t("tabs.commands")}
|
|
user={counts.commands.user}
|
|
project={counts.commands.project}
|
|
/>
|
|
<SummaryStat
|
|
tone="pink"
|
|
icon={Palette}
|
|
label={t("tabs.outputStyles")}
|
|
user={counts.outputStyles.user}
|
|
project={counts.outputStyles.project}
|
|
/>
|
|
<SummaryStat
|
|
tone="indigo"
|
|
icon={Server}
|
|
label={t("tabs.mcp")}
|
|
user={counts.mcpServers.user}
|
|
project={counts.mcpServers.project}
|
|
/>
|
|
<SummaryStat
|
|
tone="emerald"
|
|
icon={PlugZap}
|
|
label={t("tabs.plugins")}
|
|
value={counts.plugins}
|
|
/>
|
|
<SummaryStat
|
|
tone="amber"
|
|
icon={Store}
|
|
label={t("tabs.marketplaces")}
|
|
value={counts.marketplaces}
|
|
/>
|
|
<SummaryStat
|
|
tone="orange"
|
|
icon={Webhook}
|
|
label={t("tabs.hooks")}
|
|
value={Object.values(counts.hooks).reduce((a, b) => a + b, 0)}
|
|
/>
|
|
<SummaryStat
|
|
tone="rose"
|
|
icon={Keyboard}
|
|
label={t("tabs.keybindings")}
|
|
value={counts.keybindings}
|
|
/>
|
|
<SummaryStat
|
|
tone="slate"
|
|
icon={SettingsIcon}
|
|
label={t("tabs.settings")}
|
|
value={counts.settingsFiles}
|
|
/>
|
|
<SummaryStat tone="teal" icon={BookOpen} label={t("tabs.memory")} value={counts.memory} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SummaryStatProps {
|
|
tone: Tone;
|
|
icon: typeof Sparkles;
|
|
label: string;
|
|
// Either a single value, OR a user/project pair (which is summed for the headline number).
|
|
value?: number;
|
|
user?: number;
|
|
project?: number;
|
|
}
|
|
|
|
function SummaryStat({ tone, icon: Icon, label, value, user, project }: SummaryStatProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const T = TONES[tone];
|
|
const total = value !== undefined ? value : (user ?? 0) + (project ?? 0);
|
|
const showBreakdown = user !== undefined && project !== undefined;
|
|
return (
|
|
<div className={`relative rounded-lg border border-border bg-surface-2 overflow-hidden`}>
|
|
{/* Left accent bar */}
|
|
<div className={`absolute left-0 top-0 bottom-0 w-1 ${T.bar}`} 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 ${T.iconBg} flex items-center justify-center flex-shrink-0`}
|
|
>
|
|
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
|
|
</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>
|
|
{showBreakdown && (
|
|
<span className="text-[10px] text-fg-muted truncate">
|
|
{user} {t("overview.user")} · {project} {t("overview.project")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RootRow({
|
|
icon: Icon,
|
|
tone,
|
|
label,
|
|
value,
|
|
}: {
|
|
icon: typeof FolderTree;
|
|
tone: Tone;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
const T = TONES[tone];
|
|
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 ${T.bar}`} aria-hidden />
|
|
<span
|
|
className={`w-7 h-7 rounded-md ${T.iconBg} flex items-center justify-center flex-shrink-0 ml-1.5`}
|
|
>
|
|
<Icon className={`w-3.5 h-3.5 ${T.iconText}`} />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// ── MD-item generic list (skills/agents/commands/output-styles) ───────
|
|
|
|
interface MdItemListProps {
|
|
items: CcMdItem[] | null;
|
|
search: string;
|
|
onOpen: (path: string) => void;
|
|
onEdit: (
|
|
type: CcArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CcArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
kind: "skills" | "agents" | "commands" | "outputStyles";
|
|
}
|
|
|
|
function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
|
const filtered = useMemo(() => {
|
|
if (!items) return null;
|
|
const q = search.toLowerCase();
|
|
return items.filter((it) => {
|
|
if (!q) return true;
|
|
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return blob.includes(q);
|
|
});
|
|
}, [items, search]);
|
|
|
|
if (!filtered) return <SkeletonRows n={6} />;
|
|
if (filtered.length === 0) return <Empty />;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{filtered.map((it) => (
|
|
<MdItemCard
|
|
key={`${it.scope}:${it.name}`}
|
|
item={it}
|
|
onOpen={onOpen}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
kind={kind}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MdItemCardProps {
|
|
item: CcMdItem;
|
|
onOpen: (p: string) => void;
|
|
onEdit: (
|
|
type: CcArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CcArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
kind: "skills" | "agents" | "commands" | "outputStyles";
|
|
}
|
|
|
|
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
|
|
|
|
const filePath = item.file || `${item.path}/SKILL.md`;
|
|
const description =
|
|
item.frontmatter.description ||
|
|
item.preview
|
|
.replace(/^#+\s.*\n/, "")
|
|
.trim()
|
|
.slice(0, 200);
|
|
return (
|
|
<div 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} />
|
|
{item.frontmatter.model && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
|
|
{item.frontmatter.model}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{description && (
|
|
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
|
|
{description}
|
|
</p>
|
|
)}
|
|
{kind === "agents" && item.frontmatter.tools && (
|
|
<div className="mt-2 text-[11px] text-fg-muted">
|
|
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
|
|
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
|
<button
|
|
onClick={() => onOpen(filePath)}
|
|
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("common.viewSource")}
|
|
</button>
|
|
<button
|
|
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
|
|
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("edit.editButton")}
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
|
|
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("edit.deleteButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Plugins ───────────────────────────────────────────────────────────
|
|
|
|
function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search: string }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
if (!data) return <SkeletonRows n={4} />;
|
|
const filtered = data.plugins.filter(
|
|
(p) => !search || p.key.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<ExplainerBanner
|
|
title={t("explain.plugins.title")}
|
|
body={t("explain.plugins.body")}
|
|
howTo={t("explain.plugins.install")}
|
|
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
|
/>
|
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
|
|
<FileText className="w-3.5 h-3.5" />
|
|
<span className="font-mono truncate">{data.manifestPath}</span>
|
|
{!data.manifestExists && (
|
|
<span className="ml-auto text-status-warning">
|
|
{t("plugins.manifestMissing", { path: "" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<Empty />
|
|
) : (
|
|
filtered.map((p) => <PluginCard key={p.key} plugin={p} />)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PluginCard({ plugin: p }: { plugin: CcPlugin }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const meta = p.contributes?.pluginJson;
|
|
const description = meta?.description;
|
|
const contribCounts: { key: string; count: number; label: string }[] = [];
|
|
if (p.contributes) {
|
|
if (p.contributes.skills > 0)
|
|
contribCounts.push({
|
|
key: "skills",
|
|
count: p.contributes.skills,
|
|
label: t("plugins.skills", { count: p.contributes.skills }),
|
|
});
|
|
if (p.contributes.agents > 0)
|
|
contribCounts.push({
|
|
key: "agents",
|
|
count: p.contributes.agents,
|
|
label: t("plugins.agents", { count: p.contributes.agents }),
|
|
});
|
|
if (p.contributes.commands > 0)
|
|
contribCounts.push({
|
|
key: "commands",
|
|
count: p.contributes.commands,
|
|
label: t("plugins.commands", { count: p.contributes.commands }),
|
|
});
|
|
if (p.contributes.outputStyles > 0)
|
|
contribCounts.push({
|
|
key: "outputStyles",
|
|
count: p.contributes.outputStyles,
|
|
label: t("plugins.outputStyles", { count: p.contributes.outputStyles }),
|
|
});
|
|
if (p.contributes.hooks > 0)
|
|
contribCounts.push({
|
|
key: "hooks",
|
|
count: p.contributes.hooks,
|
|
label: t("plugins.hooks", { count: p.contributes.hooks }),
|
|
});
|
|
}
|
|
return (
|
|
<div 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">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm text-fg-primary">{p.name}</span>
|
|
{p.marketplace && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
|
{p.marketplace}
|
|
</span>
|
|
)}
|
|
{p.version && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
|
|
v{p.version}
|
|
</span>
|
|
)}
|
|
<ScopeBadge scope={p.scope} />
|
|
{p.enabled === true && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-success/10 text-status-success border border-status-success/30 inline-flex items-center gap-1">
|
|
<CircleDot className="w-3 h-3" />
|
|
{t("plugins.enabled")}
|
|
</span>
|
|
)}
|
|
{p.enabled === false && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-4/10 text-fg-secondary border border-border-light/30 inline-flex items-center gap-1">
|
|
<CircleSlash className="w-3 h-3" />
|
|
{t("plugins.disabled")}
|
|
</span>
|
|
)}
|
|
{!p.installPathExists && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-status-warning/10 text-status-warning border border-status-warning/30 inline-flex items-center gap-1">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{t("plugins.missing")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{description && (
|
|
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed">{description}</p>
|
|
)}
|
|
{contribCounts.length > 0 && (
|
|
<div className="mt-2.5">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
|
{t("plugins.contributes")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{contribCounts.map((c) => (
|
|
<span
|
|
key={c.key}
|
|
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border"
|
|
>
|
|
{c.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
|
|
{meta?.author?.name && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("plugins.author")}:</span> {meta.author.name}
|
|
</div>
|
|
)}
|
|
{meta?.license && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("plugins.license")}:</span> {meta.license}
|
|
</div>
|
|
)}
|
|
{p.installedAt && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("plugins.installedAt")}:</span>{" "}
|
|
{new Date(p.installedAt).toLocaleString()}
|
|
</div>
|
|
)}
|
|
{p.lastUpdated && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("plugins.lastUpdated")}:</span>{" "}
|
|
{new Date(p.lastUpdated).toLocaleString()}
|
|
</div>
|
|
)}
|
|
{p.gitCommitSha && (
|
|
<div className="col-span-2">
|
|
<span className="text-fg-muted">SHA:</span>{" "}
|
|
<span className="font-mono">{p.gitCommitSha.slice(0, 12)}</span>
|
|
</div>
|
|
)}
|
|
{meta?.homepage && (
|
|
<div className="col-span-2 truncate">
|
|
<span className="text-fg-muted">{t("plugins.homepage")}:</span>{" "}
|
|
<a
|
|
href={meta.homepage}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-accent hover:underline"
|
|
>
|
|
{meta.homepage}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{p.installPath && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
|
|
{p.installPath}
|
|
</span>
|
|
<CopyButton value={p.installPath} />
|
|
</div>
|
|
)}
|
|
<div className="mt-3">
|
|
<CommandSnippet
|
|
command={`claude plugin uninstall ${p.key}`}
|
|
label={t("explain.plugins.uninstall")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── MCP servers ───────────────────────────────────────────────────────
|
|
|
|
function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
if (!data) return <SkeletonRows n={3} />;
|
|
const all = [...data.user, ...data.projectScoped];
|
|
const filter = (arr: CcMcpServer[]) =>
|
|
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
|
|
return (
|
|
<div className="space-y-4">
|
|
<ExplainerBanner
|
|
title={t("explain.mcp.title")}
|
|
body={t("explain.mcp.body")}
|
|
howTo={t("explain.mcp.add")}
|
|
commands={[
|
|
{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") },
|
|
{ cmd: t("explain.mcp.addCmd"), note: t("explain.mcp.add") },
|
|
]}
|
|
/>
|
|
{all.length === 0 && (
|
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
|
{t("mcp.noServers")}
|
|
</div>
|
|
)}
|
|
{data.user.length > 0 && (
|
|
<div>
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
|
{t("mcp.userScope")}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{filter(data.user).map((s) => (
|
|
<McpCard key={`u:${s.name}:${s.source}`} server={s} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{data.projectScoped.length > 0 && (
|
|
<div>
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-2">
|
|
{t("mcp.projectScope")}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{filter(data.projectScoped).map((s) => (
|
|
<McpCard key={`p:${s.name}:${s.source}`} server={s} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function McpCard({ server }: { server: CcMcpServer }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
return (
|
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm text-fg-primary">{server.name}</span>
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
|
{server.kind}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted ml-auto truncate max-w-xs">{server.source}</span>
|
|
</div>
|
|
<div className="mt-2 space-y-1 text-[11px]">
|
|
{server.kind === "stdio" && (
|
|
<>
|
|
<Field label={t("mcp.command")}>
|
|
<span className="font-mono text-fg-secondary">{server.command}</span>
|
|
</Field>
|
|
{server.args && server.args.length > 0 && (
|
|
<Field label={t("mcp.args")}>
|
|
<span className="font-mono text-fg-secondary">{server.args.join(" ")}</span>
|
|
</Field>
|
|
)}
|
|
{server.envNames && server.envNames.length > 0 && (
|
|
<Field label={t("mcp.env")}>
|
|
<span className="font-mono text-fg-secondary">{server.envNames.join(", ")}</span>
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
{server.kind === "http" && (
|
|
<>
|
|
<Field label={t("mcp.url")}>
|
|
<span className="font-mono text-fg-secondary">{server.url}</span>
|
|
</Field>
|
|
{server.headers && server.headers.length > 0 && (
|
|
<Field label={t("mcp.headers")}>
|
|
<span className="font-mono text-fg-secondary">{server.headers.join(", ")}</span>
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="mt-3">
|
|
<CommandSnippet
|
|
command={`claude mcp remove ${server.name}`}
|
|
label={t("explain.mcp.remove")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<span className="text-fg-muted min-w-20">{label}:</span>
|
|
<span className="min-w-0 flex-1 truncate">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Hooks ─────────────────────────────────────────────────────────────
|
|
|
|
function HooksPanel({
|
|
sources,
|
|
scripts,
|
|
search,
|
|
onOpen,
|
|
}: {
|
|
sources: CcHookSource[] | null;
|
|
scripts: CcHookScripts | null;
|
|
search: string;
|
|
onOpen: (p: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
if (!sources) return <SkeletonRows n={3} />;
|
|
return (
|
|
<div className="space-y-4">
|
|
<ExplainerBanner
|
|
title={t("explain.hooks.title")}
|
|
body={t("explain.hooks.body")}
|
|
howTo={t("explain.hooks.howTo")}
|
|
commands={[
|
|
{ cmd: t("explain.hooks.cmd1"), note: t("explain.hooks.cmd1Note") },
|
|
{ cmd: t("explain.hooks.cmd2"), note: t("explain.hooks.cmd2Note") },
|
|
{ cmd: t("explain.hooks.cmd3"), note: t("explain.hooks.cmd3Note") },
|
|
]}
|
|
/>
|
|
{sources.map((src) => {
|
|
const events = Object.entries(src.hooks);
|
|
const filteredEvents = search
|
|
? events.filter(([event]) => event.toLowerCase().includes(search.toLowerCase()))
|
|
: events;
|
|
return (
|
|
<div key={src.scope} 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">
|
|
<ScopeBadge scope={src.scope} />
|
|
<span className="font-mono text-[11px] text-fg-muted truncate flex-1">
|
|
{src.file}
|
|
</span>
|
|
{src.exists ? (
|
|
<button
|
|
onClick={() => onOpen(src.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("common.viewSource")}
|
|
</button>
|
|
) : (
|
|
<span className="text-[11px] text-fg-muted">{t("hooks.fileMissing")}</span>
|
|
)}
|
|
</div>
|
|
<div className="p-3">
|
|
{filteredEvents.length === 0 ? (
|
|
<div className="text-xs text-fg-muted px-1 py-2">{t("hooks.noHooks")}</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{filteredEvents.map(([event, entries]) => (
|
|
<div key={event}>
|
|
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5 inline-flex items-center gap-2">
|
|
<Wrench className="w-3 h-3 text-fg-muted" />
|
|
{event}
|
|
<span className="text-[10px] text-fg-muted">({entries.length})</span>
|
|
</div>
|
|
<div className="space-y-1.5 pl-5">
|
|
{entries.map((h, idx) => (
|
|
<div
|
|
key={`${event}-${idx}`}
|
|
className="rounded-md border border-border bg-surface-1 px-2.5 py-1.5 text-[11px]"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-[10px] text-fg-muted">
|
|
{t("hooks.matcher")}={h.matcher}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted">·</span>
|
|
<span className="font-mono text-[10px] text-fg-muted">{h.type}</span>
|
|
{h.timeout != null && (
|
|
<span className="text-[10px] text-fg-muted">{h.timeout}ms</span>
|
|
)}
|
|
</div>
|
|
{h.command && (
|
|
<div className="mt-1 font-mono text-[11px] text-fg-secondary break-all">
|
|
{h.command}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{scripts && scripts.items.length > 0 && (
|
|
<div className="rounded-lg border border-border bg-surface-2">
|
|
<div className="border-b border-border px-4 py-2.5">
|
|
<div className="text-sm font-medium text-fg-primary">{t("hookScripts.title")}</div>
|
|
<p className="mt-1 text-[11px] text-fg-muted leading-relaxed">
|
|
{t("hookScripts.subtitle")}
|
|
</p>
|
|
<div className="mt-1 font-mono text-[10px] text-fg-muted">{scripts.dir}</div>
|
|
</div>
|
|
<div className="p-3 space-y-1.5">
|
|
{scripts.items.map((s) => (
|
|
<button
|
|
key={s.file}
|
|
onClick={() => onOpen(s.file)}
|
|
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
|
|
>
|
|
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
|
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
|
|
{s.name}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
|
|
<span className="text-[10px] text-fg-muted hidden md:inline">
|
|
{new Date(s.mtime).toLocaleDateString()}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
|
|
// The settings that the TUI's `/config` editor manages, in display order.
|
|
// Surfaced as a resolved at-a-glance summary so the user sees what `/config`
|
|
// set (model, verbose, theme, …) without hunting through the raw JSON files.
|
|
// Keys map 1:1 to settings.json keys per https://code.claude.com/docs/en/settings.
|
|
const CONFIG_OPTION_GROUPS: { title: string; keys: { key: string; label: string }[] }[] = [
|
|
{
|
|
title: "Model & reasoning",
|
|
keys: [
|
|
{ key: "model", label: "Model" },
|
|
{ key: "effortLevel", label: "Effort level" },
|
|
{ key: "alwaysThinkingEnabled", label: "Always thinking" },
|
|
],
|
|
},
|
|
{
|
|
title: "Output & display",
|
|
keys: [
|
|
{ key: "outputStyle", label: "Output style" },
|
|
{ key: "verbose", label: "Verbose output" },
|
|
{ key: "theme", label: "Theme" },
|
|
{ key: "language", label: "Language" },
|
|
{ key: "spinnerTipsEnabled", label: "Spinner tips" },
|
|
{ key: "autoScrollEnabled", label: "Auto-scroll" },
|
|
],
|
|
},
|
|
{
|
|
title: "Session & input",
|
|
keys: [
|
|
{ key: "autoCompactEnabled", label: "Auto-compact" },
|
|
{ key: "fileCheckpointingEnabled", label: "File checkpointing" },
|
|
{ key: "editorMode", label: "Editor mode" },
|
|
{ key: "preferredNotifChannel", label: "Notifications" },
|
|
{ key: "awaySummaryEnabled", label: "Away summary" },
|
|
],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Resolve each /config option across the settings sources (project-local >
|
|
* project > user precedence - later sources in the array win) and render a
|
|
* compact summary. Unset options show as "default" so the view reflects the
|
|
* effective configuration, not just whatever happens to be written to a file.
|
|
*/
|
|
function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) {
|
|
// Build effective map: { key → { value, scope } }. Sources arrive ordered
|
|
// user → project → project-local, so a later hit overrides an earlier one.
|
|
const effective = new Map<string, { value: unknown; scope: CcSettingsSource["scope"] }>();
|
|
for (const src of sources) {
|
|
if (!src.exists || !src.data || typeof src.data !== "object") continue;
|
|
const data = src.data as Record<string, unknown>;
|
|
for (const group of CONFIG_OPTION_GROUPS) {
|
|
for (const { key } of group.keys) {
|
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
effective.set(key, { value: data[key], scope: src.scope });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const setCount = effective.size;
|
|
|
|
return (
|
|
<div 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">
|
|
<SettingsIcon className="w-3.5 h-3.5 text-violet-300/80" />
|
|
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
|
|
<span className="text-[11px] text-fg-muted ml-auto">
|
|
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
|
|
</span>
|
|
</div>
|
|
<div className="p-3 space-y-3">
|
|
{CONFIG_OPTION_GROUPS.map((group) => (
|
|
<div key={group.title}>
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
|
|
{group.title}
|
|
</div>
|
|
<div className="rounded-md border border-border bg-surface-1 divide-y divide-border">
|
|
{group.keys.map(({ key, label }) => {
|
|
const hit = effective.get(key);
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="px-3 py-1.5 grid grid-cols-[150px_1fr_auto] gap-3 items-center"
|
|
>
|
|
<div className="text-[11px] text-fg-secondary truncate">{label}</div>
|
|
<div className="min-w-0">
|
|
{hit ? (
|
|
<SettingsValue value={hit.value} />
|
|
) : (
|
|
<span className="text-[10px] text-fg-muted italic">default</span>
|
|
)}
|
|
</div>
|
|
{hit ? (
|
|
<ScopeBadge scope={hit.scope} />
|
|
) : (
|
|
<span className="text-[10px] text-fg-muted">-</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsPanel({
|
|
sources,
|
|
statusline,
|
|
onOpen,
|
|
}: {
|
|
sources: CcSettingsSource[] | null;
|
|
statusline: CcStatusline | null;
|
|
onOpen: (p: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
if (!sources) return <SkeletonRows n={3} />;
|
|
return (
|
|
<div className="space-y-3">
|
|
<ExplainerBanner
|
|
title={t("explain.settings.title")}
|
|
body={t("explain.settings.body")}
|
|
howTo={t("explain.settings.howTo")}
|
|
commands={[
|
|
{ cmd: t("explain.settings.cmd1"), note: t("explain.settings.cmd1Note") },
|
|
{ cmd: t("explain.settings.cmd2"), note: t("explain.settings.cmd2Note") },
|
|
{ cmd: t("explain.settings.cmd3"), note: t("explain.settings.cmd3Note") },
|
|
]}
|
|
/>
|
|
<CurrentConfigPanel sources={sources} />
|
|
<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("common.redactedNotice")}
|
|
</div>
|
|
{statusline && (statusline.config || statusline.scripts.length > 0) && (
|
|
<StatuslineBlock data={statusline} onOpen={onOpen} />
|
|
)}
|
|
{sources.map((src) => (
|
|
<SettingsBlock key={src.scope} source={src} onOpen={onOpen} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatuslineBlock({ data, onOpen }: { data: CcStatusline; onOpen: (p: string) => void }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
return (
|
|
<div className="rounded-lg border border-border bg-surface-2">
|
|
<div className="border-b border-border px-4 py-2.5">
|
|
<div className="text-sm font-medium text-fg-primary">{t("statusline.title")}</div>
|
|
</div>
|
|
<div className="p-3 space-y-3">
|
|
{data.config ? (
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
|
|
{t("statusline.configured")}
|
|
</div>
|
|
<div className="rounded-md border border-border bg-surface-1 px-3 py-2 text-[11px] font-mono text-fg-secondary">
|
|
<span className="text-fg-muted">type:</span> {data.config.type ?? "-"}
|
|
{data.config.command && (
|
|
<>
|
|
<br />
|
|
<span className="text-fg-muted">command:</span> {data.config.command}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-fg-muted">{t("statusline.noStatusline")}</div>
|
|
)}
|
|
{data.scripts.length > 0 && (
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
|
|
{t("statusline.scripts")}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{data.scripts.map((s) => (
|
|
<button
|
|
key={s.file}
|
|
onClick={() => onOpen(s.file)}
|
|
className="w-full text-left rounded-md border border-border bg-surface-1 hover:bg-surface-3 px-3 py-1.5 inline-flex items-center gap-2"
|
|
>
|
|
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
|
<span className="font-mono text-[11px] text-fg-secondary flex-1 truncate">
|
|
{s.file}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted">{formatBytes(s.size)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsBlock({
|
|
source,
|
|
onOpen,
|
|
}: {
|
|
source: CcSettingsSource;
|
|
onOpen: (p: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
return (
|
|
<div 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={source.scope} />
|
|
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
|
{source.file}
|
|
</span>
|
|
{source.exists ? (
|
|
<>
|
|
<button
|
|
onClick={() => setShowRaw((v) => !v)}
|
|
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"
|
|
>
|
|
{showRaw ? "Structured" : "Raw JSON"}
|
|
</button>
|
|
<button
|
|
onClick={() => onOpen(source.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("common.viewSource")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<span className="text-[11px] text-fg-muted">{t("settings.fileMissing")}</span>
|
|
)}
|
|
</div>
|
|
{source.exists &&
|
|
(showRaw ? (
|
|
<pre className="p-3 text-[11px] font-mono text-fg-secondary overflow-auto max-h-96">
|
|
{JSON.stringify(source.data, null, 2)}
|
|
</pre>
|
|
) : (
|
|
<SettingsKeyValueList data={source.data as Record<string, unknown> | null | undefined} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsKeyValueList({ data }: { data: Record<string, unknown> | null | undefined }) {
|
|
if (!data || typeof data !== "object") {
|
|
return <div className="p-3 text-xs text-fg-muted">-</div>;
|
|
}
|
|
const entries = Object.entries(data);
|
|
if (entries.length === 0) {
|
|
return <div className="p-3 text-xs text-fg-muted">{}</div>;
|
|
}
|
|
return (
|
|
<div className="divide-y divide-border">
|
|
{entries.map(([k, v]) => (
|
|
<div
|
|
key={k}
|
|
className="px-3 py-2 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-1 md:gap-3 items-start"
|
|
>
|
|
<div className="font-mono text-[11px] text-fg-secondary truncate">{k}</div>
|
|
<div className="min-w-0">
|
|
<SettingsValue value={v} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsValue({ value }: { value: unknown }) {
|
|
if (value === null || value === undefined)
|
|
return <span className="text-[11px] text-fg-muted italic">null</span>;
|
|
if (typeof value === "boolean") {
|
|
return (
|
|
<span
|
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
|
|
value
|
|
? "bg-status-success/10 text-status-success border-status-success/30"
|
|
: "bg-surface-4/10 text-fg-secondary border-border-light/30"
|
|
}`}
|
|
>
|
|
{value ? "true" : "false"}
|
|
</span>
|
|
);
|
|
}
|
|
if (typeof value === "number") {
|
|
return <span className="font-mono text-[11px] text-fg-secondary">{value}</span>;
|
|
}
|
|
if (typeof value === "string") {
|
|
return <span className="font-mono text-[11px] text-fg-secondary break-all">{value}</span>;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return <span className="text-[11px] text-fg-muted">[]</span>;
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{value.map((item, i) => (
|
|
<span
|
|
key={i}
|
|
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border break-all"
|
|
>
|
|
{typeof item === "object" ? JSON.stringify(item) : String(item)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
// object
|
|
const obj = value as Record<string, unknown>;
|
|
return (
|
|
<div className="space-y-0.5">
|
|
{Object.entries(obj).map(([k, v]) => (
|
|
<div key={k} className="font-mono text-[11px]">
|
|
<span className="text-fg-muted">{k}:</span>{" "}
|
|
<span className="text-fg-secondary break-all">
|
|
{typeof v === "object" ? JSON.stringify(v) : String(v)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Memory ────────────────────────────────────────────────────────────
|
|
|
|
interface MemoryPanelProps {
|
|
items: CcMemoryItem[] | null;
|
|
search: string;
|
|
onOpen: (p: string) => void;
|
|
onEdit: (
|
|
type: CcArtifactType,
|
|
item: { scope: "user" | "project"; name: string; filePath: string }
|
|
) => void;
|
|
onDelete: (
|
|
type: CcArtifactType,
|
|
scope: "user" | "project",
|
|
name: string | undefined,
|
|
path: string
|
|
) => void;
|
|
onCreate: (scope: "user" | "project") => void;
|
|
onEditAuto: (item: CcMemoryItem) => void;
|
|
onDeleteAuto: (item: CcMemoryItem) => void;
|
|
onCreateAuto: (project: string) => void;
|
|
}
|
|
|
|
// Strip a leading markdown heading then take a short snippet — used when a
|
|
// per-fact memory file has no frontmatter description.
|
|
function memoryDescription(m: CcMemoryItem): string {
|
|
return (
|
|
m.frontmatter?.description ||
|
|
m.preview
|
|
.replace(/^#+\s.*\n/, "")
|
|
.trim()
|
|
.slice(0, 200)
|
|
);
|
|
}
|
|
|
|
// Reduce a markdown link target (as written inside MEMORY.md, e.g.
|
|
// `./feedback_x.md#section` or `feedback_x.md`) to the bare filename we can
|
|
// match against a fact file's `name`. Tolerant of URL-encoding and anchors.
|
|
function normalizeMemoryTarget(target: string): string {
|
|
let v = target.trim();
|
|
const hash = v.indexOf("#");
|
|
if (hash >= 0) v = v.slice(0, hash);
|
|
try {
|
|
v = decodeURIComponent(v);
|
|
} catch {
|
|
/* leave as-is when not valid percent-encoding */
|
|
}
|
|
const slash = v.lastIndexOf("/");
|
|
if (slash >= 0) v = v.slice(slash + 1);
|
|
return v.trim();
|
|
}
|
|
|
|
// Render a MEMORY.md preview with its `[label](target.md)` markdown links
|
|
// turned into clickable buttons. Everything else is emitted verbatim so the
|
|
// surrounding <pre> keeps the original index layout. Clicking a link asks the
|
|
// parent to jump to (scroll + highlight) the matching fact file.
|
|
function renderMemoryIndex(
|
|
preview: string,
|
|
onJump: (target: string) => void,
|
|
jumpTitle: string
|
|
): React.ReactNode {
|
|
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
const lines = preview.split("\n");
|
|
return lines.map((line, li) => {
|
|
const parts: React.ReactNode[] = [];
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
linkRe.lastIndex = 0;
|
|
while ((m = linkRe.exec(line)) !== null) {
|
|
const full = m[0];
|
|
const label = m[1] ?? "";
|
|
const capturedTarget = m[2] ?? "";
|
|
if (m.index > last) parts.push(line.slice(last, m.index));
|
|
parts.push(
|
|
<button
|
|
key={`${li}-${m.index}`}
|
|
type="button"
|
|
onClick={() => onJump(capturedTarget)}
|
|
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
|
|
className="text-teal-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
last = m.index + full.length;
|
|
}
|
|
if (last < line.length) parts.push(line.slice(last));
|
|
return (
|
|
<span key={li}>
|
|
{parts.length ? parts : line}
|
|
{li < lines.length - 1 ? "\n" : null}
|
|
</span>
|
|
);
|
|
});
|
|
}
|
|
|
|
function MemoryPanel({
|
|
items,
|
|
search,
|
|
onOpen,
|
|
onEdit,
|
|
onDelete,
|
|
onCreate,
|
|
onEditAuto,
|
|
onDeleteAuto,
|
|
onCreateAuto,
|
|
}: MemoryPanelProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
|
|
const q = search.trim().toLowerCase();
|
|
|
|
const { primary, autoFiltered, groups, missingScopes } = useMemo(() => {
|
|
const list = items ?? [];
|
|
const primaryItems = list.filter(
|
|
(m): m is CcMemoryItem & { scope: "user" | "project" } =>
|
|
m.scope === "user" || m.scope === "project"
|
|
);
|
|
const autoItems = list.filter((m) => m.scope === "auto-memory");
|
|
|
|
const matchesAuto = (m: CcMemoryItem) => {
|
|
if (!q) return true;
|
|
const blob = [m.name, m.project, m.frontmatter?.description, m.frontmatter?.name, m.preview]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return blob.includes(q);
|
|
};
|
|
|
|
// Search applies to the whole tab — match the CLAUDE.md cards on their
|
|
// scope label, path, and body too so the filter is consistent.
|
|
const primaryFilteredItems = primaryItems.filter((m) => {
|
|
if (!q) return true;
|
|
return [m.scope, m.file, m.preview].join(" ").toLowerCase().includes(q);
|
|
});
|
|
|
|
const filtered = autoItems.filter(matchesAuto);
|
|
|
|
// Group surviving auto-memory files by their project dir.
|
|
const byProject = new Map<string, CcMemoryItem[]>();
|
|
for (const m of filtered) {
|
|
const key = m.project || "(unknown)";
|
|
if (!byProject.has(key)) byProject.set(key, []);
|
|
byProject.get(key)!.push(m);
|
|
}
|
|
const grouped = [...byProject.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
|
|
const present = new Set(primaryItems.map((m) => m.scope));
|
|
const missing = (["user", "project"] as const).filter((s) => !present.has(s));
|
|
|
|
return {
|
|
primary: primaryFilteredItems,
|
|
autoFiltered: filtered,
|
|
groups: grouped,
|
|
missingScopes: missing,
|
|
};
|
|
}, [items, q]);
|
|
|
|
if (!items) return <SkeletonRows n={2} />;
|
|
|
|
const totalAuto = items.filter((m) => m.scope === "auto-memory").length;
|
|
// The "create missing CLAUDE.md" prompts only make sense when not filtering.
|
|
const showMissing = !q;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Primary CLAUDE.md memory (user + project) — editable */}
|
|
{primary.map((m) => (
|
|
<div key={m.scope} 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>
|
|
<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("common.viewSource")}
|
|
</button>
|
|
<button
|
|
onClick={() => onEdit("memory", { 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("edit.editButton")}
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete("memory", 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("edit.deleteButton")}
|
|
</button>
|
|
</div>
|
|
<pre className="p-3 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-72 overflow-auto">
|
|
{m.preview}
|
|
{m.truncated && (
|
|
<span className="text-fg-muted italic">
|
|
{"\n\n"}
|
|
{t("common.truncated")}
|
|
</span>
|
|
)}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
|
|
{showMissing &&
|
|
missingScopes.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("memory.missing")}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => onCreate(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("edit.newButton")}
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{/* Per-project file-based memory (~/.claude/projects/<slug>/memory/) */}
|
|
{totalAuto > 0 && (
|
|
<section className="pt-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
|
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
|
|
{t("memory.autoTitle")}
|
|
</h3>
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-semibold bg-surface-3 text-fg-secondary">
|
|
{q ? `${autoFiltered.length}/${totalAuto}` : totalAuto}
|
|
</span>
|
|
</div>
|
|
<p className="text-[11px] text-fg-muted mb-2.5 leading-relaxed">
|
|
{t("memory.autoSubtitle")}
|
|
</p>
|
|
|
|
{groups.length === 0 ? (
|
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
|
{t("memory.noMatches")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{groups.map(([project, files]) => (
|
|
<MemoryProjectGroup
|
|
key={project}
|
|
project={project}
|
|
files={files}
|
|
onOpen={onOpen}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
onCreateAuto={onCreateAuto}
|
|
defaultOpen={!!q || groups.length === 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MemoryProjectGroupProps {
|
|
project: string;
|
|
files: CcMemoryItem[];
|
|
onOpen: (p: string) => void;
|
|
onEditAuto: (item: CcMemoryItem) => void;
|
|
onDeleteAuto: (item: CcMemoryItem) => void;
|
|
onCreateAuto: (project: string) => void;
|
|
defaultOpen: boolean;
|
|
}
|
|
|
|
function MemoryProjectGroup({
|
|
project,
|
|
files,
|
|
onOpen,
|
|
onEditAuto,
|
|
onDeleteAuto,
|
|
onCreateAuto,
|
|
defaultOpen,
|
|
}: MemoryProjectGroupProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
// Re-sync when the search-driven default flips (expand on search, collapse
|
|
// when cleared). User toggles within a stable search state are preserved.
|
|
useEffect(() => {
|
|
setOpen(defaultOpen);
|
|
}, [defaultOpen]);
|
|
|
|
const indexFiles = files.filter((f) => f.isIndex);
|
|
const factFiles = files.filter((f) => !f.isIndex);
|
|
|
|
// Wiring for "click an index entry → jump to its fact file". Fact rows
|
|
// register their DOM node keyed by filename; the index links look them up.
|
|
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const [highlighted, setHighlighted] = useState<string | null>(null);
|
|
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(
|
|
() => () => {
|
|
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const rowKey = useCallback((m: CcMemoryItem) => m.name || normalizeMemoryTarget(m.file), []);
|
|
|
|
const handleJump = useCallback(
|
|
(target: string) => {
|
|
const name = normalizeMemoryTarget(target);
|
|
const el = rowRefs.current.get(name);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
setHighlighted(name);
|
|
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
|
highlightTimer.current = setTimeout(() => setHighlighted(null), 2200);
|
|
return;
|
|
}
|
|
// Target isn't currently in view (e.g. filtered out by search) — open the
|
|
// underlying file directly if we can resolve it within this project.
|
|
const match = files.find((f) => (f.name || normalizeMemoryTarget(f.file)) === name);
|
|
if (match) onOpen(match.file);
|
|
},
|
|
[files, onOpen]
|
|
);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
|
|
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex items-center gap-2 px-3 py-2.5 text-left flex-1 min-w-0"
|
|
>
|
|
<ChevronDown
|
|
className={`w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform ${
|
|
open ? "" : "-rotate-90"
|
|
}`}
|
|
/>
|
|
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
|
|
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
|
|
{project}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted flex-shrink-0">
|
|
{t("memory.fileCount", { count: files.length })}
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => onCreateAuto(project)}
|
|
title={t("memory.newFile")}
|
|
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1 flex-shrink-0"
|
|
>
|
|
<Plus className="w-2.5 h-2.5" />
|
|
{t("memory.newFile")}
|
|
</button>
|
|
</div>
|
|
|
|
{open && (
|
|
<div className="border-t border-border p-2.5 space-y-2.5">
|
|
{indexFiles.length > 0 && (
|
|
<div>
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
|
|
{t("memory.indexFiles")}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{indexFiles.map((m) => (
|
|
<MemoryIndexCard
|
|
key={m.file}
|
|
item={m}
|
|
onOpen={onOpen}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
onJump={handleJump}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{factFiles.length > 0 && (
|
|
<div>
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5 px-1">
|
|
{t("memory.factFiles", { count: factFiles.length })}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{factFiles.map((m) => {
|
|
const key = rowKey(m);
|
|
return (
|
|
<MemoryFactRow
|
|
key={m.file}
|
|
item={m}
|
|
onOpen={onOpen}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
highlighted={highlighted === key}
|
|
rowRef={(el) => {
|
|
if (el) rowRefs.current.set(key, el);
|
|
else rowRefs.current.delete(key);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MemoryAutoItemProps {
|
|
item: CcMemoryItem;
|
|
onOpen: (p: string) => void;
|
|
onEditAuto: (item: CcMemoryItem) => void;
|
|
onDeleteAuto: (item: CcMemoryItem) => void;
|
|
}
|
|
|
|
// Compact View / Edit / Delete button cluster shared by index + fact rows.
|
|
function MemoryAutoActions({ item, onOpen, onEditAuto, onDeleteAuto }: MemoryAutoItemProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
return (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<button
|
|
onClick={() => onOpen(item.file)}
|
|
title={t("common.viewSource")}
|
|
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="w-2.5 h-2.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => onEditAuto(item)}
|
|
title={t("edit.editButton")}
|
|
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1"
|
|
>
|
|
<Pencil className="w-2.5 h-2.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDeleteAuto(item)}
|
|
title={t("edit.deleteButton")}
|
|
className="text-[10px] font-medium px-1.5 py-0.5 rounded border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1"
|
|
>
|
|
<Trash2 className="w-2.5 h-2.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemoryIndexCard({
|
|
item,
|
|
onOpen,
|
|
onEditAuto,
|
|
onDeleteAuto,
|
|
onJump,
|
|
}: MemoryAutoItemProps & { onJump: (target: string) => void }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
return (
|
|
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
|
|
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
|
|
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
|
|
{item.name}
|
|
</span>
|
|
<span className="text-[10px] text-fg-muted">{formatBytes(item.size)}</span>
|
|
<MemoryAutoActions
|
|
item={item}
|
|
onOpen={onOpen}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
/>
|
|
</div>
|
|
<pre className="px-3 py-2 text-[10.5px] font-mono text-fg-secondary whitespace-pre-wrap break-words max-h-40 overflow-auto">
|
|
{renderMemoryIndex(item.preview, onJump, t("memory.jumpTo"))}
|
|
{item.truncated && <span className="text-fg-muted italic">{"\n…"}</span>}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemoryFactRow({
|
|
item,
|
|
onOpen,
|
|
onEditAuto,
|
|
onDeleteAuto,
|
|
highlighted,
|
|
rowRef,
|
|
}: MemoryAutoItemProps & {
|
|
highlighted?: boolean;
|
|
rowRef?: (el: HTMLDivElement | null) => void;
|
|
}) {
|
|
const desc = memoryDescription(item);
|
|
return (
|
|
<div
|
|
ref={rowRef}
|
|
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
|
|
highlighted
|
|
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
|
|
: "border-border bg-surface-1 hover:border-border/80"
|
|
}`}
|
|
>
|
|
<FileText className="w-3 h-3 text-fg-muted flex-shrink-0 mt-0.5" />
|
|
<div className="min-w-0 flex-1">
|
|
<span className="font-mono text-[11px] text-fg-secondary truncate block">{item.name}</span>
|
|
{desc && (
|
|
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">{desc}</p>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] text-fg-muted flex-shrink-0 mt-0.5">
|
|
{formatBytes(item.size)}
|
|
</span>
|
|
<div className="mt-0.5">
|
|
<MemoryAutoActions
|
|
item={item}
|
|
onOpen={onOpen}
|
|
onEditAuto={onEditAuto}
|
|
onDeleteAuto={onDeleteAuto}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Marketplaces ──────────────────────────────────────────────────────
|
|
|
|
function MarketplacesPanel({
|
|
data,
|
|
search,
|
|
}: {
|
|
data: CcMarketplacesResponse | null;
|
|
search: string;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
if (!data) return <SkeletonRows n={3} />;
|
|
const filtered = data.items.filter(
|
|
(m) =>
|
|
!search ||
|
|
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(m.marketplaceName || "").toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
return (
|
|
<div className="space-y-3">
|
|
<ExplainerBanner
|
|
title={t("explain.plugins.title")}
|
|
body={t("explain.plugins.body")}
|
|
howTo={t("marketplaces.manifest")}
|
|
commands={[{ cmd: t("marketplaces.addCmd"), note: "" }]}
|
|
/>
|
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted">
|
|
<FileText className="w-3.5 h-3.5" />
|
|
<span className="font-mono truncate">{data.knownPath}</span>
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
|
{t("marketplaces.noMarketplaces")}
|
|
</div>
|
|
) : (
|
|
filtered.map((m) => (
|
|
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Store className="w-3.5 h-3.5 text-fg-muted" />
|
|
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
|
|
{m.marketplaceName && m.marketplaceName !== m.name && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
|
{m.marketplaceName}
|
|
</span>
|
|
)}
|
|
{m.pluginCount != null && (
|
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
|
|
{t("marketplaces.pluginCount")}: {m.pluginCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.marketplaceDescription && (
|
|
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
|
|
{m.marketplaceDescription}
|
|
</p>
|
|
)}
|
|
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
|
|
{m.source && (
|
|
<div className="col-span-2 truncate">
|
|
<span className="text-fg-muted">{t("marketplaces.source")}:</span>{" "}
|
|
<span className="font-mono">
|
|
{m.source.source === "github" && m.source.repo
|
|
? `github.com/${m.source.repo}`
|
|
: m.source.url || m.source.repo || JSON.stringify(m.source)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{m.marketplaceOwner?.name && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("marketplaces.owner")}:</span>{" "}
|
|
{m.marketplaceOwner.name}
|
|
</div>
|
|
)}
|
|
{m.lastUpdated && (
|
|
<div>
|
|
<span className="text-fg-muted">{t("marketplaces.lastUpdated")}:</span>{" "}
|
|
{new Date(m.lastUpdated).toLocaleString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{m.installLocation && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="font-mono text-[10px] text-fg-muted truncate flex-1">
|
|
{m.installLocation}
|
|
</span>
|
|
<CopyButton value={m.installLocation} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Keybindings ───────────────────────────────────────────────────────
|
|
|
|
function KeybindingsPanel({
|
|
data,
|
|
search,
|
|
onSaved,
|
|
onToast,
|
|
}: {
|
|
data: CcKeybindings | null;
|
|
search: string;
|
|
onSaved: () => void;
|
|
onToast: (toast: NonNullable<Toast>) => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState<CcKeybindingGroup[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const startEdit = useCallback(() => {
|
|
const groups = data?.groups ?? [];
|
|
// Deep clone so edits never mutate the fetched data.
|
|
setDraft(
|
|
groups.map((g) => ({ context: g.context, bindings: g.bindings.map((b) => ({ ...b })) }))
|
|
);
|
|
setErr(null);
|
|
setEditing(true);
|
|
}, [data]);
|
|
|
|
const cancelEdit = useCallback(() => {
|
|
setEditing(false);
|
|
setDraft([]);
|
|
setErr(null);
|
|
}, []);
|
|
|
|
const updateContext = (gi: number, value: string) =>
|
|
setDraft((d) => d.map((g, i) => (i === gi ? { ...g, context: value } : g)));
|
|
const removeContext = (gi: number) => setDraft((d) => d.filter((_, i) => i !== gi));
|
|
const addContext = () =>
|
|
setDraft((d) => [...d, { context: "", bindings: [{ key: "", action: "" }] }]);
|
|
const updateBinding = (gi: number, bi: number, field: "key" | "action", value: string) =>
|
|
setDraft((d) =>
|
|
d.map((g, i) =>
|
|
i === gi
|
|
? { ...g, bindings: g.bindings.map((b, j) => (j === bi ? { ...b, [field]: value } : b)) }
|
|
: g
|
|
)
|
|
);
|
|
const removeBinding = (gi: number, bi: number) =>
|
|
setDraft((d) =>
|
|
d.map((g, i) => (i === gi ? { ...g, bindings: g.bindings.filter((_, j) => j !== bi) } : g))
|
|
);
|
|
const addBinding = (gi: number) =>
|
|
setDraft((d) =>
|
|
d.map((g, i) => (i === gi ? { ...g, bindings: [...g.bindings, { key: "", action: "" }] } : g))
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
const groups: CcKeybindingGroup[] = draft.map((g) => ({
|
|
context: g.context.trim(),
|
|
bindings: g.bindings.map((b) => ({ key: b.key.trim(), action: b.action.trim() })),
|
|
}));
|
|
// Mirror the server-side validation so users get instant, local feedback.
|
|
const seen = new Set<string>();
|
|
for (const g of groups) {
|
|
if (!g.context) return setErr(t("keybindings.errContext"));
|
|
if (seen.has(g.context))
|
|
return setErr(t("keybindings.errDupContext", { context: g.context }));
|
|
seen.add(g.context);
|
|
const keys = new Set<string>();
|
|
for (const b of g.bindings) {
|
|
if (!b.key || !b.action) return setErr(t("keybindings.errEmpty", { context: g.context }));
|
|
if (keys.has(b.key))
|
|
return setErr(t("keybindings.errDupKey", { key: b.key, context: g.context }));
|
|
keys.add(b.key);
|
|
}
|
|
}
|
|
setSaving(true);
|
|
setErr(null);
|
|
try {
|
|
const result = await api.ccConfig.writeKeybindings(groups);
|
|
onToast({
|
|
kind: "success",
|
|
message: result.created
|
|
? t("edit.saveSuccessNew")
|
|
: t("edit.saveSuccess", { path: result.backupPath || "-" }),
|
|
});
|
|
setEditing(false);
|
|
setDraft([]);
|
|
onSaved();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : "unknown error";
|
|
setErr(msg);
|
|
onToast({ kind: "error", message: t("edit.writeError", { message: msg }) });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [draft, onSaved, onToast, t]);
|
|
|
|
if (!data) return <SkeletonRows n={3} />;
|
|
|
|
const headerBar = (
|
|
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 flex items-center gap-2 text-[11px] text-fg-muted flex-wrap">
|
|
<FileText className="w-3.5 h-3.5" />
|
|
<span className="font-mono truncate flex-1 min-w-0">{data.file}</span>
|
|
{data.docs && (
|
|
<a
|
|
href={data.docs}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[11px] text-accent hover:underline inline-flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
{t("keybindings.docsLink")}
|
|
</a>
|
|
)}
|
|
{editing ? (
|
|
<div className="inline-flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={cancelEdit}
|
|
disabled={saving}
|
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary disabled:opacity-50"
|
|
>
|
|
{t("edit.cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
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 disabled:opacity-50"
|
|
>
|
|
<Save className="w-3 h-3" />
|
|
{saving ? t("edit.saving") : t("edit.save")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={startEdit}
|
|
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"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
{t("edit.editButton")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ── Edit mode ────────────────────────────────────────────────────────
|
|
if (editing) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{headerBar}
|
|
{err && (
|
|
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-center gap-2">
|
|
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span>{err}</span>
|
|
</div>
|
|
)}
|
|
{draft.map((g, gi) => (
|
|
<div key={gi} className="rounded-lg border border-border bg-surface-2">
|
|
<div className="border-b border-border px-3 py-2 flex items-center gap-2">
|
|
<span className="text-[11px] text-fg-muted flex-shrink-0">
|
|
{t("keybindings.context")}
|
|
</span>
|
|
<input
|
|
value={g.context}
|
|
onChange={(e) => updateContext(gi, e.target.value)}
|
|
placeholder={t("keybindings.contextPlaceholder")}
|
|
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeContext(gi)}
|
|
title={t("keybindings.removeContext")}
|
|
aria-label={t("keybindings.removeContext")}
|
|
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{g.bindings.map((b, bi) => (
|
|
<div key={bi} className="px-3 py-1.5 flex items-center gap-2">
|
|
<input
|
|
value={b.key}
|
|
onChange={(e) => updateBinding(gi, bi, "key", e.target.value)}
|
|
placeholder={t("keybindings.key")}
|
|
className="h-7 w-40 flex-shrink-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<span className="text-fg-muted flex-shrink-0">→</span>
|
|
<input
|
|
value={b.action}
|
|
onChange={(e) => updateBinding(gi, bi, "action", e.target.value)}
|
|
placeholder={t("keybindings.action")}
|
|
className="h-7 flex-1 min-w-0 bg-surface-1 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeBinding(gi, bi)}
|
|
title={t("keybindings.removeBinding")}
|
|
aria-label={t("keybindings.removeBinding")}
|
|
className="h-7 w-7 flex-shrink-0 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="px-3 py-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => addBinding(gi)}
|
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-border bg-surface-3 hover:bg-surface-1 text-fg-secondary inline-flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t("keybindings.addBinding")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addContext}
|
|
className="h-8 w-full text-[11px] font-medium rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center justify-center gap-1.5"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
{t("keybindings.addContext")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Read-only mode ───────────────────────────────────────────────────
|
|
if (!data.exists) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{headerBar}
|
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
|
{t("keybindings.missing", { path: data.file })}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
const q = search.toLowerCase();
|
|
return (
|
|
<div className="space-y-3">
|
|
{headerBar}
|
|
{data.groups.map((g) => {
|
|
const filtered = g.bindings.filter(
|
|
(b) =>
|
|
!q ||
|
|
b.key.toLowerCase().includes(q) ||
|
|
b.action.toLowerCase().includes(q) ||
|
|
g.context.toLowerCase().includes(q)
|
|
);
|
|
if (filtered.length === 0) return null;
|
|
return (
|
|
<div key={g.context} className="rounded-lg border border-border bg-surface-2">
|
|
<div className="border-b border-border px-4 py-2 text-xs font-medium text-fg-secondary">
|
|
{t("keybindings.context")}: <span className="text-fg-primary">{g.context}</span>
|
|
<span className="ml-2 text-[10px] text-fg-muted">({filtered.length})</span>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{filtered.map((b) => (
|
|
<div key={b.key} className="px-4 py-1.5 flex items-center gap-3">
|
|
<kbd className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-surface-3 border border-border text-fg-secondary min-w-20 text-center">
|
|
{b.key}
|
|
</kbd>
|
|
<span className="font-mono text-[11px] text-fg-secondary">{b.action}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Shared atoms ──────────────────────────────────────────────────────
|
|
|
|
function ScopeBadge({ scope }: { scope: string }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const color =
|
|
scope === "user"
|
|
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
|
: scope === "project"
|
|
? "bg-status-success/10 text-status-success border-status-success/30"
|
|
: scope === "project-local"
|
|
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
|
|
: "bg-surface-3 text-fg-secondary border-border";
|
|
const label =
|
|
scope === "project-local"
|
|
? t("scope.projectLocal")
|
|
: scope === "user"
|
|
? t("scope.user")
|
|
: scope === "project"
|
|
? t("scope.project")
|
|
: scope;
|
|
return (
|
|
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
|
|
);
|
|
}
|
|
|
|
function CopyButton({ value }: { value: string }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [copied, setCopied] = useState(false);
|
|
return (
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
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("common.copyPath")}
|
|
>
|
|
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Empty() {
|
|
const { t } = useTranslation("ccConfig");
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
|
{t("common.empty")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SkeletonRows({ n }: { n: number }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: n }).map((_, i) => (
|
|
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
// ── File viewer modal ─────────────────────────────────────────────────
|
|
|
|
function FileViewer({
|
|
state,
|
|
onClose,
|
|
}: {
|
|
state: { path: string; data: CcFileResponse | null; error: string | null };
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
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-4xl 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">
|
|
<FileText className="w-4 h-4 text-fg-muted" />
|
|
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
|
{state.path}
|
|
</span>
|
|
<CopyButton value={state.path} />
|
|
<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="overflow-auto p-4">
|
|
{state.error ? (
|
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{state.error}
|
|
</div>
|
|
) : !state.data ? (
|
|
<div className="text-sm text-fg-muted">…</div>
|
|
) : (
|
|
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
|
{state.data.text}
|
|
{state.data.truncated && (
|
|
<span className="text-fg-muted italic">
|
|
{"\n\n"}
|
|
{t("common.truncated")}
|
|
</span>
|
|
)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Editor modal (create + edit) ──────────────────────────────────────
|
|
|
|
interface EditorModalProps {
|
|
state: NonNullable<EditorState>;
|
|
onClose: () => void;
|
|
onSave: (args: {
|
|
type: CcArtifactType;
|
|
targetScope: "user" | "project" | "auto-memory";
|
|
name: string | undefined;
|
|
content: string;
|
|
project?: string;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
function EditorModal({ state, onClose, onSave }: EditorModalProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const isCreate = state.mode === "create";
|
|
const isAutoMemory = state.type === "auto-memory";
|
|
const [content, setContent] = useState<string>(isCreate ? state.template : "");
|
|
const [name, setName] = useState<string>("");
|
|
const [targetScope, setTargetScope] = useState<"user" | "project">(
|
|
isCreate ? state.defaultScope : state.scope === "auto-memory" ? "user" : state.scope
|
|
);
|
|
const [loading, setLoading] = useState(!isCreate);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// For edit mode, fetch the actual file content
|
|
useEffect(() => {
|
|
if (state.mode === "edit") {
|
|
setLoading(true);
|
|
api.ccConfig
|
|
.file(state.filePath)
|
|
.then((r) => {
|
|
setContent(r.text);
|
|
setLoading(false);
|
|
})
|
|
.catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : "unknown";
|
|
setError(msg);
|
|
setLoading(false);
|
|
});
|
|
}
|
|
}, [state]);
|
|
|
|
// Esc to close
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
if (state.type !== "memory" && state.mode === "create" && !name) {
|
|
setError(t("edit.nameLabel"));
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
// For auto-memory creates, append a .md extension when the user omits it.
|
|
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
|
|
const effectiveName =
|
|
state.mode === "edit" ? state.name : state.type === "memory" ? undefined : createName;
|
|
await onSave({
|
|
type: state.type,
|
|
targetScope: isAutoMemory ? "auto-memory" : targetScope,
|
|
name: effectiveName,
|
|
content,
|
|
project: state.project,
|
|
});
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : "unknown";
|
|
setError(t("edit.writeError", { message: msg }));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
|
|
|
|
const titleText = isCreate
|
|
? isAutoMemory
|
|
? t("memory.newFileTitle", { project: state.project ?? "" })
|
|
: t("edit.newTitle", { type: state.type })
|
|
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
|
|
|
|
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-4xl max-h-[90vh] 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">
|
|
<Pencil className="w-4 h-4 text-fg-muted" />
|
|
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</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="overflow-auto p-4 space-y-3">
|
|
{isCreate && state.type !== "memory" && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
|
{t("edit.nameLabel")}
|
|
</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={
|
|
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
|
|
}
|
|
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
|
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
/>
|
|
<p className="mt-1 text-[10px] text-fg-muted">
|
|
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
|
|
</p>
|
|
</div>
|
|
{isAutoMemory ? (
|
|
<div>
|
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
|
{t("memory.projectLabel")}
|
|
</label>
|
|
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
|
|
{state.project}
|
|
</div>
|
|
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
|
{t("edit.scopePicker")}
|
|
</label>
|
|
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
|
|
{(["user", "project"] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setTargetScope(s)}
|
|
className={`px-3 py-1 text-[11px] font-medium rounded ${
|
|
targetScope === s
|
|
? "bg-accent/20 text-accent border border-accent/30"
|
|
: "text-fg-secondary hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{t(`scope.${s}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
|
{t("edit.contentLabel")}
|
|
</label>
|
|
{loading ? (
|
|
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
|
|
) : (
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
spellCheck={false}
|
|
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
|
|
>
|
|
{t("edit.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving || loading}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{saving ? t("edit.saving") : t("edit.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Confirm-delete modal ──────────────────────────────────────────────
|
|
|
|
interface ConfirmDeleteModalProps {
|
|
state: NonNullable<ConfirmDeleteState>;
|
|
onCancel: () => void;
|
|
onConfirm: () => Promise<void>;
|
|
}
|
|
|
|
function ConfirmDeleteModal({ state, onCancel, onConfirm }: ConfirmDeleteModalProps) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onCancel();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [onCancel]);
|
|
|
|
const handleConfirm = useCallback(async () => {
|
|
setBusy(true);
|
|
try {
|
|
await onConfirm();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [onConfirm]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
|
onClick={onCancel}
|
|
>
|
|
<div
|
|
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
|
<ShieldAlert className="w-4 h-4 text-status-danger" />
|
|
<span className="text-sm font-medium text-fg-primary flex-1">
|
|
{t("edit.confirmDelete")}
|
|
</span>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
|
|
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
|
|
{t("edit.confirmDeletePath", { path: state.path })}
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={onCancel}
|
|
disabled={busy}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary disabled:opacity-60"
|
|
>
|
|
{t("edit.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirm}
|
|
disabled={busy}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Toast (5s auto-dismiss) ───────────────────────────────────────────
|
|
|
|
function ToastNotice({ toast, onDismiss }: { toast: NonNullable<Toast>; onDismiss: () => void }) {
|
|
const isErr = toast.kind === "error";
|
|
return (
|
|
<div className="fixed bottom-6 right-6 z-50 max-w-md">
|
|
<div
|
|
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
|
|
isErr
|
|
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
|
: "border-status-success/40 bg-status-success/10 text-status-success"
|
|
}`}
|
|
>
|
|
{isErr ? (
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
) : (
|
|
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
)}
|
|
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
|
|
<button
|
|
onClick={onDismiss}
|
|
className="text-current/70 hover:text-current p-0.5"
|
|
aria-label="dismiss"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Read-only explainer banner ─────────────────────────────────────────
|
|
|
|
interface ExplainerBannerProps {
|
|
title: string;
|
|
body: string;
|
|
howTo: string;
|
|
commands: { cmd: string; note: string }[];
|
|
}
|
|
|
|
function ExplainerBanner({ title, body, howTo, commands }: ExplainerBannerProps) {
|
|
return (
|
|
<div className="rounded-lg border border-status-warning/30 bg-status-warning/[0.04] px-4 py-3">
|
|
<div className="flex items-start gap-2">
|
|
<Lock className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="text-sm font-medium text-status-warning">{title}</div>
|
|
<p className="text-xs text-fg-secondary leading-relaxed">{body}</p>
|
|
{commands.length > 0 && (
|
|
<div className="pt-1">
|
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
|
|
{howTo}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{commands.map((c, i) => (
|
|
<CommandSnippet key={i} command={c.cmd} label={c.note} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Inline copyable command ────────────────────────────────────────────
|
|
|
|
function CommandSnippet({ command, label }: { command: string; label?: string }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
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>
|
|
{label && (
|
|
<span className="text-[10px] text-fg-muted hidden md:inline truncate">{label}</span>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ── Backups modal ──────────────────────────────────────────────────────
|
|
|
|
function BackupsModal({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [items, setItems] = useState<CcBackup[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.ccConfig
|
|
.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.backupPath} backup={b} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BackupRow({ backup }: { backup: CcBackup }) {
|
|
const { t } = useTranslation("ccConfig");
|
|
// Heuristic restore: rename the backup back to the active path. We don't
|
|
// shell out from the dashboard for this (too risky to silently overwrite
|
|
// a current active version) - show the user a copyable mv command instead.
|
|
const restoreCmd = `mv ${shellEscape(backup.backupPath)} ${shellEscape(deriveActivePath(backup))}`;
|
|
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">
|
|
<ScopeBadge scope={backup.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.backupPath}</div>
|
|
<div className="mt-2">
|
|
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
|
|
<CommandSnippet command={restoreCmd} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Best-effort: derive the active path the backup would restore to. We strip
|
|
// the trailing `.<ISO>.bak` segment from the basename and put the result back
|
|
// under the right active subdir. If the format doesn't match, fall back to
|
|
// "(unknown)" - the user can still copy the backup path itself.
|
|
function deriveActivePath(b: CcBackup): string {
|
|
// Strip ".<ISO>.bak" suffix from the basename.
|
|
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
|
|
const baseName = m ? m[1] : b.name;
|
|
const backupDir = b.backupPath.replace(/\/[^/]+$/, ""); // dirname
|
|
// Backups live at <root>/cc-config-backups/<type>/<name>.<ts>.bak
|
|
// Active lives at <root>/<type>/<baseName> (or .../skills/<baseName>/SKILL.md handled at use-time)
|
|
const rootMatch = backupDir.match(/^(.*)\/cc-config-backups\/([^/]+)$/);
|
|
if (rootMatch) {
|
|
const root = rootMatch[1];
|
|
const type = rootMatch[2];
|
|
return `${root}/${type}/${baseName}`;
|
|
}
|
|
// memory backups live at <projectRoot>/.cc-config-backups/memory/<name>.<ts>.bak
|
|
const memMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/memory$/);
|
|
if (memMatch) return `${memMatch[1]}/${baseName}`;
|
|
// auto-memory backups live at <memoryDir>/.cc-config-backups/auto-memory/<name>.<ts>.bak
|
|
const autoMatch = backupDir.match(/^(.*)\/\.cc-config-backups\/auto-memory$/);
|
|
if (autoMatch) return `${autoMatch[1]}/${baseName}`;
|
|
return "<active path>";
|
|
}
|
|
|
|
// 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, `'\\''`)}'`;
|
|
}
|