File size: 4,081 Bytes
bc5920a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01a64bf
 
bc5920a
 
01a64bf
bc5920a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01a64bf
bc5920a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dddaad3
 
bc5920a
 
dddaad3
bc5920a
 
 
01a64bf
bc5920a
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// The code surface for the Files pane: one CodeMirror editor that both reads
// and writes, so viewing and editing can't drift apart in look or behaviour.
//
// CodeMirror and every language mode are LAZY: none of it is in the initial
// bundle, and a language chunk only loads for a file that needs it. Until the
// editor arrives (a tick or two) the plain <pre> below is on screen, so opening
// a file never shows an empty box.
import { useEffect, useMemo, useRef, useState } from 'react';

// ---- lazy module handles -------------------------------------------------
// One dynamic import for the editor core, one per language. Vite splits each
// into its own chunk.
type CM = typeof import('./cm-core');
let corePromise: Promise<CM> | null = null;
const loadCore = () => (corePromise ??= import('./cm-core'));

// Plain, instant fallback — also what a file gets when the editor can't load.
// Line numbers live in their own sticky column so a long line scrolls under
// them rather than pushing them away.
export function PlainCode({ text }: { text: string }) {
  const gutter = useMemo(
    () => Array.from({ length: text.split('\n').length }, (_, i) => i + 1).join('\n'),
    [text],
  );
  return (
    <div className="fv-code">
      <pre className="fv-gutter" aria-hidden>{gutter}</pre>
      <pre className="fv-text">{text}</pre>
    </div>
  );
}

export type CodeViewProps = {
  text: string;
  /** File name — picks the language mode. */
  name: string;
  editable: boolean;
  /** Called on every edit with the new document. */
  onChange?: (next: string) => void;
  /** Cmd/Ctrl-S inside the editor. */
  onSave?: () => void;
  /** Light/dark, so the editor's own chrome matches the app. */
  theme: 'light' | 'dark';
  /** Soft-wrap long lines instead of scrolling sideways. */
  wrap?: boolean;
};

export default function CodeView({ text, name, editable, onChange, onSave, theme, wrap = false }: CodeViewProps) {
  const host = useRef<HTMLDivElement | null>(null);
  const view = useRef<any>(null);
  const core = useRef<CM | null>(null);
  const [ready, setReady] = useState(false);

  // Keep the latest callbacks reachable from CodeMirror's long-lived listeners
  // without tearing the editor down on every render.
  const cbs = useRef({ onChange, onSave });
  cbs.current = { onChange, onSave };

  // Mount once per file. `text` is deliberately NOT a dependency: re-running
  // this on every keystroke would rebuild the editor under the cursor. External
  // text changes (a different file, a revert) come through the effect below.
  useEffect(() => {
    let alive = true;
    (async () => {
      const mod = await loadCore();
      if (!alive || !host.current) return;
      core.current = mod;
      view.current = mod.createEditor({
        parent: host.current,
        doc: text,
        name,
        editable,
        wrap,
        theme,
        onChange: (v: string) => cbs.current.onChange?.(v),
        onSave: () => cbs.current.onSave?.(),
      });
      setReady(true);
    })().catch(() => { /* the <pre> fallback stays on screen */ });
    return () => {
      alive = false;
      view.current?.destroy();
      view.current = null;
      setReady(false);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [name]);

  // Adopt text that changed from the outside (file reloaded, edits discarded)
  // while leaving the user's own typing alone. setDoc marks the dispatch as ours
  // so it doesn't come back through onChange as a fresh edit.
  useEffect(() => {
    const v = view.current;
    if (v) core.current?.setDoc(v, text);
  }, [text]);

  useEffect(() => { if (view.current) core.current?.setEditable(view.current, editable); }, [editable]);
  useEffect(() => { if (view.current) core.current?.setWrap(view.current, wrap); }, [wrap]);
  useEffect(() => { if (view.current) core.current?.setTheme(view.current, theme); }, [theme]);

  return (
    <div className="fv-code-host">
      <div ref={host} className="fv-cm" hidden={!ready} />
      {!ready && <PlainCode text={text} />}
    </div>
  );
}