import { useEffect, useMemo, useRef, useState } from "react"; import { EditorView, basicSetup } from "codemirror"; import { EditorState, Compartment } from "@codemirror/state"; import { StreamLanguage } from "@codemirror/language"; import { octave } from "@codemirror/legacy-modes/mode/octave"; import type { SourceFile } from "@plate-lab/core"; export interface CodeViewProps { /** The generated sources */ files: SourceFile[]; /** User edits by file name; a file absent here shows its generated text */ edits: Record; onEditsChange: (edits: Record) => void; /** Tab order; files not listed follow in their original order */ order?: string[]; /** One-line descriptions shown under the tabs */ summaries?: Record; readOnly?: boolean; /** Called with the active file name */ onActiveChange?: (name: string) => void; initialFile?: string; height?: number | string; } /** * Tabbed source view of exactly the `.m` files a run executes. Editing a file * records an edit; the run then uses the edited text, and a modified tab shows * an asterisk with a way back to the generated version. */ export function CodeView(props: CodeViewProps) { const { files, edits, onEditsChange, order, summaries, readOnly, onActiveChange, initialFile, height } = props; const ordered = useMemo(() => { const byName = new Map(files.map((f) => [f.name, f])); const out: SourceFile[] = []; for (const n of order ?? []) if (byName.has(n)) out.push(byName.get(n)!); for (const f of files) if (!out.includes(f)) out.push(f); return out; }, [files, order]); const [active, setActive] = useState(initialFile ?? ordered[0]?.name ?? ""); useEffect(() => { if (!ordered.some((f) => f.name === active) && ordered[0]) setActive(ordered[0].name); }, [ordered, active]); useEffect(() => onActiveChange?.(active), [active, onActiveChange]); const hostRef = useRef(null); const viewRef = useRef(null); const editsRef = useRef(edits); editsRef.current = edits; const filesRef = useRef(files); filesRef.current = files; const activeRef = useRef(active); activeRef.current = active; const onEditsRef = useRef(onEditsChange); onEditsRef.current = onEditsChange; const readOnlyCompartment = useMemo(() => new Compartment(), []); // One editor for the lifetime of the component useEffect(() => { if (!hostRef.current) return; const view = new EditorView({ parent: hostRef.current, state: EditorState.create({ doc: "", extensions: [ basicSetup, StreamLanguage.define(octave), readOnlyCompartment.of(EditorState.readOnly.of(!!readOnly)), EditorView.updateListener.of((u) => { if (!u.docChanged) return; const name = activeRef.current; const text = u.state.doc.toString(); const original = filesRef.current.find((f) => f.name === name)?.source; const next = { ...editsRef.current }; if (text === original) delete next[name]; else next[name] = text; onEditsRef.current(next); }), ], }), }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { viewRef.current?.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(!!readOnly)) }); }, [readOnly, readOnlyCompartment]); // Load the active file's text (edited or generated) into the editor whenever it changes externally const shown = edits[active] ?? files.find((f) => f.name === active)?.source ?? ""; useEffect(() => { const view = viewRef.current; if (!view) return; const current = view.state.doc.toString(); if (current !== shown) { view.dispatch({ changes: { from: 0, to: current.length, insert: shown } }); } }, [shown, active]); const modified = active in edits; const restore = () => { const next = { ...edits }; delete next[active]; onEditsChange(next); }; const restoreAll = () => onEditsChange({}); const nModified = Object.keys(edits).length; return (
{ordered.map((f) => ( ))}
{summaries?.[active] ?? ""} {modified && ( <> edited )} {nModified > 1 && ( )}
); }