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.
- package/README.md +114 -37
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +102 -19
- package/src/pi/viewer/tui/bodyStore.ts +98 -0
- package/src/pi/viewer/tui/branding.ts +154 -0
- package/src/pi/viewer/tui/explorer.ts +20 -31
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +11 -0
- package/src/pi/viewer/tui/run.ts +13 -4
- package/src/pi/viewer/tui/surface/base.ts +182 -0
- package/src/pi/viewer/tui/surface/detail.ts +315 -0
- package/src/pi/viewer/tui/surface/explore.ts +206 -0
- package/src/pi/viewer/tui/surface/focus.ts +139 -0
- package/src/pi/viewer/tui/surface/health.ts +102 -0
- package/src/pi/viewer/tui/workspace.ts +381 -0
- package/src/pi/viewer/tui/workspaceRoot.ts +530 -0
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
- package/src/pi/viewer/browser.ts +0 -29
- package/src/pi/viewer/page.ts +0 -1316
- 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
|
-
/**
|
|
145
|
-
private
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
543
|
-
const body = this.
|
|
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));
|