/** * @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. It * loads a file ONCE per path (never over the user's typing), refuses to save * over a failed or truncated read, asks before discarding edits or overwriting * a file changed on disk, and is a real dialog (focus trap, Escape). * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, X, Save, AlertCircle } from "lucide-react"; import type { CcArtifactType } from "../../lib/api"; import { confirmDialog, confirmDiscard } from "../../lib/confirm"; import { useDialog } from "../../hooks/useDialog"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; /** What a fetcher may answer: the text, or the API's `{text, truncated}`. */ type Fetched = string | { text: string; truncated?: boolean }; const textOf = (r: Fetched) => (typeof r === "string" ? r : r.text); /** Same rule the server applies to an artifact name. */ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; /** Claude Code's own scope union, kept as the default so existing callers * (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope — * its caller instantiates `TScope` as just `"user" | "project"` instead of * casting its own narrower type into this one. */ type DefaultScope = "user" | "project" | "auto-memory"; export type EditorState< TType extends string = CcArtifactType, TScope extends string = DefaultScope, > = | { mode: "create"; type: TType; defaultScope: "user" | "project"; template: string; project?: string; // set for type === "auto-memory" } | { mode: "edit"; type: TType; scope: TScope; name: string; filePath: string; project?: string; // set for type === "auto-memory" } | null; /** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's * AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */ const DEFAULT_NAMELESS_TYPES = ["memory"]; interface EditorModalProps< TType extends string = CcArtifactType, TScope extends string = DefaultScope, > { state: NonNullable>; fetchContent: (filePath: string) => Promise; onClose: () => void; onSave: (args: { type: TType; targetScope: TScope; name: string | undefined; content: string; project?: string; }) => Promise; /** Which `state.type` values are nameless singleton files. Defaults to * Claude Code's own `["memory"]` so existing callers are unaffected. */ namelessTypes?: readonly string[]; /** Refuse the Project target (its folder is the plugin cache, wiped on update). */ projectDisabled?: boolean; } export function EditorModal< TType extends string = CcArtifactType, TScope extends string = DefaultScope, >({ state, fetchContent, onClose, onSave, namelessTypes = DEFAULT_NAMELESS_TYPES, projectDisabled = false, }: EditorModalProps) { const { t } = useTranslation("ccConfig"); const isCreate = state.mode === "create"; const isNameless = namelessTypes.includes(state.type); 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 as "user" | "project") ); const [loading, setLoading] = useState(!isCreate); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // "ok" once an edit's text is in; a failed or truncated load never saves — // Save over a failed load wrote an EMPTY file, over a truncated one a cut file. const [loadState, setLoadState] = useState<"ok" | "failed" | "truncated">( isCreate ? "ok" : "failed" ); // The text as opened; `dirty` and the changed-on-disk check compare to it. const original = useRef(isCreate ? state.template : ""); const panelRef = useRef(null); const titleId = "cc-editor-title"; const dirty = !loading && content !== original.current; useUnsavedGuard(dirty); // Read through a ref: callers pass an inline fetcher, and a dependency on it // reloaded the file on every parent render — overwriting the user's typing. const fetchRef = useRef(fetchContent); fetchRef.current = fetchContent; const editPath = state.mode === "edit" ? state.filePath : null; // `t` through a ref too: a language switch must not re-run the load below. const tRef = useRef(t); tRef.current = t; // For edit mode, fetch the file once per path. useEffect(() => { if (editPath === null) return; let live = true; setLoading(true); fetchRef .current(editPath) .then((r) => { if (!live) return; const text = textOf(r); original.current = text; setContent(text); const cut = typeof r !== "string" && !!r.truncated; setLoadState(cut ? "truncated" : "ok"); if (cut) setError(tRef.current("edit.truncatedReadOnly")); setLoading(false); }) .catch((err: unknown) => { if (!live) return; const msg = err instanceof Error ? err.message : "unknown"; setLoadState("failed"); setError(tRef.current("edit.loadError", { message: msg })); setLoading(false); }); return () => { live = false; }; }, [editPath]); const requestClose = useCallback(async () => { if (dirty && !(await confirmDiscard())) return; onClose(); }, [dirty, onClose]); // Escape closes — but not while typing in the textarea, where it only leaves // the field (an editor's Escape must never cost a draft). useDialog(true, panelRef, () => { const el = document.activeElement; if (el instanceof HTMLTextAreaElement) { el.blur(); return; } void requestClose(); }); const handleSave = useCallback(async () => { setSaving(true); setError(null); try { if (!isNameless && state.mode === "create") { if (!name.trim()) { setError(t("edit.nameRequired")); return; } if (!isAutoMemory && !NAME_RE.test(name)) { setError(t("edit.nameInvalid")); return; } } // The file changed on disk since it was opened (a running session, a // terminal): say so instead of silently overwriting that change. if (state.mode === "edit") { const fresh = await fetchRef .current(state.filePath) .then(textOf) .catch(() => null); // Could not re-read it: say so rather than saving blind. const ask = fresh === null ? { title: t("edit.unverifiedTitle"), message: t("edit.unverifiedBody"), confirmLabel: t("edit.saveAnyway"), } : fresh !== original.current ? { title: t("edit.changedOnDiskTitle"), message: t("edit.changedOnDiskBody"), confirmLabel: t("edit.overwrite"), } : null; if (ask && !(await confirmDialog(ask))) 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 : isNameless ? undefined : createName; await onSave({ type: state.type, // Widening back to TScope: this component is the one place that // legitimately knows about all three scope shapes across both // callers; a Codex caller's TScope ("user" | "project") never // actually receives "auto-memory" since isAutoMemory can only be // true when TType includes "auto-memory" (Claude-only). targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope, name: effectiveName, content, project: state.project, }); original.current = content; } 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, isNameless, 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 (
void requestClose()} >
e.stopPropagation()} >
{titleText}
{isCreate && !isNameless && (
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 ? (
) : (