pi-weave 0.1.6 → 0.1.8

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 (96) hide show
  1. package/README.md +114 -37
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +102 -19
  24. package/src/pi/viewer/tui/bodyStore.ts +98 -0
  25. package/src/pi/viewer/tui/branding.ts +154 -0
  26. package/src/pi/viewer/tui/explorer.ts +20 -31
  27. package/src/pi/viewer/tui/model.ts +46 -667
  28. package/src/pi/viewer/tui/openNote.ts +11 -0
  29. package/src/pi/viewer/tui/run.ts +13 -4
  30. package/src/pi/viewer/tui/surface/base.ts +182 -0
  31. package/src/pi/viewer/tui/surface/detail.ts +315 -0
  32. package/src/pi/viewer/tui/surface/explore.ts +206 -0
  33. package/src/pi/viewer/tui/surface/focus.ts +139 -0
  34. package/src/pi/viewer/tui/surface/health.ts +102 -0
  35. package/src/pi/viewer/tui/workspace.ts +381 -0
  36. package/src/pi/viewer/tui/workspaceRoot.ts +530 -0
  37. package/src/pi/viewer/web/run.ts +331 -0
  38. package/src/web/client/api.dom.ts +40 -0
  39. package/src/web/client/api.ts +472 -0
  40. package/src/web/client/bootstrap.ts +58 -0
  41. package/src/web/client/context/context.model.ts +313 -0
  42. package/src/web/client/dist/app.js +751 -0
  43. package/src/web/client/graph/Graph.tsx +158 -0
  44. package/src/web/client/graph/column.model.ts +431 -0
  45. package/src/web/client/graph/graph.model.ts +538 -0
  46. package/src/web/client/graph/positions.ts +339 -0
  47. package/src/web/client/graph/project.ts +153 -0
  48. package/src/web/client/graph/renderer.dom.ts +52 -0
  49. package/src/web/client/graph/renderer.ts +279 -0
  50. package/src/web/client/graph/scheme.ts +44 -0
  51. package/src/web/client/live.model.ts +275 -0
  52. package/src/web/client/live.ts +151 -0
  53. package/src/web/client/main.tsx +27 -0
  54. package/src/web/client/note/Editor.tsx +102 -0
  55. package/src/web/client/note/Note.tsx +113 -0
  56. package/src/web/client/note/editor.controller.ts +151 -0
  57. package/src/web/client/note/editor.model.ts +636 -0
  58. package/src/web/client/note/note.model.ts +738 -0
  59. package/src/web/client/search/SearchPalette.tsx +105 -0
  60. package/src/web/client/search/search.model.ts +588 -0
  61. package/src/web/client/search/search.ts +107 -0
  62. package/src/web/client/shell/Columns.tsx +161 -0
  63. package/src/web/client/shell/ContextRail.tsx +87 -0
  64. package/src/web/client/shell/Divider.tsx +44 -0
  65. package/src/web/client/shell/FocusTrap.tsx +56 -0
  66. package/src/web/client/shell/Header.tsx +54 -0
  67. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  68. package/src/web/client/shell/Shell.tsx +193 -0
  69. package/src/web/client/shell/StatusBar.tsx +28 -0
  70. package/src/web/client/shell/cssvars.ts +70 -0
  71. package/src/web/client/shell/drag.model.ts +170 -0
  72. package/src/web/client/shell/focus.model.ts +100 -0
  73. package/src/web/client/shell/keys.model.ts +453 -0
  74. package/src/web/client/shell/keys.ts +59 -0
  75. package/src/web/client/shell/layout.model.ts +526 -0
  76. package/src/web/client/shell/shell.model.ts +333 -0
  77. package/src/web/client/shell/theme.ts +477 -0
  78. package/src/web/client/shell/viewport.ts +29 -0
  79. package/src/web/client/state.ts +78 -0
  80. package/src/web/client/tree/Tree.tsx +138 -0
  81. package/src/web/client/tree/tree.model.ts +674 -0
  82. package/src/web/client/workspace.ts +214 -0
  83. package/src/web/server/page.ts +256 -0
  84. package/src/web/server/routes.ts +975 -0
  85. package/src/web/server/security.ts +361 -0
  86. package/src/web/server/server.ts +275 -0
  87. package/src/web/server/sse.ts +321 -0
  88. package/src/web/server/watcher.ts +507 -0
  89. package/src/web/shared/graph.ts +206 -0
  90. package/src/web/shared/layout.ts +497 -0
  91. package/src/web/shared/metrics.ts +136 -0
  92. package/src/web/shared/view.ts +200 -0
  93. package/src/web/shared/wire.ts +358 -0
  94. package/src/pi/viewer/browser.ts +0 -29
  95. package/src/pi/viewer/page.ts +0 -1316
  96. package/src/pi/viewer/server.ts +0 -229
