yamlover 0.3.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/bin/yamlover.js +202 -0
  4. package/dist/server.js +4681 -0
  5. package/index.html +12 -0
  6. package/package.json +72 -0
  7. package/src/client/App.tsx +372 -0
  8. package/src/client/NodeView.tsx +422 -0
  9. package/src/client/TaskStrip.tsx +34 -0
  10. package/src/client/Tree.tsx +97 -0
  11. package/src/client/api.ts +186 -0
  12. package/src/client/icons.ts +91 -0
  13. package/src/client/links.tsx +108 -0
  14. package/src/client/live.ts +42 -0
  15. package/src/client/main.tsx +10 -0
  16. package/src/client/paste-html.ts +228 -0
  17. package/src/client/paste-links.ts +42 -0
  18. package/src/client/paths.ts +109 -0
  19. package/src/client/render.tsx +326 -0
  20. package/src/client/renderers/annotate.tsx +507 -0
  21. package/src/client/renderers/asciidoc.tsx +35 -0
  22. package/src/client/renderers/chapter.tsx +138 -0
  23. package/src/client/renderers/csv.tsx +233 -0
  24. package/src/client/renderers/decoded.tsx +72 -0
  25. package/src/client/renderers/djvu.tsx +97 -0
  26. package/src/client/renderers/doc.tsx +40 -0
  27. package/src/client/renderers/docx.tsx +49 -0
  28. package/src/client/renderers/epub.tsx +147 -0
  29. package/src/client/renderers/explorer.tsx +209 -0
  30. package/src/client/renderers/fb2.tsx +149 -0
  31. package/src/client/renderers/headings.ts +69 -0
  32. package/src/client/renderers/heic.tsx +23 -0
  33. package/src/client/renderers/imagemap.tsx +157 -0
  34. package/src/client/renderers/kml.ts +46 -0
  35. package/src/client/renderers/latex.tsx +36 -0
  36. package/src/client/renderers/map.tsx +205 -0
  37. package/src/client/renderers/marklower.tsx +119 -0
  38. package/src/client/renderers/markup.tsx +64 -0
  39. package/src/client/renderers/media.tsx +19 -0
  40. package/src/client/renderers/panzoom.ts +101 -0
  41. package/src/client/renderers/pdf.tsx +176 -0
  42. package/src/client/renderers/plaintext.tsx +120 -0
  43. package/src/client/renderers/plantuml.tsx +82 -0
  44. package/src/client/renderers/psd.tsx +25 -0
  45. package/src/client/renderers/registry.tsx +389 -0
  46. package/src/client/renderers/rtf.tsx +210 -0
  47. package/src/client/renderers/spreadsheet.tsx +105 -0
  48. package/src/client/renderers/tag.tsx +113 -0
  49. package/src/client/renderers/text.tsx +41 -0
  50. package/src/client/renderers/tiff.tsx +33 -0
  51. package/src/client/styles.css +1115 -0
  52. package/src/client/vendor/README.md +30 -0
  53. package/src/client/vendor/djvu.js +15535 -0
  54. package/src/client/vite-env.d.ts +31 -0
  55. package/src/server/api.ts +147 -0
  56. package/src/server/engine-api.ts +1442 -0
  57. package/src/server/gitignore.ts +81 -0
  58. package/src/server/node-kind.ts +48 -0
  59. package/src/server/tasks.ts +83 -0
  60. package/src/server/yamlover.ts +1133 -0
