@unoverse-platform/studio 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,784 @@
1
+ /**
2
+ * THE EDITOR: the files behind the definition on screen, with the familiar affordances.
3
+ *
4
+ * Local Studio is where a developer works, so this is an editor rather than a viewer:
5
+ * tabs with a dirty marker, save on Cmd+S, find on Cmd+F, undo history, folding, and
6
+ * diagnostics in the gutter beside the line that caused them.
7
+ *
8
+ * ONE LINTER, and it is not in here. The squiggles come from the host
9
+ * (`/local/lint/draft`), which hands the developer's UNSAVED text to the shared rule
10
+ * library as an overlay. So a squiggle is the same rule that will block a save, block a
11
+ * publish, and fail CI. Nothing in this file decides whether anything is valid.
12
+ *
13
+ * IT RENDERS NOTHING WITHOUT THE HOST CAPABILITY. `hasSourceAccess` is false on the Canvas
14
+ * host, whose universe serves definitions and not the files behind them. A shared screen
15
+ * must never branch on which app it runs in, so the host answers instead (the same seam
16
+ * `hasAuth` uses).
17
+ *
18
+ * Installed assets open read-only. An edit to one is invisible to every other machine and
19
+ * lost on the next install, and the design system is what keeps the token contract stable.
20
+ * The verdict is the host's, never a rule reimplemented here.
21
+ *
22
+ * See docs/architecture/LOCAL_STUDIO.md §Authoring in Studio.
23
+ */
24
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25
+ import { Compartment, EditorState } from "@codemirror/state";
26
+ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
27
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
28
+ // NOT `defaultHighlightStyle`: it is tuned for a LIGHT background, and over the dark theme
29
+ // it won the cascade for property names, rendering them dark blue on near-black. The dark
30
+ // theme's OWN highlight style is applied instead, explicitly, because dropping the light
31
+ // one on its own left every token the same colour.
32
+ import { bracketMatching, foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from "@codemirror/language";
33
+ import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
34
+ import { linter, lintGutter, lintKeymap, type Diagnostic } from "@codemirror/lint";
35
+ import { search, searchKeymap, highlightSelectionMatches } from "@codemirror/search";
36
+ import { json } from "@codemirror/lang-json";
37
+ import { yaml } from "@codemirror/lang-yaml";
38
+ import { markdown } from "@codemirror/lang-markdown";
39
+ import { javascript } from "@codemirror/lang-javascript";
40
+ import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark";
41
+ import { authedFetch, hasSourceAccess } from "studio-host";
42
+ import { hideSchemaLine, unoverseCompletion, type Vocabulary } from "./source/completion";
43
+ import { parseDocument } from "yaml";
44
+ import { FileTree, stateOf, type SourceFile } from "./source/FileTree";
45
+ import { locateSubtree, nodeRanges, pathAtOffset, stampPaths } from "./source/inspect";
46
+ import { clearHighlight, highlightRendered } from "./source/highlight";
47
+ import { buildSourceMap, deepElementFromPoint, stampedAncestor } from "./source/picker";
48
+
49
+ export type { SourceFile };
50
+
51
+ /** A finding, exactly as the shared rule library reports it. */
52
+ export interface LintFinding {
53
+ level?: string;
54
+ line?: number;
55
+ msg?: string;
56
+ }
57
+
58
+ function languageFor(rel: string) {
59
+ if (/\.ya?ml$/.test(rel)) return [yaml()];
60
+ if (/\.jsonc?$/.test(rel)) return [json()];
61
+ if (/\.md$/.test(rel)) return [markdown()];
62
+ if (/\.m?js$/.test(rel)) return [javascript()];
63
+ return [];
64
+ }
65
+
66
+ function readOnlyNote(reason?: string): string {
67
+ if (reason === "installed") return "installed, read-only";
68
+ if (reason === "outside-project") return "outside project, read-only";
69
+ return "read-only";
70
+ }
71
+
72
+ /** `layouts/course-detail.yaml` reads as its filename, with the folder as the quiet part. */
73
+ function splitRel(rel: string): { dir: string; name: string } {
74
+ const i = rel.lastIndexOf("/");
75
+ return i === -1 ? { dir: "", name: rel } : { dir: rel.slice(0, i + 1), name: rel.slice(i + 1) };
76
+ }
77
+
78
+ export function SourceView({
79
+ uri,
80
+ path,
81
+ accent,
82
+ layoutOrder,
83
+ activeState,
84
+ onSaved,
85
+ onPickState,
86
+ }: {
87
+ uri?: string;
88
+ path?: string;
89
+ accent?: string;
90
+ /** The component's faces, in declared order, so layouts list in that order. */
91
+ layoutOrder?: string[];
92
+ /** The face currently on the preview. Its layout file is the one opened. */
93
+ activeState?: string;
94
+ /** Fired after a save the host accepted, so the preview can refresh itself. */
95
+ onSaved?: () => void;
96
+ /** Opening a layout switches the preview to that face. */
97
+ onPickState?: (state: string) => void;
98
+ }) {
99
+ const [files, setFiles] = useState<SourceFile[]>([]);
100
+ const [active, setActive] = useState<SourceFile | null>(null);
101
+ const [loaded, setLoaded] = useState("");
102
+ const [draft, setDraft] = useState("");
103
+ const [error, setError] = useState<string | null>(null);
104
+ const [saving, setSaving] = useState(false);
105
+ const [problems, setProblems] = useState<LintFinding[]>([]);
106
+ const [savedAt, setSavedAt] = useState<number | null>(null);
107
+ const [host, setHost] = useState<HTMLDivElement | null>(null);
108
+ const [vocab, setVocab] = useState<Vocabulary | null>(null);
109
+ /** The composed tree the renderer received, carrying each node's `$id`. */
110
+ const [composedRoot, setComposedRoot] = useState<unknown>(null);
111
+ /** How many elements the cursor's node rendered. Null when there is nothing to say. */
112
+ const [matches, setMatches] = useState<number | null>(null);
113
+ const viewRef = useRef<EditorView | null>(null);
114
+ /** This pane, so the picker can tell its own chrome from the preview. */
115
+ const rootRef = useRef<HTMLDivElement | null>(null);
116
+ /** The last view the editor followed, so it follows a CHANGE rather than every render. */
117
+ const syncedState = useRef<string | undefined>(undefined);
118
+ const dirty = draft !== loaded;
119
+ /**
120
+ * The vocabulary, read through a ref by the completion source.
121
+ *
122
+ * The editor is built once per file and its extensions close over whatever exists at that
123
+ * moment. Reading `vocab` directly meant an editor built before `/local/vocabulary`
124
+ * answered captured null and offered NOTHING for the life of that file.
125
+ */
126
+ const vocabRef = useRef<Vocabulary | null>(null);
127
+ useEffect(() => {
128
+ vocabRef.current = vocab;
129
+ }, [vocab]);
130
+ /** Armed by the header button: hover the preview, click to land on the line. */
131
+ const [picking, setPicking] = useState(false);
132
+ /** The closed primitive set, which is what tells a node apart from a prop. */
133
+ const primitives = useMemo(() => new Set(vocab?.primitives ?? []), [vocab]);
134
+
135
+ const target = uri ? `uri=${encodeURIComponent(uri)}` : path ? `path=${encodeURIComponent(path)}` : null;
136
+
137
+ /**
138
+ * The composed tree, stamped with the same `$id`s the renderer drew. Re-read after every
139
+ * save, because the tree it describes has just changed.
140
+ */
141
+ useEffect(() => {
142
+ if (!uri) return;
143
+ let live = true;
144
+ authedFetch(`/local/definition?uri=${encodeURIComponent(uri)}`)
145
+ .then((r) => r.json())
146
+ .then((d) => {
147
+ if (!live) return;
148
+ const root = (d as { root?: unknown }).root;
149
+ stampPaths(root, "root");
150
+ setComposedRoot(root ?? null);
151
+ })
152
+ .catch(() => live && setComposedRoot(null));
153
+ return () => {
154
+ live = false;
155
+ };
156
+ }, [uri, savedAt]);
157
+
158
+ /**
159
+ * A CHANGED VIEW INVALIDATES THE BOX.
160
+ *
161
+ * The overlay is drawn from an element's rect at one instant. Switch face or step and
162
+ * that element is re-rendered, moved, or gone, leaving a box sitting over whatever now
163
+ * occupies the space, which is worse than showing nothing. Clearing on a view change
164
+ * costs one click to ask again.
165
+ */
166
+ useEffect(() => {
167
+ clearHighlight();
168
+ setMatches(null);
169
+ }, [activeState, savedAt]);
170
+
171
+ // The boxes are absolutely positioned, so they go stale the moment anything moves.
172
+ useEffect(() => {
173
+ const drop = () => {
174
+ clearHighlight();
175
+ setMatches(null);
176
+ };
177
+ window.addEventListener("resize", drop);
178
+ window.addEventListener("scroll", drop, true);
179
+ return () => {
180
+ window.removeEventListener("resize", drop);
181
+ window.removeEventListener("scroll", drop, true);
182
+ clearHighlight();
183
+ };
184
+ }, []);
185
+
186
+ // The closed vocabulary, once. It changes only when the project's tokens do.
187
+ useEffect(() => {
188
+ if (!hasSourceAccess) return;
189
+ let live = true;
190
+ authedFetch("/local/vocabulary")
191
+ .then((r) => r.json())
192
+ .then((v) => live && setVocab(v))
193
+ .catch(() => {
194
+ /* completion is an affordance; the editor works without it */
195
+ });
196
+ return () => {
197
+ live = false;
198
+ };
199
+ }, []);
200
+
201
+ useEffect(() => {
202
+ if (!hasSourceAccess || !target) return;
203
+ let live = true;
204
+ setError(null);
205
+ authedFetch(`/local/source?${target}`)
206
+ .then((r) => r.json())
207
+ .then((d) => {
208
+ if (!live) return;
209
+ const list: SourceFile[] = d.files ?? [];
210
+ setFiles(list);
211
+ setActive((prev) => list.find((f) => f.path === prev?.path) ?? list[0] ?? null);
212
+ if (!list.length) setError(d.error ?? "no source files found");
213
+ })
214
+ .catch((e) => live && setError(String(e?.message ?? e)));
215
+ return () => {
216
+ live = false;
217
+ };
218
+ }, [target]);
219
+
220
+ useEffect(() => {
221
+ setProblems([]);
222
+ setSavedAt(null);
223
+ if (!active) {
224
+ setLoaded("");
225
+ setDraft("");
226
+ return;
227
+ }
228
+ let live = true;
229
+ authedFetch(`/local/source/file?path=${encodeURIComponent(active.path)}`)
230
+ .then((r) => r.json())
231
+ .then((d) => {
232
+ if (!live) return;
233
+ setLoaded(d.text ?? "");
234
+ setDraft(d.text ?? "");
235
+ })
236
+ .catch((e) => live && setError(String(e?.message ?? e)));
237
+ return () => {
238
+ live = false;
239
+ };
240
+ }, [active]);
241
+
242
+ /**
243
+ * DIAGNOSTICS from the one linter, over the wire.
244
+ *
245
+ * CodeMirror debounces this for us and cancels in flight work when typing resumes. A
246
+ * finding the library reports without a line (structural rules report the file, not the
247
+ * position) is anchored to line 1 and is also listed in the problems strip, so it is
248
+ * never invisible.
249
+ */
250
+ const lintSource = useCallback(
251
+ async (view: EditorView): Promise<Diagnostic[]> => {
252
+ const file = active;
253
+ if (!file) return [];
254
+ const text = view.state.doc.toString();
255
+ try {
256
+ const res = await authedFetch("/local/lint/draft", {
257
+ method: "POST",
258
+ headers: { "content-type": "application/json" },
259
+ body: JSON.stringify({ path: file.path, text }),
260
+ });
261
+ const d = await res.json();
262
+ const findings: LintFinding[] = d.findings ?? [];
263
+ setProblems(findings);
264
+ return findings.map((f) => {
265
+ const line = view.state.doc.line(Math.min(Math.max(f.line ?? 1, 1), view.state.doc.lines));
266
+ return {
267
+ from: line.from,
268
+ to: line.to,
269
+ severity: f.level === "error" ? "error" : f.level === "warn" ? "warning" : "info",
270
+ message: f.msg ?? "",
271
+ } as Diagnostic;
272
+ });
273
+ } catch {
274
+ return [];
275
+ }
276
+ },
277
+ [active],
278
+ );
279
+
280
+ // The lint source closes over the active file, so it is swapped through a compartment
281
+ // rather than by rebuilding the editor, which would cost the cursor on every file load.
282
+ const lintCompartment = useMemo(() => new Compartment(), []);
283
+ useEffect(() => {
284
+ viewRef.current?.dispatch({
285
+ effects: lintCompartment.reconfigure(linter(lintSource, { delay: 400 })),
286
+ });
287
+ }, [lintSource, lintCompartment]);
288
+
289
+ const saveRef = useRef<() => void>(() => {});
290
+
291
+ useEffect(() => {
292
+ if (!host || !active) return;
293
+ const editable = active.writable;
294
+ const view = new EditorView({
295
+ parent: host,
296
+ state: EditorState.create({
297
+ doc: loaded,
298
+ extensions: [
299
+ lineNumbers(),
300
+ foldGutter(),
301
+ lintGutter(),
302
+ highlightActiveLine(),
303
+ highlightActiveLineGutter(),
304
+ highlightSelectionMatches(),
305
+ bracketMatching(),
306
+ closeBrackets(),
307
+ indentUnit.of(" "),
308
+ history(),
309
+ search({ top: true }),
310
+ hideSchemaLine,
311
+ ...languageFor(active.rel),
312
+ oneDarkHighlightStyle ? syntaxHighlighting(oneDarkHighlightStyle) : [],
313
+ autocompletion({ override: [unoverseCompletion(() => vocabRef.current)], activateOnTyping: true }),
314
+ lintCompartment.of(linter(lintSource, { delay: 400 })),
315
+ keymap.of([
316
+ // Save first, so it wins over any default binding on the same chord.
317
+ { key: "Mod-s", preventDefault: true, run: () => (saveRef.current(), true) },
318
+ ...closeBracketsKeymap,
319
+ ...completionKeymap,
320
+ ...searchKeymap,
321
+ ...lintKeymap,
322
+ ...foldKeymap,
323
+ ...historyKeymap,
324
+ ...defaultKeymap,
325
+ indentWithTab,
326
+ ]),
327
+ EditorState.readOnly.of(!editable),
328
+ EditorView.editable.of(editable),
329
+ EditorView.updateListener.of((u) => {
330
+ if (u.docChanged) setDraft(u.state.doc.toString());
331
+ // The cursor moving is what asks "what is this?", so inspect on selection too.
332
+ if (u.docChanged || u.selectionSet) inspectAt(u.state.doc.toString(), u.state.selection.main.head);
333
+ }),
334
+ // WRAP. Definitions carry prose in `default:` values (a course description, a
335
+ // markdown body), and truncating those at the pane edge hides the thing being
336
+ // edited. Wrapping also stops a long line widening the pane.
337
+ EditorView.lineWrapping,
338
+ EditorView.theme(
339
+ {
340
+ "&": { fontSize: "12px", height: "100%" },
341
+ ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", lineHeight: "1.45" },
342
+ ".cm-gutters": { fontSize: "11px", background: "transparent", border: "none" },
343
+ ".cm-content": { paddingBottom: "40vh" },
344
+ // A wrapped continuation lines up under the text, not under the gutter.
345
+ ".cm-line": { paddingLeft: "2px" },
346
+
347
+ /**
348
+ * THE COMPLETION POPUP, on the dark editor.
349
+ *
350
+ * CodeMirror styles the tooltip from its base theme, and the light variant
351
+ * put near-white text on white: every option except the selected one was
352
+ * unreadable. The `dark` flag below is half the fix (it selects the dark
353
+ * base styles); these rules are the other half, because the popup carries
354
+ * three kinds of text and each needs its own weight.
355
+ */
356
+ ".cm-tooltip.cm-tooltip-autocomplete": {
357
+ background: "#1f262f",
358
+ border: "1px solid #384151",
359
+ borderRadius: "4px",
360
+ boxShadow: "0 8px 24px rgba(0,0,0,.45)",
361
+ },
362
+ ".cm-tooltip.cm-tooltip-autocomplete > ul": {
363
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
364
+ fontSize: "12px",
365
+ maxHeight: "16em",
366
+ },
367
+ ".cm-tooltip.cm-tooltip-autocomplete > ul > li": { padding: "2px 8px", color: "#c9d3e0" },
368
+ ".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": { background: "#2f4f7a", color: "#ffffff" },
369
+ // The typed prefix, so you can see WHY an option matched.
370
+ ".cm-completionMatchedText": { color: "#7dd3a0", textDecoration: "none", fontWeight: "600" },
371
+ // "colour token", "primitive", "space step": the quiet half of the line.
372
+ ".cm-completionDetail": { color: "#7d8899", fontStyle: "normal", marginLeft: "0.75em" },
373
+ ".cm-completionIcon": { color: "#7d8899", opacity: "0.8", marginRight: "0.4em" },
374
+ },
375
+ // Without this the editor reports itself as LIGHT, and the popup above was the
376
+ // visible consequence.
377
+ { dark: true },
378
+ ),
379
+ ],
380
+ }),
381
+ });
382
+ viewRef.current = view;
383
+ return () => {
384
+ view.destroy();
385
+ viewRef.current = null;
386
+ };
387
+ // `loaded` seeds the document; `draft` deliberately does not, or every keystroke would
388
+ // rebuild the editor and drop the cursor.
389
+ // `loaded` is deliberately absent: it changes on every save, and rebuilding here
390
+ // destroyed the editor mid-session, taking the undo history and the cursor with it.
391
+ // An externally changed file is applied as a transaction below instead.
392
+ }, [host, active?.path, lintCompartment]);
393
+
394
+ /**
395
+ * Text that arrived from disk, applied WITHOUT rebuilding.
396
+ *
397
+ * Skipped when the document already matches, which is the case after our own save, so a
398
+ * save is invisible to the editor rather than a reset.
399
+ */
400
+ useEffect(() => {
401
+ const view = viewRef.current;
402
+ if (!view || view.state.doc.toString() === loaded) return;
403
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: loaded } });
404
+ }, [loaded]);
405
+
406
+ /**
407
+ * INSPECT: box the rendered element the cursor is inside.
408
+ *
409
+ * Three hops, each of which can legitimately come up empty:
410
+ * the cursor's node WITHIN this file (YAML ranges from the parser)
411
+ * where this file sits in the COMPOSED tree (a layout is included into a face)
412
+ * the elements that node rendered (none if hidden, many under an `Each`)
413
+ *
414
+ * Anything that fails clears the overlay rather than guessing, and `matches` reports what
415
+ * happened so the editor can say "not currently rendered" instead of looking broken.
416
+ */
417
+ const inspectAt = useCallback(
418
+ (text: string, offset: number) => {
419
+ if (!composedRoot || !primitives.size || !active?.rel.match(/\.(ya?ml)$/)) {
420
+ clearHighlight();
421
+ setMatches(null);
422
+ return;
423
+ }
424
+ try {
425
+ const doc = parseDocument(text);
426
+ const inFile = pathAtOffset(nodeRanges(doc, primitives), offset);
427
+ if (!inFile) {
428
+ clearHighlight();
429
+ setMatches(null);
430
+ return;
431
+ }
432
+ /**
433
+ * TWO FILE SHAPES, TWO WAYS IN.
434
+ *
435
+ * An ENVELOPE (`coursecard.yaml`) holds its tree under `root:`, and that tree IS
436
+ * the composed root, so the file path maps by dropping one level. A PARTIAL (a
437
+ * layout) is a bare node included somewhere inside the composed tree, so it has to
438
+ * be found first. Treating everything as a partial meant an envelope never matched
439
+ * and every line in it read as "not rendered".
440
+ */
441
+ const js = doc.toJS() as Record<string, unknown> | null;
442
+ const isEnvelope = !!js && typeof js === "object" && !js.type && !!js.root;
443
+ let id: string | null = null;
444
+ if (isEnvelope) {
445
+ id = inFile === "root" ? null : `root${inFile.slice("root.root".length)}`;
446
+ } else {
447
+ const prefix = locateSubtree(composedRoot, js);
448
+ id = prefix ? `${prefix}${inFile.slice("root".length)}` : null;
449
+ }
450
+ setMatches(highlightRendered(id, accent));
451
+ } catch {
452
+ // Mid-keystroke the document often does not parse. Nothing to show, no error.
453
+ clearHighlight();
454
+ setMatches(null);
455
+ }
456
+ },
457
+ [composedRoot, active, accent, primitives],
458
+ );
459
+
460
+ /**
461
+ * THE ELEMENT PICKER, armed from the header.
462
+ *
463
+ * Hover the preview to box what is under the pointer, click to open the file that drew it
464
+ * with the cursor on the node. The map from a rendered element back to a line is built
465
+ * once when the picker is armed: a component has a dozen files and a hover has a frame.
466
+ */
467
+ useEffect(() => {
468
+ if (!picking) return;
469
+ let map: Map<string, { path: string; from: number }> | null = null;
470
+
471
+ void Promise.all(
472
+ files
473
+ .filter((f) => /\.ya?ml$/.test(f.rel))
474
+ .map(async (f) => ({
475
+ ...f,
476
+ text: await authedFetch(`/local/source/file?path=${encodeURIComponent(f.path)}`)
477
+ .then((r) => r.json())
478
+ .then((d) => d.text ?? ""),
479
+ })),
480
+ ).then((withText) => {
481
+ map = buildSourceMap(withText, composedRoot, primitives, parseDocument, nodeRanges, locateSubtree, (d: any) => d.toJS());
482
+ });
483
+
484
+ const onMove = (e: MouseEvent) => {
485
+ // Over this pane there is nothing to inspect, so drop the box rather than leaving the
486
+ // last one hanging while the pointer is somewhere unrelated.
487
+ if (rootRef.current?.contains(e.target as Node)) return void highlightRendered(null, accent);
488
+ const hit = stampedAncestor(deepElementFromPoint(e.clientX, e.clientY));
489
+ highlightRendered(hit?.id ?? null, accent);
490
+ };
491
+ const onClick = (e: MouseEvent) => {
492
+ /**
493
+ * SWALLOW CLICKS ON THE PREVIEW, AND ONLY THOSE.
494
+ *
495
+ * While picking, a click on the preview is a question about the UI, never a use of
496
+ * it, so it must not reach the component's own `action`. But swallowing every click
497
+ * in the window also ate the one on the pick button, so the picker could not be
498
+ * turned off, and the file rail stopped responding. Clicks inside this pane are the
499
+ * editor's own and pass straight through.
500
+ */
501
+ if (rootRef.current?.contains(e.target as Node)) return;
502
+ e.preventDefault();
503
+ e.stopPropagation();
504
+ const hit = stampedAncestor(deepElementFromPoint(e.clientX, e.clientY));
505
+ const found = hit && map?.get(hit.id);
506
+ if (!found) return;
507
+ setPicking(false);
508
+ clearHighlight();
509
+ const file = files.find((f) => f.path === found.path);
510
+ /**
511
+ * TELL THE HOST WHAT WAS PICKED, so a connected agent can answer "make this bigger".
512
+ * The pointing happens in the UI and the instruction arrives in a terminal, so the
513
+ * selection has to live somewhere both can reach.
514
+ */
515
+ void authedFetch("/local/selection", {
516
+ method: "POST",
517
+ headers: { "content-type": "application/json" },
518
+ body: JSON.stringify({ uri, nodeId: hit.id, file: found.path, offset: found.from, tag: hit.el.tagName.toLowerCase() }),
519
+ }).catch(() => {
520
+ /* an agent may not be connected; picking still works */
521
+ });
522
+ if (file && file.path !== active?.path) setActive(file);
523
+ // The document may be about to swap, so place the cursor once it has.
524
+ setTimeout(() => {
525
+ const view = viewRef.current;
526
+ if (!view) return;
527
+ const pos = Math.min(found.from, view.state.doc.length);
528
+ // CENTRED, not merely visible: `scrollIntoView: true` stops as soon as the line is
529
+ // on screen, which lands it against whichever edge it came from and hides the
530
+ // surrounding lines that give it meaning.
531
+ view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
532
+ view.focus();
533
+ }, file && file.path !== active?.path ? 250 : 0);
534
+ };
535
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicking(false);
536
+
537
+ window.addEventListener("mousemove", onMove, true);
538
+ window.addEventListener("click", onClick, true);
539
+ window.addEventListener("keydown", onKey);
540
+ return () => {
541
+ window.removeEventListener("mousemove", onMove, true);
542
+ window.removeEventListener("click", onClick, true);
543
+ window.removeEventListener("keydown", onKey);
544
+ clearHighlight();
545
+ };
546
+ }, [picking, files, composedRoot, primitives, accent, active?.path]);
547
+
548
+ /** Cmd+Shift+C arms the picker, the same chord DevTools uses for the same job. */
549
+ useEffect(() => {
550
+ const onKey = (e: KeyboardEvent) => {
551
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "c") {
552
+ e.preventDefault();
553
+ setPicking((p) => !p);
554
+ }
555
+ };
556
+ window.addEventListener("keydown", onKey);
557
+ return () => window.removeEventListener("keydown", onKey);
558
+ }, []);
559
+
560
+ const copy = useCallback(() => {
561
+ void navigator.clipboard?.writeText(draft);
562
+ }, [draft]);
563
+
564
+ const save = useCallback(async () => {
565
+ if (!active?.writable || !dirty || saving) return;
566
+ setSaving(true);
567
+ setError(null);
568
+ try {
569
+ const res = await authedFetch("/local/source/file", {
570
+ method: "POST",
571
+ headers: { "content-type": "application/json" },
572
+ body: JSON.stringify({ path: active.path, text: draft }),
573
+ });
574
+ const d = await res.json();
575
+ if (d.saved) {
576
+ setLoaded(draft);
577
+ setSavedAt(Date.now());
578
+ setProblems(d.findings ?? []);
579
+ onSaved?.();
580
+ } else {
581
+ setError(d.error === "lint errors" ? "not saved: fix the errors below" : (d.error ?? "save refused"));
582
+ setProblems(d.findings ?? []);
583
+ }
584
+ } catch (e) {
585
+ setError(String((e as Error)?.message ?? e));
586
+ } finally {
587
+ setSaving(false);
588
+ }
589
+ }, [active, draft, dirty, saving, onSaved]);
590
+
591
+ // The editor's Mod-s binding calls through this, so the keymap never goes stale.
592
+ useEffect(() => {
593
+ saveRef.current = () => void save();
594
+ }, [save]);
595
+
596
+ /** Put the cursor on a finding's line, the way a problems list is expected to behave. */
597
+ /**
598
+ * LIVE PREVIEW: a clean edit saves itself, and the preview follows.
599
+ *
600
+ * Editing a token and then reaching for Cmd+S to find out what it looks like is a round
601
+ * trip the developer should not have to make. The preview renders what is ON DISK, so
602
+ * "render it" and "save it" are the same act here.
603
+ *
604
+ * ONLY WHEN THE LINTER IS HAPPY. `problems` is the shared library's verdict on the draft,
605
+ * so a file mid-keystroke or carrying an error is never written. That also means this
606
+ * adds no new write path: it presses the same lint-gated save the button does, which can
607
+ * still refuse, in which case nothing reaches disk.
608
+ */
609
+ useEffect(() => {
610
+ if (!dirty || saving || !active?.writable) return;
611
+ if (problems.some((p) => p.level === "error")) return;
612
+ const t = setTimeout(() => void save(), 700);
613
+ return () => clearTimeout(t);
614
+ }, [dirty, saving, active, problems, save]);
615
+
616
+ const goToLine = useCallback((line?: number) => {
617
+ const view = viewRef.current;
618
+ if (!view || !line) return;
619
+ const l = view.state.doc.line(Math.min(Math.max(line, 1), view.state.doc.lines));
620
+ view.dispatch({ selection: { anchor: l.from }, effects: EditorView.scrollIntoView(l.from, { y: "center" }) });
621
+ view.focus();
622
+ }, []);
623
+
624
+ if (!hasSourceAccess) return null;
625
+
626
+ const errorCount = problems.filter((p) => p.level === "error").length;
627
+ const warnCount = problems.filter((p) => p.level === "warn").length;
628
+
629
+ const dirtyPaths = useMemo(
630
+ () => (active && dirty ? new Set([active.path]) : new Set<string>()),
631
+ [active, dirty],
632
+ );
633
+
634
+ /**
635
+ * OPENING A LAYOUT SWITCHES THE FACE.
636
+ *
637
+ * A layout's filename is its state name, so opening `layouts/course-detail.yaml` and
638
+ * putting the preview into `course-detail` are the same intent. Reading a face's
639
+ * definition beside a preview of a DIFFERENT face is a trap the rail would otherwise
640
+ * set for you every time.
641
+ */
642
+ const pickFile = useCallback(
643
+ (f: SourceFile) => {
644
+ setActive(f);
645
+ if (f.rel.startsWith("layouts/")) onPickState?.(stateOf(f.rel));
646
+ },
647
+ [onPickState],
648
+ );
649
+
650
+ /**
651
+ * And the reverse: change the face anywhere else and its layout opens here.
652
+ *
653
+ * IT MUST NOT OVERRIDE A FILE ALREADY SHOWING THAT FACE. `activeState` is the FACE only,
654
+ * and a face's steps live in their own layouts (`course` → `course-detail`). Matching on
655
+ * the face alone pulled the editor off `course-detail.yaml` and back to `course.yaml` the
656
+ * instant you opened it, so the rail could not open a step at all. A layout belonging to
657
+ * the active face, at any depth, is left alone.
658
+ *
659
+ * Skipped while the file has unsaved edits: swapping a document out from under a draft
660
+ * loses work.
661
+ */
662
+ useEffect(() => {
663
+ if (!activeState || dirty) return;
664
+ /**
665
+ * ONLY WHEN THE VIEW ACTUALLY CHANGES.
666
+ *
667
+ * Reacting to the open file as well meant every click in the rail was immediately
668
+ * undone: open `manifest.yaml`, the effect re-ran, and it pulled you back to the live
669
+ * layout. You could never read anything but the layout being rendered. The state is
670
+ * remembered instead, so the editor follows a NEW view and otherwise leaves you alone.
671
+ */
672
+ if (syncedState.current === activeState) return;
673
+ syncedState.current = activeState;
674
+ const layout = (name: string) => files.find((f) => f.rel.startsWith("layouts/") && stateOf(f.rel) === name);
675
+ /**
676
+ * `activeState` is the DEEPEST view (`course-detail`), so try it first. A step with no
677
+ * layout of its own is drawn by its face's file, so fall back along the name:
678
+ * `course-detail` → `course`. Anything with no layout at all leaves the editor alone.
679
+ */
680
+ let match = layout(activeState);
681
+ for (let name = activeState; !match && name.includes("-"); ) {
682
+ name = name.slice(0, name.lastIndexOf("-"));
683
+ match = layout(name);
684
+ }
685
+ if (match) setActive(match);
686
+ }, [activeState, files, dirty]);
687
+
688
+ return (
689
+ // min-w-0 all the way down. A flex child defaults to min-width:auto, so a long line in
690
+ // the editor pushes the whole split wider than its container and squeezes the preview
691
+ // off the screen instead of scrolling inside its own pane.
692
+ <div ref={rootRef} className="flex min-h-0 min-w-0 flex-1 bg-[#1c222b] text-gray-200">
693
+ <FileTree
694
+ files={files}
695
+ active={active}
696
+ dirtyPaths={dirtyPaths}
697
+ accent={accent}
698
+ layoutOrder={layoutOrder}
699
+ liveState={activeState}
700
+ onPick={pickFile}
701
+ />
702
+
703
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
704
+ {/* One slim header: where you are on the left, what you can do on the right. */}
705
+ <div className="flex shrink-0 items-center gap-2 border-b border-gray-800 px-2 py-1 text-[10px]">
706
+ <span className="min-w-0 flex-1 truncate text-gray-500" title={active?.path}>
707
+ {active ? splitRel(active.rel).dir : ""}
708
+ <span className="text-gray-300">{active ? splitRel(active.rel).name : ""}</span>
709
+ </span>
710
+ {/* What the cursor is pointing at on the right. Zero is worth saying: the node is
711
+ real but not drawn right now (an inactive Switch case, a false visibleWhen). */}
712
+ {matches !== null && (
713
+ <span className="shrink-0" style={{ color: matches > 0 ? (accent ?? "#3b82f6") : "#7d8899" }}>
714
+ {matches === 0 ? "not rendered" : matches === 1 ? "1 element" : `${matches} elements`}
715
+ </span>
716
+ )}
717
+ {errorCount > 0 && <span className="shrink-0 text-red-400">{errorCount} error{errorCount > 1 ? "s" : ""}</span>}
718
+ {errorCount === 0 && warnCount > 0 && <span className="shrink-0 text-amber-400">{warnCount} warning{warnCount > 1 ? "s" : ""}</span>}
719
+ {errorCount === 0 && warnCount === 0 && dirty && <span className="shrink-0 text-amber-400">edited</span>}
720
+ {errorCount === 0 && warnCount === 0 && !dirty && savedAt && <span className="shrink-0 text-emerald-400">saved</span>}
721
+ {active && !active.writable && (
722
+ <span className="shrink-0 rounded border border-gray-700 px-1.5 py-0.5 text-gray-500">{readOnlyNote(active.reason)}</span>
723
+ )}
724
+ {/* PICK: hover the preview to box an element, click to land on its line. */}
725
+ <button
726
+ onClick={() => setPicking((p) => !p)}
727
+ aria-pressed={picking}
728
+ title="Pick an element in the preview (Cmd+Shift+C, Esc to cancel)"
729
+ className="flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5"
730
+ style={{
731
+ borderColor: picking ? "transparent" : "#374151",
732
+ background: picking ? (accent ?? "#3b82f6") : "transparent",
733
+ color: picking ? "#fff" : "#9ca3af",
734
+ }}
735
+ >
736
+ {/* The DevTools glyph: a pointer inside a frame. */}
737
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
738
+ <path d="M2 5.5V3a1 1 0 0 1 1-1h2.5M14 5.5V3a1 1 0 0 0-1-1h-2.5M2 10.5V13a1 1 0 0 0 1 1h2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
739
+ <path d="m8 7 5 5-2 .4L9.8 15 8 7Z" fill="currentColor" />
740
+ </svg>
741
+ pick
742
+ </button>
743
+ <button onClick={copy} className="shrink-0 rounded border border-gray-700 px-1.5 py-0.5 text-gray-400 hover:bg-white/5">
744
+ copy
745
+ </button>
746
+ {active?.writable && (
747
+ <button
748
+ onClick={() => void save()}
749
+ disabled={!dirty || saving}
750
+ title="Save (Cmd+S). Lint runs; a failing save is rolled back."
751
+ className="shrink-0 rounded border px-1.5 py-0.5 disabled:opacity-40"
752
+ style={{ borderColor: dirty ? (accent ?? "#10b981") : "#374151", color: dirty ? (accent ?? "#10b981") : "#9ca3af" }}
753
+ >
754
+ {saving ? "saving" : "save"}
755
+ </button>
756
+ )}
757
+ </div>
758
+
759
+ {error && <div className="shrink-0 px-2 py-1 text-[11px] text-amber-400">{error}</div>}
760
+
761
+ <div ref={setHost} className="min-h-0 min-w-0 flex-1 overflow-hidden" />
762
+
763
+ {/* PROBLEMS. Findings the library reports without a position live only here, so the
764
+ strip is the complete list and the gutter is the located subset of it. */}
765
+ {problems.length > 0 && (
766
+ <div className="max-h-24 shrink-0 overflow-auto border-t border-gray-800 bg-black/25">
767
+ {problems.map((p, i) => (
768
+ <button
769
+ key={i}
770
+ onClick={() => goToLine(p.line)}
771
+ className="block w-full px-2 py-0.5 text-left text-[11px] hover:bg-white/5"
772
+ >
773
+ <span className={p.level === "error" ? "text-red-400" : "text-amber-400"}>
774
+ {p.line ? p.line : "file"}
775
+ </span>
776
+ <span className="ml-2 text-gray-300">{p.msg}</span>
777
+ </button>
778
+ ))}
779
+ </div>
780
+ )}
781
+ </div>
782
+ </div>
783
+ );
784
+ }