@@ -0,0 +1,98 @@
1
+ /**
2
+ * BodyStore — shared note/.okf body cache (weave-view-tui-v2 §6, §9.1).
3
+ *
4
+ * The v1 WeaveExplorer cached bodies in private maps. v2 lifts that cache into
5
+ * a per-session store so every pane (e.g. two Detail panes reading the same
6
+ * note) shares one fetch per node id. Behavior is identical to v1's
7
+ * per-explorer cache: a body load is kicked off once per id, in-flight loads
8
+ * are deduped, and a refresh busts the cache so the next read re-fetches.
9
+ *
10
+ * The store is harness-free (takes injected loaders + an optional
11
+ * onChange callback), so it is unit-tested with fake loaders exactly like
12
+ * v1's body tests.
13
+ */
14
+
15
+ import type { ViewNote } from "../../../core/graph/current";
16
+
17
+ /** The body loaders a BodyStore is bound to (bound to vault/cwd by run.ts). */
18
+ export interface BodyLoaders {
19
+ loadNote: (slug: string) => Promise<ViewNote | null>;
20
+ loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
21
+ }
22
+
23
+ export interface BodyStoreOptions {
24
+ loaders: BodyLoaders;
25
+ /** Invoked (no args) when an async load resolves so the owner can re-render. */
26
+ onChange?: () => void;
27
+ }
28
+
29
+ /**
30
+ * A cache+dedup keyed by node id. `get` never throws and never double-loads.
31
+ *
32
+ * - `null` body = the node exists but has no loadable body (or it loaded to
33
+ * null); the pane renders nothing for it.
34
+ * - `undefined` = not yet requested (the pane shows a placeholder and asks the
35
+ * store to load, mirroring v1's `bodyLinesFor`).
36
+ * - `isLoading(id)` = an in-flight request is outstanding (pane shows "loading").
37
+ */
38
+ export class BodyStore {
39
+ private readonly loaders: BodyLoaders;
40
+ private readonly onChange: (() => void) | undefined;
41
+ private cache = new Map<string, string | null>();
42
+ private loading = new Set<string>();
43
+
44
+ constructor(opts: BodyStoreOptions) {
45
+ this.loaders = opts.loaders;
46
+ this.onChange = opts.onChange;
47
+ }
48
+
49
+ /** True when the id is mid-load. */
50
+ isLoading(id: string): boolean {
51
+ return this.loading.has(id);
52
+ }
53
+
54
+ /** Cached body for the id, or undefined when not yet requested. */
55
+ get(id: string): string | null | undefined {
56
+ return this.cache.get(id);
57
+ }
58
+
59
+ /** True once a body has been requested for the id (cached or failed). */
60
+ has(id: string): boolean {
61
+ return this.cache.has(id);
62
+ }
63
+
64
+ /**
65
+ * Kick off a load for `id` if not already requested/in-flight. Returns true
66
+ * when a load was started (so the caller can show a placeholder). The load
67
+ * uses `kind` (note/file) to pick the loader and `ref` (slug/rel path).
68
+ */
69
+ load(id: string, kind: "note" | "file", ref: string | undefined): boolean {
70
+ if (ref === undefined) return false;
71
+ if (this.cache.has(id) || this.loading.has(id)) return false;
72
+ this.loading.add(id);
73
+ if (kind === "note") {
74
+ void this.loaders
75
+ .loadNote(ref)
76
+ .then((note) => this.finish(id, note?.body ?? null))
77
+ .catch(() => this.finish(id, null));
78
+ } else {
79
+ void this.loaders
80
+ .loadOkf(ref)
81
+ .then((file) => this.finish(id, file?.body ?? null))
82
+ .catch(() => this.finish(id, null));
83
+ }
84
+ return true;
85
+ }
86
+
87
+ /** Drop all cached bodies and in-flight markers (the `r` refresh). */
88
+ clear(): void {
89
+ this.cache.clear();
90
+ this.loading.clear();
91
+ }
92
+
93
+ private finish(id: string, body: string | null): void {
94
+ this.cache.set(id, body);
95
+ this.loading.delete(id);
96
+ this.onChange?.();
97
+ }
98
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * branding.ts — weave-view TUI logo & wordmark tiers (weave-view-tui-v2 §5).
3
+ *
4
+ * Renders the brand as a small, casual in-bar mark. Three tiers, auto-selected
5
+ * once per session from terminal capability and cached:
6
+ *
7
+ * 1. "kitty" — the real raster logo via pi-tui's `Image` component (a small
8
+ * ~2×2-cell favicon). The raw JPG is bundled and base64'd once.
9
+ * 2. "glyph" — a tiny curated Unicode glyph derived from the logo silhouette.
10
+ * 3. "plain" — `🧵` + wordmark, forced by PI_WEAVE_TUI_PLAIN.
11
+ *
12
+ * The line-art/unicode constants contain no ESC by construction (decision 1:
13
+ * the representation fallback is a curated constant, never generated at runtime).
14
+ */
15
+
16
+ import { detectCapabilities, getCapabilities, Image, type Component } from "@earendil-works/pi-tui";
17
+ import type { ThemeSlot } from "./theme";
18
+
19
+ /** Logo render tier. */
20
+ export type LogoTier = "kitty" | "glyph" | "plain";
21
+
22
+ /** A tiny Unicode mark derived from the logo silhouette (decision 1 fallback). */
23
+ export const MARK_GLYPH = "◈";
24
+ /** Absolute last-resort mark (also the forced plain tier). */
25
+ export const PLAIN_MARK = "🧵";
26
+ /** The wordmark shown after the mark. */
27
+ export const WORDMARK = "weave view";
28
+
29
+ /** Env var that forces the plain tier. */
30
+ export const PLAIN_ENV = "PI_WEAVE_TUI_PLAIN";
31
+
32
+ /** MIME of the bundled raster logo asset (downscaled JPEG of the logo). */
33
+ export const LOGO_MIME = "image/jpeg";
34
+
35
+ /**
36
+ * Bundled downscaled raster copy of `docs/pi-weave-logo.jpg` (32×32, ~1.3 KB).
37
+ * Decision 1 (§15): ship a small raster copy and embed it via pi-tui's `Image`
38
+ * (Kitty graphics) as a small ~2×2-cell favicon in the header strip — a casual
39
+ * in-bar mark, not a hero splash. Stored base64 so the render path needs no
40
+ * fs/path resolution and the asset is trivially unit-testable.
41
+ */
42
+ export const LOGO_B64 =
43
+ "/9j/4AAQSkZJRgABAQAASABIAAD/4QDORXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAExAAIAAAAVAAAAZodpAAQAAAABAAAAfAAAAAAAAABIAAAAAQAAAEgAAAABUGl4ZWxtYXRvciBQcm8gMi4xLjMAAAAEkAQAAgAAABQAAACyoAEAAwAAAAEAAQAAoAIABAAAAAEAAAAgoAMABAAAAAEAAAAgAAAAADIwMjY6MDg6MjIgMTk6NTY6MjIA/+0AZFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAsHAFaAAMbJUccAgAAAgACHAI+AAgyMDI2MDgyMhwCPwALMTk1NjIyKzAwMDA4QklNBCUAAAAAABBB7D4+CxrdyaRylkhoEmIW/8AAEQgAIAAgAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAv/aAAwDAQACEQMRAD8A/E8Lk4rQsocXcD46SIf/AB4VDbpvYDua95+HfhtZtHn8QpBbXEdvKyM00iKW2RtKY4Vcjc+xGY45wAByQD62FwzqO17Luc0pWPC7yHNzM2Orsf1NUGXBxXu/xH8O/Y7Cy114baCG9m8oNBIrbCYlmCTKmdr7JEbHJwcH5gQPEZ02sR6UsThnTdr3XcIyuf/Q/GHT/JkcQzbQpO7Lccjtn3r0Dwv/AG1fMdH0e+e1e+kRTaEM4uZwTtBXhSRk7c8YOO/PlwJByK14dZvI7dbXcDEpJAKjv79ccetezQxfLHlZw1acn8L1PTPE+nanZpb2Wr6i99JBkw2gV4xbzSk8KuGA3BckgYKjGQMVzPjG70+ygh8LaXFZSQWshuGurdd8kkkqDKGY5YqgABUfKH3ECsW48U6vcWs1k02IbjbvUKBnb0AOMgdsA4xxXOE55oxGJ5lZDjRVk5atH//Z";
44
+
45
+ /** Terminal capability surface branding probes. */
46
+ export interface BrandCapabilities {
47
+ /** Kitty graphics protocol available. */
48
+ kitty: boolean;
49
+ }
50
+
51
+ /** Pure tier selection; exported for direct unit testing. */
52
+ export function logoTierFor(caps: BrandCapabilities, forcePlain: boolean): LogoTier {
53
+ if (forcePlain) return "plain";
54
+ if (caps.kitty) return "kitty";
55
+ return "glyph";
56
+ }
57
+
58
+ /**
59
+ * Resolve the environment (PI_WEAVE_TUI_PLAIN) to a plain flag. Exported as a
60
+ * small pure function so the env read is testable in isolation.
61
+ */
62
+ export function plainEnv(env: Record<string, string | undefined> = process.env): boolean {
63
+ const v = env[PLAIN_ENV];
64
+ return v !== undefined && v !== "" && v !== "0";
65
+ }
66
+
67
+ // Session-level probe cache (probed once, reused for the whole session §5.1).
68
+ let cachedTier: LogoTier | null = null;
69
+ let cachedCaps: BrandCapabilities | null = null;
70
+
71
+ /**
72
+ * Return the session-cached capabilities (probed once). The probe runs at most
73
+ * once per process; subsequent calls reuse the cache. `getCaps` is injectable
74
+ * for tests (defaults to a live kitty probe). Never throws.
75
+ */
76
+ export function probeGraphics(getCaps: () => BrandCapabilities): BrandCapabilities {
77
+ if (cachedCaps) return cachedCaps;
78
+ try {
79
+ cachedCaps = getCaps();
80
+ } catch {
81
+ cachedCaps = { kitty: false };
82
+ }
83
+ return cachedCaps;
84
+ }
85
+
86
+ /**
87
+ * Resolve the session logo tier, probing terminal capability once and caching
88
+ * for the process. `env` is injectable for tests.
89
+ */
90
+ export function logoTier(env: Record<string, string | undefined> = process.env): LogoTier {
91
+ if (cachedTier) return cachedTier;
92
+ const caps = probeGraphics(() => getBrandCapabilities());
93
+ cachedTier = logoTierFor(caps, plainEnv(env));
94
+ return cachedTier;
95
+ }
96
+
97
+ /** Reset the session probe cache (test seam). */
98
+ export function resetBrandCache(): void {
99
+ cachedTier = null;
100
+ cachedCaps = null;
101
+ }
102
+
103
+ /** A live kitty-capability probe backed by pi-tui's terminal capability query. */
104
+ export function getBrandCapabilities(): BrandCapabilities {
105
+ return { kitty: detectCapabilities().images === "kitty" };
106
+ }
107
+
108
+ /**
109
+ * The mark rendered as a single header line (≤ width). This is the string the
110
+ * header strip / empty state embed. For the kitty tier the mark glyph is used
111
+ * in the text strip; the real raster is emitted via `logoImage()` when a kitty
112
+ * surface is available.
113
+ */
114
+ export function renderMark(tier: LogoTier, theme: { fg: (slot: ThemeSlot, text: string) => string }, width: number): string {
115
+ let mark: string;
116
+ if (tier === "plain") {
117
+ mark = theme.fg("muted", PLAIN_MARK);
118
+ } else {
119
+ // kitty and glyph tiers share the curated glyph in the text strip.
120
+ mark = theme.fg("accent", MARK_GLYPH);
121
+ }
122
+ return mark.slice(0, Math.max(1, width));
123
+ }
124
+
125
+ /**
126
+ * A small pi-tui `Image` component for the kitty tier (decision 1 favicon).
127
+ * Takes the base64 JPG + mime; renders at ~2×2 cells. Returns a component that
128
+ * emits the Kitty sequence when the terminal supports it, else a text fallback.
129
+ */
130
+ export function logoImage(
131
+ base64Data: string,
132
+ mimeType: string,
133
+ theme: { fg: (slot: ThemeSlot, text: string) => string },
134
+ ): Component {
135
+ return new Image(base64Data, mimeType, { fallbackColor: (t) => theme.fg("text", t) }, {
136
+ maxWidthCells: 2,
137
+ maxHeightCells: 2,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Build the bundled kitty raster logo `Image` (decision 1 favicon). Returns a
143
+ * component only when the terminal supports the Kitty graphics protocol;
144
+ * `null` otherwise so the caller keeps the one-line glyph header. The render
145
+ * path splices this component's lines onto their own row(s) — never inlined
146
+ * into a styled text line. The base64 asset is a compile-time constant, so the
147
+ * only fallback needed is the Kitty gate (no fs/path reads at runtime).
148
+ */
149
+ export function bundledLogoImage(
150
+ theme: { fg: (slot: ThemeSlot, text: string) => string },
151
+ ): Component | null {
152
+ if (getCapabilities().images === "kitty") return logoImage(LOGO_B64, LOGO_MIME, theme);
153
+ return null;
154
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { matchesKey, parseKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
16
+ import { BodyStore } from "./bodyStore";
16
17
 
17
18
  import type { GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
18
19
  import type { NoteSource } from "../../../core/types";
@@ -20,6 +21,7 @@ import type { ViewNote } from "../../../core/graph/current";
20
21
  import {
21
22
  detailModel,
22
23
  focusModel,
24
+ formatTreeMeta,
23
25
  graphRoots,
24
26
  healthModel,
25
27
  initialState,
@@ -141,10 +143,8 @@ export class WeaveExplorer implements Component {
141
143
  private readonly nowFn: () => number;
142
144
 
143
145
  state: ExplorerState;
144
- /** Cached note/okf bodies keyed by node id; null = not yet loaded. */
145
- private bodyCache = new Map<string, string | null>();
146
- /** In-flight body loads, keyed by node id. */
147
- private bodyLoading = new Set<string>();
146
+ /** Shared note/okf body cache (dedup by node id, bust-on-refresh). */
147
+ private readonly bodies: BodyStore;
148
148
  /** Render cache keyed by `${width}:${version}`. */
149
149
  private renderCache = new Map<string, string[]>();
150
150
  wantsKeyRelease = false;
@@ -157,14 +157,21 @@ export class WeaveExplorer implements Component {
157
157
  this.done = opts.done;
158
158
  this.rows = opts.rows ?? 24;
159
159
  this.nowFn = opts.now ?? Date.now;
160
+ this.bodies = new BodyStore({
161
+ loaders: opts.loaders,
162
+ onChange: () => {
163
+ this.state = { ...this.state, version: this.state.version + 1 };
164
+ this.invalidate();
165
+ this.tui.requestRender();
166
+ },
167
+ });
160
168
  this.state = initialState(graphRoots(this.model));
161
169
  }
162
170
 
163
171
  /** Replace the graph (used by the `r` refresh). Preserves selection by id. */
164
172
  setModel(model: GraphModel): void {
165
173
  this.model = model;
166
- this.bodyCache.clear();
167
- this.bodyLoading.clear();
174
+ this.bodies.clear();
168
175
  this.state = reduce(this.state, { type: "refreshDone" });
169
176
  // Re-resolve selection: keep id if still present, else first root.
170
177
  if (this.state.selectedId && !this.nodeExists(this.state.selectedId)) {
@@ -353,8 +360,8 @@ export class WeaveExplorer implements Component {
353
360
  showInternals: this.state.showInternals,
354
361
  provFilter: this.state.provFilter,
355
362
  query: this.state.query,
356
- now: this.nowFn(),
357
363
  });
364
+ const now = this.nowFn();
358
365
  const hint = treeEmptyHint(this.model);
359
366
  if (rows.length === 0 && hint) {
360
367
  return { lines: [{ text: t.fg("dim", hint) }], rows: [] };
@@ -363,7 +370,8 @@ export class WeaveExplorer implements Component {
363
370
  const chev = chevron(r.expanded, r.hasKids);
364
371
  const marker = this.rowMarker(r.kind, r.provenance);
365
372
  const label = sanitizeTerminalText(r.label);
366
- const meta = r.meta ? t.fg("dim", ` ${sanitizeTerminalText(r.meta)}`) : "";
373
+ const metaText = formatTreeMeta(r.meta, now);
374
+ const meta = metaText ? t.fg("dim", ` ${sanitizeTerminalText(metaText)}`) : "";
367
375
  const indent = " ".repeat(r.depth);
368
376
  const body = `${indent}${chev} ${marker}${label}`;
369
377
  const text = meta ? `${body}${meta}` : body;
@@ -504,31 +512,12 @@ export class WeaveExplorer implements Component {
504
512
  }
505
513
 
506
514
  private maybeLoadBody(id: string): void {
507
- if (this.bodyCache.has(id) || this.bodyLoading.has(id)) return;
508
515
  const node = this.model.nodes.find((n) => n.id === id);
509
516
  if (!node) return;
510
517
  if (node.kind === "note") {
511
- const slug = node.detail.slug;
512
- if (!slug) return;
513
- this.bodyLoading.add(id);
514
- void this.loaders.loadNote(slug).then((note) => {
515
- this.bodyCache.set(id, note?.body ?? null);
516
- this.bodyLoading.delete(id);
517
- this.state = { ...this.state, version: this.state.version + 1 };
518
- this.invalidate();
519
- this.tui.requestRender();
520
- });
518
+ this.bodies.load(id, "note", node.detail.slug);
521
519
  } else if (node.kind === "file") {
522
- const rel = node.detail.path;
523
- if (!rel) return;
524
- this.bodyLoading.add(id);
525
- void this.loaders.loadOkf(rel).then((file) => {
526
- this.bodyCache.set(id, file?.body ?? null);
527
- this.bodyLoading.delete(id);
528
- this.state = { ...this.state, version: this.state.version + 1 };
529
- this.invalidate();
530
- this.tui.requestRender();
531
- });
520
+ this.bodies.load(id, "file", node.detail.path);
532
521
  }
533
522
  }
534
523
 
@@ -539,8 +528,8 @@ export class WeaveExplorer implements Component {
539
528
  // never completes (maybeLoadBody skips non-note/file kinds).
540
529
  const node = this.model.nodes.find((n) => n.id === id);
541
530
  if (!node || (node.kind !== "note" && node.kind !== "file")) return [];
542
- if (this.bodyLoading.has(id)) return [this.theme.fg("dim", "(loading…)")];
543
- const body = this.bodyCache.get(id);
531
+ if (this.bodies.isLoading(id)) return [this.theme.fg("dim", "(loading…)")];
532
+ const body = this.bodies.get(id);
544
533
  if (body === undefined) {
545
534
  // not yet requested — trigger a load (render is sync, so show placeholder)
546
535
  queueMicrotask(() => this.maybeLoadBody(id));