1import { useEffect, useMemo, useRef, useState } from "react";
2import { EditorView, basicSetup } from "codemirror";
3import { EditorState, Compartment } from "@codemirror/state";
4import { StreamLanguage } from "@codemirror/language";
5import { octave } from "@codemirror/legacy-modes/mode/octave";
6import type { SourceFile } from "@plate-lab/core";
8export interface CodeViewProps {
9 /** The generated sources */
10 files: SourceFile[];
11 /** User edits by file name; a file absent here shows its generated text */
12 edits: Record<string, string>;
13 onEditsChange: (edits: Record<string, string>) => void;
14 /** Tab order; files not listed follow in their original order */
15 order?: string[];
16 /** One-line descriptions shown under the tabs */
17 summaries?: Record<string, string>;
18 readOnly?: boolean;
19 /** Called with the active file name */
20 onActiveChange?: (name: string) => void;
21 initialFile?: string;
22 height?: number | string;
23}
25/**
26 * Tabbed source view of exactly the `.m` files a run executes. Editing a file
27 * records an edit; the run then uses the edited text, and a modified tab shows
28 * an asterisk with a way back to the generated version.
29 */
30export function CodeView(props: CodeViewProps) {
31 const { files, edits, onEditsChange, order, summaries, readOnly, onActiveChange, initialFile, height } = props;
32 const ordered = useMemo(() => {
33 const byName = new Map(files.map((f) => [f.name, f]));
34 const out: SourceFile[] = [];
35 for (const n of order ?? []) if (byName.has(n)) out.push(byName.get(n)!);
36 for (const f of files) if (!out.includes(f)) out.push(f);
37 return out;
38 }, [files, order]);
40 const [active, setActive] = useState(initialFile ?? ordered[0]?.name ?? "");
41 useEffect(() => {
42 if (!ordered.some((f) => f.name === active) && ordered[0]) setActive(ordered[0].name);
43 }, [ordered, active]);
44 useEffect(() => onActiveChange?.(active), [active, onActiveChange]);
46 const hostRef = useRef<HTMLDivElement>(null);
47 const viewRef = useRef<EditorView | null>(null);
48 const editsRef = useRef(edits);
49 editsRef.current = edits;
50 const filesRef = useRef(files);
51 filesRef.current = files;
52 const activeRef = useRef(active);
53 activeRef.current = active;
54 const onEditsRef = useRef(onEditsChange);
55 onEditsRef.current = onEditsChange;
56 const readOnlyCompartment = useMemo(() => new Compartment(), []);
58 // One editor for the lifetime of the component
59 useEffect(() => {
60 if (!hostRef.current) return;
61 const view = new EditorView({
62 parent: hostRef.current,
63 state: EditorState.create({
64 doc: "",
65 extensions: [
66 basicSetup,
67 StreamLanguage.define(octave),
68 readOnlyCompartment.of(EditorState.readOnly.of(!!readOnly)),
69 EditorView.updateListener.of((u) => {
70 if (!u.docChanged) return;
71 const name = activeRef.current;
72 const text = u.state.doc.toString();
73 const original = filesRef.current.find((f) => f.name === name)?.source;
74 const next = { ...editsRef.current };
75 if (text === original) delete next[name];
76 else next[name] = text;
77 onEditsRef.current(next);
78 }),
79 ],
80 }),
81 });
82 viewRef.current = view;
83 return () => {
84 view.destroy();
85 viewRef.current = null;
86 };
87 // eslint-disable-next-line react-hooks/exhaustive-deps
88 }, []);
90 useEffect(() => {
91 viewRef.current?.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(!!readOnly)) });
92 }, [readOnly, readOnlyCompartment]);
94 // Load the active file's text (edited or generated) into the editor whenever it changes externally
95 const shown = edits[active] ?? files.find((f) => f.name === active)?.source ?? "";
96 useEffect(() => {
97 const view = viewRef.current;
98 if (!view) return;
99 const current = view.state.doc.toString();
100 if (current !== shown) {
101 view.dispatch({ changes: { from: 0, to: current.length, insert: shown } });
102 }
103 }, [shown, active]);
105 const modified = active in edits;
106 const restore = () => {
107 const next = { ...edits };
108 delete next[active];
109 onEditsChange(next);
110 };
111 const restoreAll = () => onEditsChange({});
112 const nModified = Object.keys(edits).length;
114 return (
115 <div className="pl-code" style={{ height }}>
116 <div className="pl-code-tabs" role="tablist">
117 {ordered.map((f) => (
118 <button
119 key={f.name}
120 role="tab"
121 aria-selected={f.name === active}
122 className={`pl-code-tab${f.name === active ? " pl-active" : ""}${f.name in edits ? " pl-modified" : ""}`}
123 onClick={() => setActive(f.name)}
124 title={summaries?.[f.name]}
125 >
126 {f.name}
127 </button>
128 ))}
129 </div>
130 <div className="pl-code-toolbar">
131 <span>{summaries?.[active] ?? ""}</span>
132 <span style={{ flex: 1 }} />
133 {modified && (
134 <>
135 <span className="pl-badge pl-badge-warn">edited</span>
136 <button className="pl-btn" onClick={restore}>
137 Restore this file
138 </button>
139 </>
140 )}
141 {nModified > 1 && (
142 <button className="pl-btn" onClick={restoreAll}>
143 Restore all ({nModified})
144 </button>
145 )}
146 </div>
147 <div className="pl-code-editor" ref={hostRef} />
148 </div>
149 );
150}