import { Loader2Icon, SaveIcon } from "lucide-react"; import { type ComponentType, type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { cn } from "../lib/cn"; import { codeEditorPeers, type CodeEditorLanguage } from "../lib/workbench-peers"; import { useOpenGeniText } from "../messages/runtime"; // --- Lazy CodeMirror surface ---------------------------------------------- // // CodeMirror 6 (via @uiw/react-codemirror) plus the per-language grammars are a // heavy bundle (lezer parsers, the view layer). We keep them OFF the critical // path and out of any SSR bundle by lazy-importing only when a file is actually // EDITED. The read-only viewer stays Pierre's Shiki `File`; this is the // editable complement, mounted on demand. /** The subset of `@uiw/react-codemirror`'s props we drive. */ type ReactCodeMirrorComponent = ComponentType<{ value?: string; height?: string; theme?: "light" | "dark" | "none" | unknown; editable?: boolean; readOnly?: boolean; basicSetup?: boolean | Record; extensions?: unknown[]; onChange?: ((value: string) => void) | undefined; className?: string; style?: CSSProperties; }>; // Resolved lazily on first edit: the editor component + the keymap/Prec helpers // (both re-exported from @uiw/react-codemirror, which re-exports @codemirror/view // and @codemirror/state) + the chosen language extension. type EditorBundle = { Editor: ReactCodeMirrorComponent; saveKeymapExtension: (onSave: () => void) => unknown; languageExtension: unknown | null; }; /** Map a filename to a grammar key (or null for plain text — still fully editable). */ export function languageForPath(path: string): string | null { const name = path.split("/").pop() ?? path; const ext = name.includes(".") ? (name.split(".").pop() ?? "").toLowerCase() : ""; switch (ext) { case "js": case "jsx": case "ts": case "tsx": case "mjs": case "cjs": return "javascript"; case "json": case "jsonc": return "json"; case "py": case "pyi": return "python"; case "md": case "markdown": case "mdx": return "markdown"; case "css": case "scss": case "less": return "css"; case "html": case "htm": case "xml": case "svg": case "vue": return "html"; default: return null; } } export type CodeEditorProps = { /** Workspace-relative path — drives language inference (and the save target upstream). */ path: string; /** The decoded text contents to seed the editor with. */ initialContents: string; /** * Persist the current buffer against the exact contents it was edited from. * The second argument is the immutable compare-and-swap baseline; hosts must * not replace it with a newer remote snapshot or concurrent writes can be * silently overwritten. */ onSave: (contents: string, expectedContents: string) => Promise; /** Explicitly overwrite after a visible expected-content conflict. */ onOverwrite?: ((contents: string) => Promise) | undefined; /** Re-read the selected file after a visible save conflict. */ onReload?: (() => void) | undefined; /** Fired once when the buffer FIRST diverges from its baseline (the first real * keystroke) — the wake-on-edit intent. The host warms the box on this so the * eventual save lands fast; merely opening the file for reading never fires it. */ onEditIntent?: (() => void) | undefined; /** Read-only mode (e.g. a truncated/too-large file shown for reference only). */ readOnly?: boolean | undefined; themeType?: "dark" | "light" | undefined; /** Rendered while the (lazy) CodeMirror bundle loads. */ loading?: ReactNode | undefined; /** Rendered if `@uiw/react-codemirror` is not installed / fails to import. */ fallback?: ReactNode | undefined; className?: string | undefined; }; export type EditorBufferState = { path: string; value: string; baseline: string; }; /** * Reconcile a remote file snapshot without destroying unsaved local work. * * A different path is a new document and always resets. For the same path, a * clean editor follows the remote snapshot while a dirty editor retains both * its local value and the original CAS baseline. The eventual save will then * either succeed against that baseline or surface a typed conflict. */ export function reconcileEditorBuffer( current: EditorBufferState, incoming: { path: string; contents: string }, ): EditorBufferState { if (current.path === incoming.path && current.value !== current.baseline) { return current; } if ( current.path === incoming.path && current.value === incoming.contents && current.baseline === incoming.contents ) { return current; } return { path: incoming.path, value: incoming.contents, baseline: incoming.contents, }; } /** * The EDITABLE single-file pane: CodeMirror 6 with a per-language grammar chosen * from the filename, og-* themed, with dirty tracking and a save path wired to * `Cmd/Ctrl+S` *and* an explicit Save button. The viewer (Pierre `File`) stays * the read-only complement — this is only mounted when the user opts to edit. * * Save semantics: the buffer is "dirty" the moment it diverges from the last * saved baseline; a successful `onSave` clears dirty and re-baselines. A failed * save keeps the buffer dirty (nothing is lost) and surfaces the error inline. * `readOnly` suppresses every mutation path so a truncated/binary file can never * be saved back (which would corrupt it by writing the truncated prefix). */ export function CodeEditor({ path, initialContents, onSave, onOverwrite, onReload, onEditIntent, readOnly = false, themeType = "dark", loading, fallback, className, }: CodeEditorProps) { const msg = useOpenGeniText(); const [failed, setFailed] = useState(false); const [bundle, setBundle] = useState(null); const peerRevision = useSyncExternalStore( codeEditorPeers.subscribe, codeEditorPeers.revision, codeEditorPeers.revision, ); // The live buffer and its compare-and-swap baseline are one state value so a // remote refresh cannot update one without the other. const [buffer, setBuffer] = useState(() => ({ path, value: initialContents, baseline: initialContents, })); const { value, baseline } = buffer; const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [savedTick, setSavedTick] = useState(false); const saveInFlightRef = useRef(false); const dirty = value !== baseline; const saveConflict = saveError !== null && (saveError as Error & { code?: unknown }).code === "file_write_conflict"; // Follow path changes and clean same-file refreshes. Dirty same-file buffers // intentionally ignore remote snapshots: the original baseline must survive // until guarded save, explicit reload, or navigation. const editIntentFiredRef = useRef(false); useEffect(() => { setBuffer((current) => reconcileEditorBuffer(current, { path, contents: initialContents })); }, [path, initialContents]); // A new document is a new edit-intent/error lifecycle. The normal // SandboxFiles host also keys the component by path; this keeps standalone // consumers correct without relying on that implementation detail. useEffect(() => { editIntentFiredRef.current = false; setSaveError(null); setSavedTick(false); }, [path]); // Record a buffer change: the FIRST divergence from the baseline is the edit // intent that warms the box (idempotent — latched per opened file). const noteEdit = useCallback( (next: string) => { setBuffer((current) => ({ ...current, value: next })); setSavedTick(false); if (!editIntentFiredRef.current && next !== baseline) { editIntentFiredRef.current = true; onEditIntent?.(); } }, [baseline, onEditIntent], ); // Resolve the lazy bundle (editor + keymap/Prec helpers + language grammar) // once, re-resolving the grammar when the file's language class changes. const langKey = useMemo(() => languageForPath(path) as CodeEditorLanguage | null, [path]); useEffect(() => { let cancelled = false; void (async () => { try { const peers = await codeEditorPeers.load(); const cmMod = peers.module as { default: ReactCodeMirrorComponent; keymap: { of: (binds: unknown[]) => unknown }; Prec: { highest: (ext: unknown) => unknown }; }; // Language peers are optional. A missing grammar must degrade to a plain // CodeMirror document, not throw the entire editor down to textarea. let languageExtension: unknown | null = null; if (langKey) { try { languageExtension = (await peers.languages?.[langKey]?.()) ?? null; } catch { languageExtension = null; } } if (cancelled) return; // A high-precedence Cmd/Ctrl-S keymap that calls back into the latest // save handler (kept fresh via a ref) and swallows the browser's // "save page" default. const saveKeymapExtension = (run: () => void) => cmMod.Prec.highest( cmMod.keymap.of([ { key: "Mod-s", preventDefault: true, run: () => { run(); return true; }, }, ]), ); setBundle({ Editor: cmMod.default, saveKeymapExtension, languageExtension, }); setFailed(false); } catch { if (!cancelled) setFailed(true); } })(); return () => { cancelled = true; }; }, [langKey, peerRevision]); // Keep the freshest save closure reachable from the (stable) keymap binding. const saveRef = useRef<() => void>(() => {}); const save = useCallback(async () => { if (readOnly || saveInFlightRef.current) return; // Snapshot the buffer at call time so an in-flight edit can't race the write. const snapshot = value; if (snapshot === baseline) return; // nothing to persist saveInFlightRef.current = true; setSaving(true); setSaveError(null); setSavedTick(false); try { await onSave(snapshot, baseline); setBuffer((current) => current.path === path ? { ...current, baseline: snapshot } : current, ); setSavedTick(true); } catch (cause) { setSaveError(cause instanceof Error ? cause : new Error(String(cause))); } finally { saveInFlightRef.current = false; setSaving(false); } }, [readOnly, value, baseline, onSave, path]); const overwrite = useCallback(async () => { if (readOnly || !onOverwrite || saveInFlightRef.current) return; const snapshot = value; if (snapshot === baseline) return; saveInFlightRef.current = true; setSaving(true); setSaveError(null); setSavedTick(false); try { await onOverwrite(snapshot); setBuffer((current) => current.path === path ? { ...current, baseline: snapshot } : current, ); setSavedTick(true); } catch (cause) { setSaveError(cause instanceof Error ? cause : new Error(String(cause))); } finally { saveInFlightRef.current = false; setSaving(false); } }, [readOnly, onOverwrite, value, baseline, path]); saveRef.current = () => { void save(); }; // A brief "Saved" affordance after a successful write, cleared on the next edit. useEffect(() => { if (!savedTick) return; const t = setTimeout(() => setSavedTick(false), 1800); return () => clearTimeout(t); }, [savedTick]); const fileName = path.split("/").filter(Boolean).pop() ?? path; const cmExtensions = useMemo(() => { if (!bundle) return [] as unknown[]; const exts: unknown[] = [bundle.saveKeymapExtension(() => saveRef.current())]; if (bundle.languageExtension) exts.push(bundle.languageExtension); return exts; }, [bundle]); const Editor = bundle?.Editor; const reload = useCallback(() => { setBuffer({ path, value: initialContents, baseline: initialContents }); editIntentFiredRef.current = false; setSaveError(null); setSavedTick(false); onReload?.(); }, [initialContents, onReload, path]); return (
{/* Save bar: dirty indicator + status + the explicit Save button. */}
{fileName} {dirty && !readOnly ? " •" : ""}
{saveError && !saveConflict && ( {saveError.message} )} {!saveError && savedTick && !dirty && ( {msg("codeEditor.saved2")} )} {readOnly ? ( {msg("codeEditor.readOnly2")} ) : !saveConflict ? ( ) : null}
{saveConflict ? (
{msg("codeEditor.fileChangedOnMachine")} {" "} {msg("codeEditor.reloadingDiscardsYourEditsOverwriting")}
{onReload ? ( ) : null} {onOverwrite ? ( ) : null}
) : null} {/* The editor surface. */}
{failed ? ( (fallback ?? ( void save()} /> )) ) : Editor ? ( ) : ( (loading ?? ) )}
); } /** og-* themed CSS-var overrides handed to CodeMirror's container. */ const editorVars = { "--og-cm-bg": "var(--og-color-bg)", fontFamily: "var(--og-font-mono)", } as CSSProperties; function PlainTextarea({ value, readOnly, onChange, onSave, }: { value: string; readOnly: boolean; onChange: (value: string) => void; onSave: () => void; }) { const msg = useOpenGeniText(); return (