@@ -0,0 +1,422 @@
1
+ import { Fragment, memo, useEffect, useReducer, useState } from "react";
2
+ import { fetchNode, fetchSchema, NodeJson, pasteFile, pasteRich, pasteText, PasteResult } from "./api";
3
+ import { arxivPdf, tweetUrl, fetchTweetText } from "./paste-links";
4
+ import { countImages, htmlToRich, resolveImages, RichDraft } from "./paste-html";
5
+ import { getRenderer } from "./renderers/registry";
6
+ import { AnnotatedMaterial, useAnnotations } from "./renderers/annotate";
7
+
8
+ // Renderers whose output is prose — they get the TEXT annotation layer (drag-select → palette →
9
+ // highlight). Image and map renderers carry their OWN region annotation layer (drag-rectangle →
10
+ // palette), and pdf/djvu render saved region overlays; see annotate.tsx and the UI guide.
11
+ const TEXT_MATERIALS = new Set(["chapter", "markdown", "asciidoc", "marklower"]);
12
+ import { TagBadges, splitTagRefs } from "./renderers/tag";
13
+ import { Render } from "./render";
14
+ import { strToSegs } from "./paths";
15
+
16
+ // The data representations, in order: `yamlover` (the default, YAML-family syntax), `json5p`
17
+ // (JSON-family syntax), then `yamlover/schema` (the instance schema, YAML-family). Each is one
18
+ // level deep with nested containers as links. A node with a renderer also offers a tab keyed by
19
+ // the renderer's *name* (e.g. `chapter`) — the rendered view, and that node's default.
20
+ export type Format = "yamlover" | "json5p" | "yamlover/schema" | (string & {});
21
+ export const FORMATS: Format[] = ["yamlover", "json5p", "yamlover/schema"];
22
+ export const DEFAULT_FORMAT: Format = "yamlover";
23
+
24
+ const isStandard = (f: Format) => (FORMATS as string[]).includes(f);
25
+ const isSchema = (f: Format) => f.endsWith("schema");
26
+ // Serialization syntax: json5p renders JSON-family; yamlover (+ its schema) renders YAML-family.
27
+ const syntaxOf = (f: Format): "yaml" | "json" => (f === "json5p" ? "json" : "yaml");
28
+
29
+ /** The representation actually shown: the requested `format` if it is a standard
30
+ * view or this node's renderer name; otherwise the node's default (its renderer's
31
+ * view, else `yaml-schema`). Guards a stale renderer-name format (e.g. a
32
+ * hand-edited URL, or one carried onto a node with no such renderer). */
33
+ function effectiveFormat(format: Format, renderer: { name: string } | null): Format {
34
+ if (isStandard(format)) return format;
35
+ if (renderer && format === renderer.name) return format;
36
+ return renderer ? renderer.name : DEFAULT_FORMAT;
37
+ }
38
+
39
+ /** A node's bare name: its last path segment (a decoded key or `[index]`), or ""
40
+ * for the root. Used as the document title when the node has no schema title. */
41
+ function nodeName(path: string): string {
42
+ const segs = strToSegs(path);
43
+ const last = segs[segs.length - 1];
44
+ if (last === undefined) return "";
45
+ return typeof last === "number" ? `[${last}]` : last;
46
+ }
47
+
48
+ interface Props {
49
+ path: string;
50
+ format: Format;
51
+ /** Bumped by App when a server-pushed change touches this node — re-fetch it. */
52
+ refreshSignal?: number;
53
+ onFormat: (f: Format) => void;
54
+ onNavigate: (path: string) => void;
55
+ /** Called after a paste/upload changed the tree at `path`, so the TOC branch can refresh. */
56
+ onContentChanged?: (path: string) => void;
57
+ /** Called after a file was uploaded onto a directory MEMBER, to open the new file. */
58
+ onOpenUploaded?: (result: PasteResult) => void;
59
+ }
60
+
61
+ const MIME_EXT: Record<string, string> = {
62
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp",
63
+ "image/svg+xml": "svg", "image/bmp": "bmp", "image/tiff": "tiff", "application/pdf": "pdf",
64
+ };
65
+
66
+ /** The files carried by a clipboard paste (a file-manager copy fills `files`; a copied image
67
+ * arrives as an `items` entry of kind "file"). */
68
+ function clipboardFiles(e: ClipboardEvent): File[] {
69
+ const dt = e.clipboardData;
70
+ if (!dt) return [];
71
+ if (dt.files && dt.files.length) return Array.from(dt.files);
72
+ const out: File[] = [];
73
+ for (const it of Array.from(dt.items || [])) {
74
+ if (it.kind === "file") { const f = it.getAsFile(); if (f) out.push(f); }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** A name for a pasted file — its own, else a synthesized one from its MIME type. */
80
+ function pastedName(f: File): string {
81
+ if (f.name) return f.name;
82
+ return `pasted.${MIME_EXT[f.type] || "bin"}`;
83
+ }
84
+
85
+ /** Read a File as base64 (the bare payload, no data-URL prefix). */
86
+ function fileToBase64(f: File): Promise<string> {
87
+ return new Promise((resolve, reject) => {
88
+ const r = new FileReader();
89
+ r.onload = () => resolve(String(r.result).split(",")[1] || "");
90
+ r.onerror = () => reject(new Error("could not read file"));
91
+ r.readAsDataURL(f);
92
+ });
93
+ }
94
+
95
+ /** The RHS pane: one node shown in the selected representation. Every
96
+ * representation is one level deep; nested objects/arrays appear as
97
+ * `{ N keys }` / `[ M elements ]` hyperlinks you click to descend. */
98
+ // memo: App re-renders on every SSE task-progress frame (background indexing/hashing — several
99
+ // per second); the node pane — incl. a mounted PDF with all its pages — must only re-render
100
+ // when its own props change, or scrolling a long document JANKS while a task runs.
101
+ export const NodeView = memo(function NodeView({ path, format, refreshSignal = 0, onFormat, onNavigate, onContentChanged, onOpenUploaded }: Props) {
102
+ const [node, setNode] = useState<NodeJson | null>(null); // header + data value
103
+ const [schema, setSchema] = useState<unknown>(null);
104
+ const [bin, setBin] = useState<unknown>(null); // base64 payload for a binary leaf
105
+ const [error, setError] = useState<string | null>(null);
106
+ const [reloadKey, setReloadKey] = useState(0); // bumped to re-fetch the node after a paste
107
+ const [pasteMsg, setPasteMsg] = useState<string | null>(null); // transient upload status
108
+ const [dragging, setDragging] = useState(false); // a file is being dragged over the window
109
+ // Bumped by a renderer's bar `config` control (e.g. the markup width) after it writes a URL
110
+ // param, so the whole node view re-renders and the rendered body picks up the new setting.
111
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
112
+ // The tags APPLIED to this material via annotations — they join the header badges (the
113
+ // upstream relation is the annotation node, so the hop to its tag comes from /api/annotations).
114
+ // Unconditional: hooks must run on every render, including the loading ones.
115
+ const anns = useAnnotations(path);
116
+
117
+ useEffect(() => {
118
+ setError(null);
119
+ setNode(null);
120
+ let cancelled = false;
121
+ // A first (one-level) fetch settles the node's (type, format); a renderer that
122
+ // needs deeper value (e.g. a chapter, depth 2: arrays one level, elements the
123
+ // next) gets a second fetch at that depth before its value is shown.
124
+ fetchNode(path)
125
+ .then((n) => {
126
+ if (cancelled) return;
127
+ const d = getRenderer(n)?.depth ?? 1;
128
+ if (d > 1) fetchNode(path, d).then((dn) => !cancelled && setNode(dn)).catch((e) => !cancelled && setError(e.message));
129
+ else setNode(n);
130
+ })
131
+ .catch((e) => !cancelled && setError(e.message));
132
+ return () => {
133
+ cancelled = true;
134
+ };
135
+ }, [path, reloadKey, refreshSignal]);
136
+
137
+ // Paste-to-upload: pasting clipboard file(s) uploads them — the server drops the file into this
138
+ // directory (a directory page), appends it as a chapter chunk (a chapter page), or drops it into
139
+ // the nearest enclosing directory (any other page, i.e. a MEMBER of a directory). Plain TEXT is
140
+ // pasted too: a chapter gains it as a new chunk; anywhere else it becomes a new chapter
141
+ // .yamlover file in the nearest directory. Skipped while the focus is in a text field (so
142
+ // annotation notes still paste text normally).
143
+ useEffect(() => {
144
+ if (!node) return;
145
+ const onPaste = (e: ClipboardEvent) => {
146
+ const t = e.target as HTMLElement | null;
147
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
148
+ const files = clipboardFiles(e);
149
+ if (files.length > 0) {
150
+ e.preventDefault();
151
+ void uploadFiles(files);
152
+ return;
153
+ }
154
+ // a web-page selection: its text/html flavor keeps the images and headings that the
155
+ // plain-text flavor drops — paste those as image chunks and subchapters
156
+ const html = e.clipboardData?.getData("text/html") ?? "";
157
+ const rich = html ? htmlToRich(html) : null;
158
+ if (rich) {
159
+ e.preventDefault();
160
+ void uploadRich(rich);
161
+ return;
162
+ }
163
+ const text = e.clipboardData?.getData("text/plain") ?? "";
164
+ if (!text.trim()) return;
165
+ e.preventDefault();
166
+ const arxiv = arxivPdf(text);
167
+ if (arxiv) {
168
+ void uploadRemotePdf(arxiv);
169
+ return;
170
+ }
171
+ const tweet = tweetUrl(text);
172
+ if (tweet) {
173
+ void uploadTweet(tweet);
174
+ return;
175
+ }
176
+ void uploadText(text);
177
+ };
178
+ document.addEventListener("paste", onPaste);
179
+ return () => document.removeEventListener("paste", onPaste);
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [node, path]);
182
+
183
+ // Drag-and-drop upload: dropping file(s) anywhere on the page uploads them by the SAME rules as
184
+ // paste (server decides: into this directory, as a chapter chunk, or into the enclosing folder).
185
+ // Listeners are document-wide so a drop is caught everywhere (and the browser's "open the file"
186
+ // default is suppressed); a `depth` counter keeps the overlay steady as the cursor crosses nested
187
+ // elements.
188
+ useEffect(() => {
189
+ if (!node) return;
190
+ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes("Files");
191
+ let depth = 0;
192
+ const onEnter = (e: DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); depth++; setDragging(true); };
193
+ const onOver = (e: DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; };
194
+ const onLeave = () => { depth = Math.max(0, depth - 1); if (depth === 0) setDragging(false); };
195
+ const onDrop = (e: DragEvent) => {
196
+ if (!hasFiles(e)) return;
197
+ e.preventDefault();
198
+ depth = 0;
199
+ setDragging(false);
200
+ const files = Array.from(e.dataTransfer?.files || []);
201
+ if (files.length) void uploadFiles(files);
202
+ };
203
+ document.addEventListener("dragenter", onEnter);
204
+ document.addEventListener("dragover", onOver);
205
+ document.addEventListener("dragleave", onLeave);
206
+ document.addEventListener("drop", onDrop);
207
+ return () => {
208
+ document.removeEventListener("dragenter", onEnter);
209
+ document.removeEventListener("dragover", onOver);
210
+ document.removeEventListener("dragleave", onLeave);
211
+ document.removeEventListener("drop", onDrop);
212
+ };
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ }, [node, path]);
215
+
216
+ const uploadFiles = async (files: File[]) => {
217
+ try {
218
+ setPasteMsg(`uploading ${files.length} file${files.length > 1 ? "s" : ""}…`);
219
+ let last: PasteResult | null = null;
220
+ for (const f of files) {
221
+ const b64 = await fileToBase64(f);
222
+ last = await pasteFile(path, pastedName(f), b64);
223
+ }
224
+ setPasteMsg(files.length > 1 ? `uploaded ${files.length} files` : "uploaded");
225
+ window.setTimeout(() => setPasteMsg(null), 1500);
226
+ if (last?.open) {
227
+ // a member page: the file went to the enclosing directory — open it (App refreshes the TOC
228
+ // branch and navigates to the new file in its renderer view).
229
+ onOpenUploaded?.(last);
230
+ } else {
231
+ // a directory or chapter page: refresh in place so the new file / chunk shows.
232
+ setReloadKey((k) => k + 1);
233
+ onContentChanged?.(path);
234
+ }
235
+ } catch (err) {
236
+ setPasteMsg("paste failed: " + (err as Error).message);
237
+ window.setTimeout(() => setPasteMsg(null), 4000);
238
+ }
239
+ };
240
+
241
+ // A pasted arXiv link: download the paper's PDF in the browser, then hand it to the normal
242
+ // file-paste flow (chapter → pointer chunk, directory → child, member → open).
243
+ const uploadRemotePdf = async ({ url, name }: { url: string; name: string }) => {
244
+ try {
245
+ setPasteMsg(`downloading ${name}…`);
246
+ const resp = await fetch(url);
247
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
248
+ const blob = await resp.blob();
249
+ await uploadFiles([new File([blob], name, { type: "application/pdf" })]);
250
+ } catch (err) {
251
+ setPasteMsg("download failed: " + (err as Error).message);
252
+ window.setTimeout(() => setPasteMsg(null), 4000);
253
+ }
254
+ };
255
+
256
+ // A rich (HTML) paste: download its images in the browser, then send ONE structured payload —
257
+ // a chapter appends the chunks/subchapters; a directory gains a new chapter (directory-backed
258
+ // when images are present, so they live inside it).
259
+ const uploadRich = async (draft: RichDraft) => {
260
+ try {
261
+ const n = countImages(draft);
262
+ if (n) setPasteMsg(`downloading ${n} image${n > 1 ? "s" : ""}…`);
263
+ const rich = await resolveImages(draft);
264
+ setPasteMsg("pasting…");
265
+ const result = await pasteRich(path, rich);
266
+ setPasteMsg(result.chapter ? "chunks added" : "chapter created");
267
+ window.setTimeout(() => setPasteMsg(null), 1500);
268
+ if (result.open) {
269
+ onOpenUploaded?.(result);
270
+ } else {
271
+ setReloadKey((k) => k + 1);
272
+ onContentChanged?.(path);
273
+ }
274
+ } catch (err) {
275
+ setPasteMsg("paste failed: " + (err as Error).message);
276
+ window.setTimeout(() => setPasteMsg(null), 4000);
277
+ }
278
+ };
279
+
280
+ // A pasted tweet link: fetch the FULL message via X's public oEmbed (your Telegram-style
281
+ // preview, but the whole text) and paste it as TEXT — chapter chunk or new chapter file.
282
+ const uploadTweet = async (statusUrl: string) => {
283
+ try {
284
+ setPasteMsg("fetching tweet…");
285
+ await uploadText(await fetchTweetText(statusUrl));
286
+ } catch (err) {
287
+ setPasteMsg("tweet fetch failed: " + (err as Error).message);
288
+ window.setTimeout(() => setPasteMsg(null), 4000);
289
+ }
290
+ };
291
+
292
+ // Paste TEXT by the same navigation rules: a chapter page gains a chunk (refresh in place); a
293
+ // member page gets a sibling chapter file the App then opens; a directory page refreshes.
294
+ const uploadText = async (text: string) => {
295
+ try {
296
+ setPasteMsg("pasting text…");
297
+ const result = await pasteText(path, text);
298
+ setPasteMsg(result.chapter ? "chunk added" : "chapter created");
299
+ window.setTimeout(() => setPasteMsg(null), 1500);
300
+ if (result.open) {
301
+ onOpenUploaded?.(result);
302
+ } else {
303
+ setReloadKey((k) => k + 1);
304
+ onContentChanged?.(path);
305
+ }
306
+ } catch (err) {
307
+ setPasteMsg("paste failed: " + (err as Error).message);
308
+ window.setTimeout(() => setPasteMsg(null), 4000);
309
+ }
310
+ };
311
+
312
+ // The browser tab reflects where you are: the node's schema title if it has one,
313
+ // else its bare name (the last path segment), falling back to the app name at the
314
+ // (titleless) root. Re-set whenever the node settles.
315
+ useEffect(() => {
316
+ if (!node) return;
317
+ document.title = node.title?.trim() || nodeName(path) || "yamlover";
318
+ }, [node, path]);
319
+
320
+ useEffect(() => {
321
+ setSchema(null);
322
+ setBin(null);
323
+ if (!node) return;
324
+ const r = getRenderer(node);
325
+ const eff = effectiveFormat(format, r);
326
+ if (r && eff === r.name) return; // the rendered view reads node.value (already fetched)
327
+ if (isSchema(eff)) {
328
+ fetchSchema(path).then(setSchema).catch((e) => setError(e.message));
329
+ } else if (node.type === "binary") {
330
+ fetchNode(path, undefined, { binary: true }).then((n) => setBin(n.value)).catch((e) => setError(e.message));
331
+ }
332
+ }, [format, path, node]);
333
+
334
+ if (error) return <div className="error">{error}</div>;
335
+ if (!node) return <div className="loading">…</div>;
336
+
337
+ const renderer = getRenderer(node);
338
+ // The renderer adds its own tab (its name), which is this node's default; the
339
+ // standard representations follow.
340
+ const tabs: Format[] = renderer ? [renderer.name, ...FORMATS] : FORMATS;
341
+ const effective = effectiveFormat(format, renderer);
342
+ const showRendered = renderer != null && effective === renderer.name;
343
+
344
+ let content: unknown;
345
+ let ready: boolean;
346
+ if (showRendered) {
347
+ content = node.value;
348
+ ready = true;
349
+ } else if (isSchema(effective)) {
350
+ content = schema;
351
+ ready = schema != null;
352
+ } else if (node.type === "binary") {
353
+ content = bin;
354
+ ready = bin != null;
355
+ } else {
356
+ content = node.value;
357
+ ready = true;
358
+ }
359
+
360
+ // Tag references (rel edges to x-yamlover-tag nodes) show as badges on every
361
+ // representation, JOINED by the tags applied via annotations (deduped by path);
362
+ // the remaining relations stay in the data-view panel.
363
+ const { tags: relTags, rest } = splitTagRefs(node.relations);
364
+ const tags = [...relTags];
365
+ for (const a of anns) {
366
+ if (a.tag && !tags.some((t) => t.path === a.tag!.path)) {
367
+ tags.push({ path: a.tag.path, label: a.tag.name, color: a.tag.color });
368
+ }
369
+ }
370
+
371
+ return (
372
+ <div className="nodeview">
373
+ {pasteMsg && <div className="paste-toast">{pasteMsg}</div>}
374
+ {dragging && <div className="drop-overlay">Drop file to upload</div>}
375
+ <div className="nodehead">
376
+ <div className="nodemeta">
377
+ <span className="tag">{node.type}</span>
378
+ {node.concrete && <span className="tag dim">{node.concrete}</span>}
379
+ {/* the tags this node is filed under, inline among the chips */}
380
+ <TagBadges tags={tags} onNavigate={onNavigate} />
381
+ </div>
382
+ {/* the representation tabs dock LEFT, after the chips, set off by a separator; a
383
+ renderer's own bar control (e.g. markup width) sits right after its button */}
384
+ <span className="bar-sep" aria-hidden="true">|</span>
385
+ <div className="tabs">
386
+ {tabs.map((f) => (
387
+ <Fragment key={f}>
388
+ <button className={"tab" + (effective === f ? " active" : "")} onClick={() => onFormat(f)}>
389
+ {f}
390
+ </button>
391
+ {showRendered && renderer && f === renderer.name && renderer.config?.(rerender)}
392
+ </Fragment>
393
+ ))}
394
+ </div>
395
+ </div>
396
+
397
+ {/* a renderer presents the node's own title/description; the default view
398
+ shows the description here as a subtitle above the value */}
399
+ {!showRendered && node.description && <p className="nodedesc">{node.description}</p>}
400
+
401
+ {showRendered ? (
402
+ TEXT_MATERIALS.has(renderer!.name) ? (
403
+ <AnnotatedMaterial path={path}>{renderer!.render(node, onNavigate)}</AnnotatedMaterial>
404
+ ) : (
405
+ renderer!.render(node, onNavigate)
406
+ )
407
+ ) : (
408
+ <pre className="code">
409
+ {/* data views lead with the relations panel (reverse members / `..`),
410
+ an <hr/>, then the value; schema views embed rel inline already */}
411
+ {!isSchema(effective) && Object.keys(rest).length > 0 && (
412
+ <>
413
+ <Render value={rest} syntax={syntaxOf(effective)} onNavigate={onNavigate} />
414
+ <hr className="reldiv" />
415
+ </>
416
+ )}
417
+ {ready ? <Render value={content} syntax={syntaxOf(effective)} onNavigate={onNavigate} /> : "…"}
418
+ </pre>
419
+ )}
420
+ </div>
421
+ );
422
+ });
@@ -0,0 +1,34 @@
1
+ import { TaskInfo } from "./api";
2
+
3
+ /** The topbar's long-running-task indicator: one chip per task (label, counts, a slim
4
+ * progress bar — percent when the total is known, an indeterminate sweep otherwise).
5
+ * Finished tasks linger briefly (App prunes them) so completion is visible; a failed
6
+ * task shows red with its error in the tooltip. */
7
+ export function TaskStrip({ tasks }: { tasks: TaskInfo[] }) {
8
+ if (!tasks.length) return null;
9
+ return (
10
+ <div className="task-strip">
11
+ {tasks.map((t) => {
12
+ const { done, total, message } = t.progress;
13
+ const pct = t.state === "done" ? 100 : total ? Math.min(100, Math.floor((done / total) * 100)) : null;
14
+ const counts = total ? `${done}/${total}` : t.state === "running" && done > 0 ? String(done) : "";
15
+ return (
16
+ <span key={t.id} className={`task-chip ${t.state}`} title={t.error ?? message ?? t.label}>
17
+ <span className="task-label">
18
+ {t.label}
19
+ {counts && <span className="task-counts"> {counts}</span>}
20
+ {t.state === "error" && " — failed"}
21
+ </span>
22
+ <span className="task-bar">
23
+ {pct !== null ? (
24
+ <span className="task-bar-fill" style={{ width: `${pct}%` }} />
25
+ ) : (
26
+ <span className="task-bar-fill indeterminate" />
27
+ )}
28
+ </span>
29
+ </span>
30
+ );
31
+ })}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,97 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import { TreeNode } from "./api";
3
+ import { tocView } from "./renderers/registry";
4
+ import { typeIcon } from "./icons";
5
+ import { isAncestorPath, displayPath } from "./paths";
6
+
7
+ interface Props {
8
+ node: TreeNode;
9
+ current: string;
10
+ onSelect: (path: string) => void;
11
+ onLoadChildren: (path: string, levels?: number) => Promise<void>;
12
+ depth?: number;
13
+ }
14
+
15
+ /**
16
+ * One TOC branch. Children are labeled by title or key/index. How a node appears
17
+ * is the renderer's call (see `tocView`): by default all of its children, but
18
+ * e.g. a chapter surfaces only its subchapters and keeps prose off the tree. A
19
+ * node is *expandable* when it has such children; past the initially loaded
20
+ * levels, children are fetched on first expand. Selecting a row navigates the RHS.
21
+ */
22
+ // memo: App re-renders on every SSE task-progress frame (background indexing/hashing — several
23
+ // per second); the TOC must only re-render when its own props change.
24
+ export const Tree = memo(function Tree({ node, current, onSelect, onLoadChildren, depth = 0 }: Props) {
25
+ // How this node presents in the TOC: the rows to show, whether it expands, and
26
+ // whether those rows are loaded yet (a renderer may unwrap/filter; default is
27
+ // the node's own children, fetched lazily on first expand).
28
+ const { children: kids, expandable, loaded, loadDepth } = tocView(node);
29
+ // Loaded branches start open (so the first levels show expanded); a branch
30
+ // whose children are not loaded yet starts closed and loads when opened.
31
+ const [open, setOpen] = useState(kids.length > 0);
32
+ const [loading, setLoading] = useState(false);
33
+ const selected = node.path === current;
34
+ const onPath = isAncestorPath(node.path, current); // an ancestor of the selection
35
+ const rowRef = useRef<HTMLDivElement>(null);
36
+
37
+ // Reveal the selection: keep its ancestors open, and scroll it into view.
38
+ useEffect(() => {
39
+ if (onPath) setOpen(true);
40
+ }, [onPath]);
41
+ useEffect(() => {
42
+ if (selected) rowRef.current?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
43
+ }, [selected]);
44
+
45
+ const ti = typeIcon(node.type, node.format, node.concrete);
46
+ // a folder (plain `dir` concrete) shows open vs closed like a normal file manager
47
+ const glyph = open && ti.glyph === "📁" ? "📂" : ti.glyph;
48
+
49
+ const toggle = async () => {
50
+ if (!open && expandable && !loaded) {
51
+ setLoading(true);
52
+ try {
53
+ await onLoadChildren(node.path, loadDepth);
54
+ } finally {
55
+ setLoading(false);
56
+ }
57
+ }
58
+ setOpen((o) => !o);
59
+ };
60
+
61
+ return (
62
+ <div className="tree-branch">
63
+ <div
64
+ ref={rowRef}
65
+ className={"tree-row" + (selected ? " selected" : "")}
66
+ style={{ paddingLeft: depth * 14 + 4 }}
67
+ >
68
+ {expandable ? (
69
+ <button
70
+ className={"toggle" + (open ? " open" : "") + (loading ? " loading" : "")}
71
+ onClick={toggle}
72
+ aria-label={open ? "collapse" : "expand"}
73
+ >
74
+ <span className="chevron">›</span>
75
+ </button>
76
+ ) : (
77
+ <span className="toggle leaf" />
78
+ )}
79
+ <span className={"icon " + ti.cls} title={ti.title}>{glyph}</span>
80
+ <span className="tree-label" onClick={() => onSelect(node.path)} title={`${displayPath(node.path)} (${node.type})`}>
81
+ {node.label}
82
+ </span>
83
+ </div>
84
+ {open &&
85
+ kids.map((c) => (
86
+ <Tree
87
+ key={c.path}
88
+ node={c}
89
+ current={current}
90
+ onSelect={onSelect}
91
+ onLoadChildren={onLoadChildren}
92
+ depth={depth + 1}
93
+ />
94
+ ))}
95
+ </div>
96
+ );
97
+ });