From 865c6c1d1e44e0dca3dc234767c8c219de728172 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 8 Sep 2026 20:40:47 +0700 Subject: [PATCH] refactor(config): lift the tool-agnostic config modals out of CcConfig Moves FileViewer, EditorModal, ConfirmDeleteModal, ToastNotice, MdItemList, ScopeBadge, and the shared atoms (Empty/SkeletonRows/CopyButton/formatBytes) into client/src/components/config/ so the upcoming Codex config panel can reuse them. EditorModal now takes a fetchContent callback instead of calling api.ccConfig.file itself; CcConfig.tsx supplies the Claude-specific fetcher. The moved EditorState/ConfirmDeleteState type field widens from CcArtifactType to string so non-Claude callers can use them too, with a cast at the two remaining Claude-specific call sites in CcConfig.tsx. --- .../components/config/ConfirmDeleteModal.tsx | 89 +++ client/src/components/config/EditorModal.tsx | 237 +++++++ client/src/components/config/FileViewer.tsx | 77 +++ client/src/components/config/MdItemList.tsx | 145 ++++ client/src/components/config/ScopeBadge.tsx | 32 + client/src/components/config/ToastNotice.tsx | 46 ++ .../config/__tests__/EditorModal.test.tsx | 48 ++ .../components/config/configPrimitives.tsx | 59 ++ client/src/pages/CcConfig.tsx | 640 +----------------- 9 files changed, 759 insertions(+), 614 deletions(-) create mode 100644 client/src/components/config/ConfirmDeleteModal.tsx create mode 100644 client/src/components/config/EditorModal.tsx create mode 100644 client/src/components/config/FileViewer.tsx create mode 100644 client/src/components/config/MdItemList.tsx create mode 100644 client/src/components/config/ScopeBadge.tsx create mode 100644 client/src/components/config/ToastNotice.tsx create mode 100644 client/src/components/config/__tests__/EditorModal.test.tsx create mode 100644 client/src/components/config/configPrimitives.tsx diff --git a/client/src/components/config/ConfirmDeleteModal.tsx b/client/src/components/config/ConfirmDeleteModal.tsx new file mode 100644 index 00000000..43786f62 --- /dev/null +++ b/client/src/components/config/ConfirmDeleteModal.tsx @@ -0,0 +1,89 @@ +/** + * @file ConfirmDeleteModal.tsx + * @description Confirmation modal for deleting a config artifact. Tool- + * agnostic — the caller supplies the async delete action; the modal only + * tracks the busy state and Escape-to-cancel behavior. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert, Trash2 } from "lucide-react"; + +export type ConfirmDeleteState = { + type: string; + scope: "user" | "project" | "auto-memory"; + name?: string; + path: string; + project?: string; // set for type === "auto-memory" +} | null; + +interface ConfirmDeleteModalProps { + state: NonNullable; + onCancel: () => void; + onConfirm: () => Promise; +} + +export 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 ( +
+
e.stopPropagation()} + > +
+ + + {t("edit.confirmDelete")} + +
+
+

{t("edit.confirmDeleteBody")}

+
+ {t("edit.confirmDeletePath", { path: state.path })} +
+
+
+ + +
+
+
+ ); +} diff --git a/client/src/components/config/EditorModal.tsx b/client/src/components/config/EditorModal.tsx new file mode 100644 index 00000000..7ea3305c --- /dev/null +++ b/client/src/components/config/EditorModal.tsx @@ -0,0 +1,237 @@ +/** + * @file EditorModal.tsx + * @description Create/edit modal for text-file config artifacts (skills, + * agents, commands, output styles, memory). Tool-agnostic: it takes a + * `fetchContent` callback instead of reaching for any particular tool's API, + * so the same modal serves both the Claude Code and Codex config pages. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pencil, X, Save, AlertCircle } from "lucide-react"; + +export type EditorState = + | { + mode: "create"; + type: string; + defaultScope: "user" | "project"; + template: string; + project?: string; // set for type === "auto-memory" + } + | { + mode: "edit"; + type: string; + scope: "user" | "project" | "auto-memory"; + name: string; + filePath: string; + project?: string; // set for type === "auto-memory" + } + | null; + +interface EditorModalProps { + state: NonNullable; + fetchContent: (filePath: string) => Promise; + onClose: () => void; + onSave: (args: { + type: string; + targetScope: "user" | "project" | "auto-memory"; + name: string | undefined; + content: string; + project?: string; + }) => Promise; +} + +export function EditorModal({ state, fetchContent, onClose, onSave }: EditorModalProps) { + const { t } = useTranslation("ccConfig"); + const isCreate = state.mode === "create"; + const isAutoMemory = state.type === "auto-memory"; + const [content, setContent] = useState(isCreate ? state.template : ""); + const [name, setName] = useState(""); + 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(null); + + // For edit mode, fetch the actual file content + useEffect(() => { + if (state.mode === "edit") { + setLoading(true); + fetchContent(state.filePath) + .then((text) => { + setContent(text); + setLoading(false); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "unknown"; + setError(msg); + setLoading(false); + }); + } + }, [state, fetchContent]); + + // 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 ( +
+
e.stopPropagation()} + > +
+ + {titleText} + +
+ +
+ {isCreate && state.type !== "memory" && ( +
+
+ + 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" + /> +

+ {isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")} +

+
+ {isAutoMemory ? ( +
+ +
+ {state.project} +
+

{t("memory.projectHelp")}

+
+ ) : ( +
+ +
+ {(["user", "project"] as const).map((s) => ( + + ))} +
+
+ )} +
+ )} + +
+ + {loading ? ( +
+ ) : ( +