pi-weave 0.1.1
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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +69 -0
- package/skills/.gitkeep +0 -0
- package/skills/weave-explore/SKILL.md +64 -0
- package/skills/weave-notepad/SKILL.md +77 -0
- package/src/core/frontmatter.ts +119 -0
- package/src/core/git.ts +194 -0
- package/src/core/graph/build.ts +258 -0
- package/src/core/graph/current.ts +107 -0
- package/src/core/graph/model.ts +63 -0
- package/src/core/graph/wikilinks.ts +28 -0
- package/src/core/index.ts +25 -0
- package/src/core/languages.ts +76 -0
- package/src/core/mutex.ts +34 -0
- package/src/core/paths.ts +37 -0
- package/src/core/repoIndex.ts +404 -0
- package/src/core/slug.ts +28 -0
- package/src/core/summaries.ts +300 -0
- package/src/core/types.ts +155 -0
- package/src/core/vault.ts +254 -0
- package/src/core/workspace.ts +85 -0
- package/src/pi/index.ts +191 -0
- package/src/pi/summarize.ts +134 -0
- package/src/pi/tools/noteTool.ts +178 -0
- package/src/pi/tools/repoTool.ts +94 -0
- package/src/pi/viewer/browser.ts +29 -0
- package/src/pi/viewer/page.ts +1316 -0
- package/src/pi/viewer/server.ts +229 -0
- package/src/pi/viewer/tui/explorer.ts +597 -0
- package/src/pi/viewer/tui/model.ts +1011 -0
- package/src/pi/viewer/tui/run.ts +69 -0
- package/src/pi/viewer/tui/theme.ts +90 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE view-model for the weave-view TUI (weave-view-tui-design §5, §10.1).
|
|
3
|
+
*
|
|
4
|
+
* Ports the browser page's `listTree` / `focusNeighborhood` / `deriveBacklinks`
|
|
5
|
+
* semantics into TypeScript. Imports ONLY core types — no pi-tui imports —
|
|
6
|
+
* so the bulk of the logic is unit-tested like the page's listTree tests.
|
|
7
|
+
*
|
|
8
|
+
* Every interesting behavior is an exported pure function: `treeRows`,
|
|
9
|
+
* `focusModel`, `detailModel`, `healthModel`, `reduce`, `sanitizeTerminalText`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../../../core/graph/model";
|
|
13
|
+
import type { NoteSource } from "../../../core/types";
|
|
14
|
+
import { MAX_FILTER_LEN, PROVENANCE_CYCLE, provenanceStyle, type ProvenanceStyle } from "./theme";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Row types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** A selectable row in any surface. `target` (when set) is the node id a row
|
|
21
|
+
* jumps to on `enter` (a link / neighbor). */
|
|
22
|
+
export interface SelectableRow {
|
|
23
|
+
id: string;
|
|
24
|
+
/** Node id this row jumps to on enter (links/backlinks/neighbors). */
|
|
25
|
+
target?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One row in the Explore tree. */
|
|
29
|
+
export interface TreeRow extends SelectableRow {
|
|
30
|
+
depth: number;
|
|
31
|
+
hasKids: boolean;
|
|
32
|
+
expanded: boolean;
|
|
33
|
+
label: string;
|
|
34
|
+
kind: NodeKind;
|
|
35
|
+
provenance: NoteSource | null;
|
|
36
|
+
/** Dim meta tail (note: relative updated; module: files=N; …), pre-truncated. */
|
|
37
|
+
meta: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Shared pure helpers (mirror the page's `focusNeighborhood` / `deriveBacklinks`)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** 1-hop neighborhood of `id` (the node itself + direct neighbors). 2-hop excluded. */
|
|
45
|
+
export function focusNeighborhood(id: string, edges: readonly GraphEdge[]): Set<string> {
|
|
46
|
+
const out = new Set<string>([id]);
|
|
47
|
+
for (const e of edges) {
|
|
48
|
+
if (e.source === id) out.add(e.target);
|
|
49
|
+
if (e.target === id) out.add(e.source);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Map each node id → its incoming `links-to` sources (backlinks). */
|
|
55
|
+
export function deriveBacklinks(edges: readonly GraphEdge[]): Map<string, string[]> {
|
|
56
|
+
const out = new Map<string, string[]>();
|
|
57
|
+
for (const e of edges) {
|
|
58
|
+
if (e.kind !== "links-to") continue;
|
|
59
|
+
const list = out.get(e.target);
|
|
60
|
+
if (list) list.push(e.source);
|
|
61
|
+
else out.set(e.target, [e.source]);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Incident-edge degree (both directions, all kinds). */
|
|
67
|
+
export function degreeOf(id: string, edges: readonly GraphEdge[]): number {
|
|
68
|
+
let d = 0;
|
|
69
|
+
for (const e of edges) if (e.source === id || e.target === id) d++;
|
|
70
|
+
return d;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Relative human time, mirroring the page's `relTime`. `now` is epoch ms. */
|
|
74
|
+
export function relTime(iso: string | undefined, now: number): string {
|
|
75
|
+
if (!iso) return "";
|
|
76
|
+
const t = Date.parse(iso);
|
|
77
|
+
if (Number.isNaN(t)) return "";
|
|
78
|
+
const s = Math.max(0, Math.floor((now - t) / 1000));
|
|
79
|
+
if (s < 60) return "just now";
|
|
80
|
+
const m = Math.floor(s / 60);
|
|
81
|
+
if (m < 60) return `${m}m ago`;
|
|
82
|
+
const h = Math.floor(m / 60);
|
|
83
|
+
if (h < 24) return `${h}h ago`;
|
|
84
|
+
const d = Math.floor(h / 24);
|
|
85
|
+
if (d < 30) return `${d}d ago`;
|
|
86
|
+
const mo = Math.floor(d / 30);
|
|
87
|
+
if (mo < 12) return `${mo}mo ago`;
|
|
88
|
+
return `${Math.floor(mo / 12)}y ago`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// treeRows — the Explore surface (port of the page's listTree, 1:1)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export interface TreeState {
|
|
96
|
+
/** Expanded node ids (when no filter is active). */
|
|
97
|
+
expanded: ReadonlySet<string>;
|
|
98
|
+
/** Reveal repo plumbing (gitState/external/package/entryPoint). */
|
|
99
|
+
showInternals: boolean;
|
|
100
|
+
/** Provenance filter (null = all). */
|
|
101
|
+
provFilter: NoteSource | null;
|
|
102
|
+
/** Substring filter on node labels (case-insensitive). */
|
|
103
|
+
query: string;
|
|
104
|
+
/** Reference time for relative-time meta (epoch ms). */
|
|
105
|
+
now: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Disambiguate labels that would otherwise read as the same entry twice
|
|
109
|
+
* (a remote URL whose tail matches the repo name, an npm package named
|
|
110
|
+
* after the repo). Mirrors the page's `listLabel`. */
|
|
111
|
+
export function listLabel(node: GraphNode): string {
|
|
112
|
+
if (node.kind === "external" && node.detail.url) {
|
|
113
|
+
const u = node.detail.url
|
|
114
|
+
.replace(/^[a-z]+:\/\//, "")
|
|
115
|
+
.replace(/^[^@\/]+@/, "")
|
|
116
|
+
.replace(/\.git$/, "")
|
|
117
|
+
.replace(/\/+$/, "");
|
|
118
|
+
if (u) return u;
|
|
119
|
+
}
|
|
120
|
+
if (node.kind === "package" && node.detail.manifest) {
|
|
121
|
+
return `${node.label} (${node.detail.manifest})`;
|
|
122
|
+
}
|
|
123
|
+
return node.label;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Build the contains/anchored-at nesting + entry-point-to-module map. */
|
|
127
|
+
interface TreeIndex {
|
|
128
|
+
byId: Map<string, GraphNode>;
|
|
129
|
+
/** strict contains (used for entry-point → module placement). */
|
|
130
|
+
contains: Map<string, string[]>;
|
|
131
|
+
/** contains + anchored-at (the nesting hierarchy). */
|
|
132
|
+
tree: Map<string, string[]>;
|
|
133
|
+
/** node id → true when it has an incoming contains/anchored-at edge. */
|
|
134
|
+
incoming: Set<string>;
|
|
135
|
+
/** module id → entry-point ids nested under it (by path prefix). */
|
|
136
|
+
moduleEntries: Map<string, string[]>;
|
|
137
|
+
/** roots (nodes with no incoming edge), in node-input order. */
|
|
138
|
+
roots: string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function indexTree(model: GraphModel): TreeIndex {
|
|
142
|
+
const byId = new Map<string, GraphNode>();
|
|
143
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
144
|
+
|
|
145
|
+
const contains = new Map<string, string[]>();
|
|
146
|
+
const tree = new Map<string, string[]>();
|
|
147
|
+
const incoming = new Set<string>();
|
|
148
|
+
for (const e of model.edges) {
|
|
149
|
+
if (e.kind === "contains") {
|
|
150
|
+
const list = contains.get(e.source);
|
|
151
|
+
if (list) list.push(e.target);
|
|
152
|
+
else contains.set(e.source, [e.target]);
|
|
153
|
+
}
|
|
154
|
+
if (e.kind === "contains" || e.kind === "anchored-at") {
|
|
155
|
+
const list = tree.get(e.source);
|
|
156
|
+
if (list) list.push(e.target);
|
|
157
|
+
else tree.set(e.source, [e.target]);
|
|
158
|
+
incoming.add(e.target);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Entry points nest under the module whose path is the entry's directory prefix.
|
|
163
|
+
// The caller only invokes this for entryPoint nodes, so the path guard suffices.
|
|
164
|
+
const moduleFor = (entry: GraphNode): string | null => {
|
|
165
|
+
if (!entry.detail.path) return null;
|
|
166
|
+
const p = entry.detail.path;
|
|
167
|
+
let best: string | null = null;
|
|
168
|
+
let bestLen = 0;
|
|
169
|
+
const repoKids = contains.get("repository") ?? [];
|
|
170
|
+
for (const cid of repoKids) {
|
|
171
|
+
const c = byId.get(cid);
|
|
172
|
+
if (c && c.kind === "module" && c.detail.path) {
|
|
173
|
+
const mp = c.detail.path;
|
|
174
|
+
if (p.startsWith(mp + "/") && mp.length > bestLen) {
|
|
175
|
+
best = cid;
|
|
176
|
+
bestLen = mp.length;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return best;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const moduleEntries = new Map<string, string[]>();
|
|
184
|
+
for (const n of model.nodes) {
|
|
185
|
+
if (n.kind !== "entryPoint") continue;
|
|
186
|
+
const m = moduleFor(n);
|
|
187
|
+
if (m) {
|
|
188
|
+
const list = moduleEntries.get(m);
|
|
189
|
+
if (list) list.push(n.id);
|
|
190
|
+
else moduleEntries.set(m, [n.id]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const roots = model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
|
|
195
|
+
return { byId, contains, tree, incoming, moduleEntries, roots };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Knowledge-first default: hide repo plumbing unless showInternals. */
|
|
199
|
+
function hiddenInternalKind(kind: NodeKind): boolean {
|
|
200
|
+
return kind === "gitState" || kind === "external" || kind === "package" || kind === "entryPoint";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sortKids(ids: string[], byId: Map<string, GraphNode>): string[] {
|
|
204
|
+
return ids
|
|
205
|
+
.slice()
|
|
206
|
+
.sort((a, b) => {
|
|
207
|
+
const la = listLabel(byId.get(a)!);
|
|
208
|
+
const lb = listLabel(byId.get(b)!);
|
|
209
|
+
return la.localeCompare(lb);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function metaFor(node: GraphNode, now: number): string {
|
|
214
|
+
switch (node.kind) {
|
|
215
|
+
case "note":
|
|
216
|
+
return node.detail.updated ? relTime(node.detail.updated, now) : "";
|
|
217
|
+
case "module":
|
|
218
|
+
return node.detail.files ? `files=${node.detail.files}` : "";
|
|
219
|
+
case "gitState":
|
|
220
|
+
return node.detail.commit ? node.detail.commit.slice(0, 7) : "";
|
|
221
|
+
case "repository":
|
|
222
|
+
return node.detail.files ? `${node.detail.files} files` : "";
|
|
223
|
+
case "package":
|
|
224
|
+
return node.detail.kind ?? "";
|
|
225
|
+
case "entryPoint":
|
|
226
|
+
return node.detail.summary ? "summary" : "";
|
|
227
|
+
default:
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build the Explore tree rows (port of the page's `listTree`).
|
|
234
|
+
*
|
|
235
|
+
* - Roots: `vault`, `repository` (nodes with no incoming containment edge).
|
|
236
|
+
* - Entry points nest under their prefix module.
|
|
237
|
+
* - Internals hidden by default; `i` toggles.
|
|
238
|
+
* - Filter (query and/or provenance): prune to matches + ancestors, auto-expanding ancestors.
|
|
239
|
+
* - Default expansion (no filter): controlled by `state.expanded`.
|
|
240
|
+
*/
|
|
241
|
+
export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
|
|
242
|
+
const idx = indexTree(model);
|
|
243
|
+
const { byId, tree, moduleEntries, roots } = idx;
|
|
244
|
+
|
|
245
|
+
const children = (id: string): string[] => {
|
|
246
|
+
const kids = tree.get(id) ?? [];
|
|
247
|
+
// Drop entry points that have a claiming module (they re-nest under it).
|
|
248
|
+
const filtered = kids.filter((kid) => {
|
|
249
|
+
const n = byId.get(kid);
|
|
250
|
+
if (!n || n.kind !== "entryPoint") return true;
|
|
251
|
+
for (const [, entries] of moduleEntries) {
|
|
252
|
+
if (entries.includes(kid)) return false;
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
});
|
|
256
|
+
let all = filtered.concat(moduleEntries.get(id) ?? []);
|
|
257
|
+
// Skip kids absent from the node set (corrupt/hand-built graphs): the page
|
|
258
|
+
// never produces these, but a defensive filter keeps the tree robust.
|
|
259
|
+
all = all.filter((k) => byId.has(k));
|
|
260
|
+
if (!state.showInternals) {
|
|
261
|
+
all = all.filter((k) => {
|
|
262
|
+
const n = byId.get(k);
|
|
263
|
+
return !(n && hiddenInternalKind(n.kind));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return sortKids(all, byId);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const filtering = state.provFilter !== null || state.query.length > 0;
|
|
270
|
+
|
|
271
|
+
const matches = (node: GraphNode): boolean => {
|
|
272
|
+
if (state.provFilter !== null && node.provenance !== state.provFilter) return false;
|
|
273
|
+
if (state.query.length > 0 && !listLabel(node).toLowerCase().includes(state.query.toLowerCase())) return false;
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Pass 1: mark visible nodes (self-match or a visible descendant).
|
|
278
|
+
const visible = new Set<string>();
|
|
279
|
+
const mark = (id: string): boolean => {
|
|
280
|
+
const node = byId.get(id)!;
|
|
281
|
+
let any = false;
|
|
282
|
+
for (const k of children(id)) if (mark(k)) any = true;
|
|
283
|
+
const show = matches(node) || any;
|
|
284
|
+
if (show) visible.add(id);
|
|
285
|
+
return show;
|
|
286
|
+
};
|
|
287
|
+
for (const r of roots) mark(r);
|
|
288
|
+
|
|
289
|
+
const rows: TreeRow[] = [];
|
|
290
|
+
const walk = (id: string, depth: number): void => {
|
|
291
|
+
if (!visible.has(id)) return;
|
|
292
|
+
const node = byId.get(id)!;
|
|
293
|
+
const kids = children(id);
|
|
294
|
+
const visibleKids = kids.filter((k) => visible.has(k));
|
|
295
|
+
const expanded = filtering ? visibleKids.length > 0 : state.expanded.has(id);
|
|
296
|
+
rows.push({
|
|
297
|
+
id,
|
|
298
|
+
depth,
|
|
299
|
+
hasKids: kids.length > 0,
|
|
300
|
+
expanded,
|
|
301
|
+
label: listLabel(node),
|
|
302
|
+
kind: node.kind,
|
|
303
|
+
provenance: node.provenance,
|
|
304
|
+
meta: metaFor(node, state.now),
|
|
305
|
+
});
|
|
306
|
+
if (expanded) for (const k of visibleKids) walk(k, depth + 1);
|
|
307
|
+
};
|
|
308
|
+
for (const r of roots) walk(r, 0);
|
|
309
|
+
return rows;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Empty-state hint rows for the tree surface (design §5.1). */
|
|
313
|
+
export function treeEmptyHint(model: GraphModel): string | null {
|
|
314
|
+
const vault = model.nodes.find((n) => n.kind === "vault");
|
|
315
|
+
if (!vault) return null;
|
|
316
|
+
// no notes and no repository
|
|
317
|
+
const hasNotes = model.nodes.some((n) => n.kind === "note");
|
|
318
|
+
const hasRepo = model.nodes.some((n) => n.kind === "repository");
|
|
319
|
+
if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// focusModel — the 1-hop neighborhood (port of focusNeighborhood, grouped)
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
export interface FocusRow extends SelectableRow {
|
|
328
|
+
label: string;
|
|
329
|
+
kind: NodeKind;
|
|
330
|
+
provenance: NoteSource | null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface FocusGroup {
|
|
334
|
+
/** Heading label, e.g. "links to →", "← linked from", "contains", "contained by". */
|
|
335
|
+
heading: string;
|
|
336
|
+
rows: FocusRow[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface FocusModel {
|
|
340
|
+
center: FocusRow;
|
|
341
|
+
groups: FocusGroup[];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const OUTGOING_HEADINGS: Record<string, string> = {
|
|
345
|
+
"links-to": "links to →",
|
|
346
|
+
contains: "contains",
|
|
347
|
+
"anchored-at": "anchored at",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Group the 1-hop neighborhood of `id` (outgoing by kind, then incoming). */
|
|
351
|
+
export function focusModel(model: GraphModel, id: string): FocusModel {
|
|
352
|
+
const byId = new Map<string, GraphNode>();
|
|
353
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
354
|
+
|
|
355
|
+
const centerNode = byId.get(id);
|
|
356
|
+
const center: FocusRow = centerNode
|
|
357
|
+
? rowFromNode(centerNode)
|
|
358
|
+
: { id, label: id, kind: "note", provenance: null };
|
|
359
|
+
|
|
360
|
+
const outByKind = new Map<string, string[]>();
|
|
361
|
+
const incomingContains = new Map<string, string[]>();
|
|
362
|
+
const backlinks = new Map<string, string[]>();
|
|
363
|
+
for (const e of model.edges) {
|
|
364
|
+
if (e.source === id) {
|
|
365
|
+
const list = outByKind.get(e.kind);
|
|
366
|
+
if (list) list.push(e.target);
|
|
367
|
+
else outByKind.set(e.kind, [e.target]);
|
|
368
|
+
} else if (e.target === id) {
|
|
369
|
+
if (e.kind === "links-to") {
|
|
370
|
+
const list = backlinks.get(e.kind);
|
|
371
|
+
if (list) list.push(e.source);
|
|
372
|
+
else backlinks.set(e.kind, [e.source]);
|
|
373
|
+
} else if (e.kind === "contains" || e.kind === "anchored-at") {
|
|
374
|
+
const list = incomingContains.get(e.kind);
|
|
375
|
+
if (list) list.push(e.source);
|
|
376
|
+
else incomingContains.set(e.kind, [e.source]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const toRows = (ids: string[]): FocusRow[] =>
|
|
382
|
+
ids
|
|
383
|
+
.map((x) => byId.get(x))
|
|
384
|
+
.filter((n): n is GraphNode => n !== undefined)
|
|
385
|
+
.map(rowFromNode);
|
|
386
|
+
|
|
387
|
+
const groups: FocusGroup[] = [];
|
|
388
|
+
for (const kind of ["links-to", "contains", "anchored-at"] as const) {
|
|
389
|
+
const ids = outByKind.get(kind) ?? [];
|
|
390
|
+
if (ids.length === 0) continue;
|
|
391
|
+
groups.push({ heading: OUTGOING_HEADINGS[kind] ?? kind, rows: toRows(ids) });
|
|
392
|
+
}
|
|
393
|
+
const bl = backlinks.get("links-to") ?? [];
|
|
394
|
+
if (bl.length > 0) groups.push({ heading: "← linked from", rows: toRows(bl) });
|
|
395
|
+
const containedBy = incomingContains.get("contains") ?? incomingContains.get("anchored-at") ?? [];
|
|
396
|
+
if (containedBy.length > 0) groups.push({ heading: "contained by", rows: toRows(containedBy) });
|
|
397
|
+
|
|
398
|
+
return { center, groups };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function rowFromNode(n: GraphNode): FocusRow {
|
|
402
|
+
return { id: n.id, target: n.id, label: listLabel(n), kind: n.kind, provenance: n.provenance };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// detailModel — selected node meta + links + backlinks (body loaded async)
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
export interface DetailMetaRow extends SelectableRow {
|
|
410
|
+
label: string;
|
|
411
|
+
value: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface DetailLinkRow extends SelectableRow {
|
|
415
|
+
label: string;
|
|
416
|
+
kind: NodeKind;
|
|
417
|
+
provenance: NoteSource | null;
|
|
418
|
+
/** "link" (outgoing) or "backlink" (incoming). */
|
|
419
|
+
direction: "link" | "backlink";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface DetailModel {
|
|
423
|
+
id: string;
|
|
424
|
+
label: string;
|
|
425
|
+
kind: NodeKind;
|
|
426
|
+
provenance: NoteSource | null;
|
|
427
|
+
meta: DetailMetaRow[];
|
|
428
|
+
links: DetailLinkRow[];
|
|
429
|
+
backlinks: DetailLinkRow[];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Ordered meta keys shown in the detail header (design §5.2). */
|
|
433
|
+
const META_ORDER = [
|
|
434
|
+
"path",
|
|
435
|
+
"slug",
|
|
436
|
+
"source",
|
|
437
|
+
"updated",
|
|
438
|
+
"created",
|
|
439
|
+
"tags",
|
|
440
|
+
"files",
|
|
441
|
+
"languages",
|
|
442
|
+
"branch",
|
|
443
|
+
"commit",
|
|
444
|
+
"uncommitted changes",
|
|
445
|
+
"captured",
|
|
446
|
+
"manifest",
|
|
447
|
+
"kind",
|
|
448
|
+
"url",
|
|
449
|
+
"summarized files",
|
|
450
|
+
"summarized by",
|
|
451
|
+
"summarized at",
|
|
452
|
+
"summary",
|
|
453
|
+
"dangling links",
|
|
454
|
+
"warning",
|
|
455
|
+
"stale",
|
|
456
|
+
"preview",
|
|
457
|
+
"root",
|
|
458
|
+
"notes",
|
|
459
|
+
"state",
|
|
460
|
+
] as const;
|
|
461
|
+
|
|
462
|
+
export function detailModel(model: GraphModel, id: string): DetailModel | null {
|
|
463
|
+
const byId = new Map<string, GraphNode>();
|
|
464
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
465
|
+
const node = byId.get(id);
|
|
466
|
+
if (!node) return null;
|
|
467
|
+
|
|
468
|
+
const meta: DetailMetaRow[] = [];
|
|
469
|
+
for (const key of META_ORDER) {
|
|
470
|
+
const v = node.detail[key];
|
|
471
|
+
if (v === undefined || v === "") continue;
|
|
472
|
+
meta.push({ id: `meta:${key}`, label: key, value: v });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const links: DetailLinkRow[] = [];
|
|
476
|
+
for (const e of model.edges) {
|
|
477
|
+
if (e.source !== id) continue;
|
|
478
|
+
const t = byId.get(e.target);
|
|
479
|
+
if (!t) continue;
|
|
480
|
+
links.push({
|
|
481
|
+
id: `link:${e.kind}:${e.target}`,
|
|
482
|
+
target: e.target,
|
|
483
|
+
label: `${e.kind} → ${listLabel(t)}`,
|
|
484
|
+
kind: t.kind,
|
|
485
|
+
provenance: t.provenance,
|
|
486
|
+
direction: "link",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const backlinks: DetailLinkRow[] = [];
|
|
491
|
+
for (const e of model.edges) {
|
|
492
|
+
if (e.kind !== "links-to" || e.target !== id) continue;
|
|
493
|
+
const s = byId.get(e.source);
|
|
494
|
+
if (!s) continue;
|
|
495
|
+
backlinks.push({
|
|
496
|
+
id: `backlink:${e.source}`,
|
|
497
|
+
target: e.source,
|
|
498
|
+
label: `← ${listLabel(s)}`,
|
|
499
|
+
kind: s.kind,
|
|
500
|
+
provenance: s.provenance,
|
|
501
|
+
direction: "backlink",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
id,
|
|
507
|
+
label: listLabel(node),
|
|
508
|
+
kind: node.kind,
|
|
509
|
+
provenance: node.provenance,
|
|
510
|
+
meta,
|
|
511
|
+
links,
|
|
512
|
+
backlinks,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// healthModel — staleness + link health, derived exclusively from GraphModel
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
export interface HealthRow extends SelectableRow {
|
|
521
|
+
text: string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface HealthSection {
|
|
525
|
+
heading: string;
|
|
526
|
+
rows: HealthRow[];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface HealthModel {
|
|
530
|
+
sections: HealthSection[];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const HEALTH_LIST_CAP = 10;
|
|
534
|
+
|
|
535
|
+
/** Staleness + link health, derived exclusively from the GraphModel
|
|
536
|
+
* (design §5.4 — mirrors v2 §7: zero new server/core fields). */
|
|
537
|
+
export function healthModel(model: GraphModel): HealthModel {
|
|
538
|
+
const byId = new Map<string, GraphNode>();
|
|
539
|
+
for (const n of model.nodes) byId.set(n.id, n);
|
|
540
|
+
const sections: HealthSection[] = [];
|
|
541
|
+
|
|
542
|
+
// Repository section
|
|
543
|
+
const repo = byId.get("repository");
|
|
544
|
+
if (repo) {
|
|
545
|
+
const rows: HealthRow[] = [];
|
|
546
|
+
const staleness = model.staleness;
|
|
547
|
+
if (staleness) {
|
|
548
|
+
rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
|
|
549
|
+
for (let i = 0; i < staleness.reasons.length; i++) {
|
|
550
|
+
rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
|
|
554
|
+
if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
|
|
555
|
+
const summarized = model.nodes
|
|
556
|
+
.filter((n) => n.kind === "module" && n.detail["summarized files"])
|
|
557
|
+
.reduce((acc, n) => acc + Number(n.detail["summarized files"] ?? 0), 0);
|
|
558
|
+
if (summarized > 0) {
|
|
559
|
+
rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
|
|
560
|
+
}
|
|
561
|
+
if (repo.detail.state === "stale") {
|
|
562
|
+
// already covered by staleness reasons; no extra row
|
|
563
|
+
}
|
|
564
|
+
sections.push({ heading: "Repository", rows });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Vault section
|
|
568
|
+
const vault = byId.get("vault");
|
|
569
|
+
if (vault) {
|
|
570
|
+
const rows: HealthRow[] = [];
|
|
571
|
+
const noteCount = Number(vault.detail.notes ?? "0");
|
|
572
|
+
rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
|
|
573
|
+
const prov = countProvenance(model.nodes);
|
|
574
|
+
rows.push({
|
|
575
|
+
id: "health:vault:provenance",
|
|
576
|
+
text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
|
|
577
|
+
});
|
|
578
|
+
if (vault.detail.warning) {
|
|
579
|
+
rows.push({ id: "health:vault:warning", text: vault.detail.warning });
|
|
580
|
+
}
|
|
581
|
+
sections.push({ heading: "Vault", rows });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Link health
|
|
585
|
+
const backlinks = deriveBacklinks(model.edges);
|
|
586
|
+
const orphans: GraphNode[] = [];
|
|
587
|
+
const dangling: { node: GraphNode; count: number }[] = [];
|
|
588
|
+
for (const n of model.nodes) {
|
|
589
|
+
if (n.kind !== "note") continue;
|
|
590
|
+
if (!backlinks.has(n.id)) orphans.push(n);
|
|
591
|
+
const dl = n.detail["dangling links"];
|
|
592
|
+
if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
|
|
593
|
+
}
|
|
594
|
+
const degree = new Map<string, number>();
|
|
595
|
+
for (const e of model.edges) {
|
|
596
|
+
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
597
|
+
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
598
|
+
}
|
|
599
|
+
const hubs = [...model.nodes]
|
|
600
|
+
.map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
|
|
601
|
+
.filter((x) => x.d > 0)
|
|
602
|
+
.sort((a, b) => b.d - a.d)
|
|
603
|
+
.slice(0, HEALTH_LIST_CAP);
|
|
604
|
+
|
|
605
|
+
const linkRows: HealthRow[] = [];
|
|
606
|
+
if (orphans.length > 0) {
|
|
607
|
+
linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
|
|
608
|
+
const shown = orphans.slice(0, HEALTH_LIST_CAP);
|
|
609
|
+
for (let i = 0; i < shown.length; i++) {
|
|
610
|
+
linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
|
|
611
|
+
}
|
|
612
|
+
if (orphans.length > HEALTH_LIST_CAP) {
|
|
613
|
+
linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
|
|
617
|
+
}
|
|
618
|
+
if (dangling.length > 0) {
|
|
619
|
+
linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
|
|
620
|
+
const shown = dangling.slice(0, HEALTH_LIST_CAP);
|
|
621
|
+
for (let i = 0; i < shown.length; i++) {
|
|
622
|
+
linkRows.push({
|
|
623
|
+
id: `health:link:dangling:${shown[i]!.node.id}`,
|
|
624
|
+
text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
|
|
625
|
+
target: shown[i]!.node.id,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (dangling.length > HEALTH_LIST_CAP) {
|
|
629
|
+
linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (hubs.length > 0) {
|
|
633
|
+
linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
|
|
634
|
+
for (let i = 0; i < hubs.length; i++) {
|
|
635
|
+
linkRows.push({
|
|
636
|
+
id: `health:link:hub:${hubs[i]!.n.id}`,
|
|
637
|
+
text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
|
|
638
|
+
target: hubs[i]!.n.id,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
sections.push({ heading: "Link health", rows: linkRows });
|
|
643
|
+
|
|
644
|
+
return { sections };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface ProvenanceCounts {
|
|
648
|
+
total: number;
|
|
649
|
+
human: number;
|
|
650
|
+
agent: number;
|
|
651
|
+
generated: number;
|
|
652
|
+
structural: number;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
|
|
656
|
+
const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
|
|
657
|
+
for (const n of nodes) {
|
|
658
|
+
if (n.provenance === "human") c.human++;
|
|
659
|
+
else if (n.provenance === "agent") c.agent++;
|
|
660
|
+
else if (n.provenance === "generated") c.generated++;
|
|
661
|
+
else c.structural++;
|
|
662
|
+
}
|
|
663
|
+
return c;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// sanitizeTerminalText — the TUI's XSS analog (design §9.3)
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
/** Strip terminal-escape/control sequences from disk/user content before
|
|
671
|
+
* styling or handing to Markdown: ESC (0x1b), CSI (0x9b), BEL (0x07), and
|
|
672
|
+
* other C0 controls except newline (0x0a) and tab (0x09). */
|
|
673
|
+
export function sanitizeTerminalText(s: string): string {
|
|
674
|
+
// eslint-disable-next-line no-control-regex
|
|
675
|
+
return s.replace(/\x1b/g, "").replace(/\x9b/g, "").replace(/\x07/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// ExplorerState + reduce — key→state transitions (design §6, §10.1)
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
export type Surface = "tree" | "detail" | "focus" | "health";
|
|
683
|
+
|
|
684
|
+
export interface ExplorerState {
|
|
685
|
+
surface: Surface;
|
|
686
|
+
/** Inline search sub-mode (only meaningful on the tree surface). */
|
|
687
|
+
searching: boolean;
|
|
688
|
+
/** Selected node id (source of truth; the cursor index is derived). */
|
|
689
|
+
selectedId: string | null;
|
|
690
|
+
/** Node pinned for focus mode. */
|
|
691
|
+
focusId: string | null;
|
|
692
|
+
/** Node open in detail. */
|
|
693
|
+
detailId: string | null;
|
|
694
|
+
/** Tree expansion (when no filter is active). */
|
|
695
|
+
expanded: Set<string>;
|
|
696
|
+
showInternals: boolean;
|
|
697
|
+
provFilter: NoteSource | null;
|
|
698
|
+
query: string;
|
|
699
|
+
helpOpen: boolean;
|
|
700
|
+
refreshing: boolean;
|
|
701
|
+
/** Bumped on every mutation so the render cache can key off it. */
|
|
702
|
+
version: number;
|
|
703
|
+
/** Scroll offset (top visible row index) for the current surface. */
|
|
704
|
+
scrollOffset: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function initialState(roots: readonly string[]): ExplorerState {
|
|
708
|
+
const expanded = new Set<string>();
|
|
709
|
+
for (const r of roots) expanded.add(r);
|
|
710
|
+
const selectedId = roots[0] ?? null;
|
|
711
|
+
return {
|
|
712
|
+
surface: "tree",
|
|
713
|
+
searching: false,
|
|
714
|
+
selectedId,
|
|
715
|
+
focusId: null,
|
|
716
|
+
detailId: null,
|
|
717
|
+
expanded,
|
|
718
|
+
showInternals: false,
|
|
719
|
+
provFilter: null,
|
|
720
|
+
query: "",
|
|
721
|
+
helpOpen: false,
|
|
722
|
+
refreshing: false,
|
|
723
|
+
version: 0,
|
|
724
|
+
scrollOffset: 0,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The roots of a graph (nodes with no incoming contains/anchored-at edge). */
|
|
729
|
+
export function graphRoots(model: GraphModel): string[] {
|
|
730
|
+
const incoming = new Set<string>();
|
|
731
|
+
for (const e of model.edges) {
|
|
732
|
+
if (e.kind === "contains" || e.kind === "anchored-at") incoming.add(e.target);
|
|
733
|
+
}
|
|
734
|
+
return model.nodes.filter((n) => !incoming.has(n.id)).map((n) => n.id);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export type Action =
|
|
738
|
+
| { type: "up" }
|
|
739
|
+
| { type: "down" }
|
|
740
|
+
| { type: "pageUp" }
|
|
741
|
+
| { type: "pageDown" }
|
|
742
|
+
| { type: "home" }
|
|
743
|
+
| { type: "end" }
|
|
744
|
+
| { type: "left" }
|
|
745
|
+
| { type: "right" }
|
|
746
|
+
| { type: "enter" }
|
|
747
|
+
| { type: "searchStart" }
|
|
748
|
+
| { type: "searchChar"; ch: string }
|
|
749
|
+
| { type: "searchBackspace" }
|
|
750
|
+
| { type: "searchCommit" }
|
|
751
|
+
| { type: "cycleProvenance" }
|
|
752
|
+
| { type: "toggleInternals" }
|
|
753
|
+
| { type: "focus" }
|
|
754
|
+
| { type: "focusExit" }
|
|
755
|
+
| { type: "surfaceTree" }
|
|
756
|
+
| { type: "surfaceHealth" }
|
|
757
|
+
| { type: "refresh" }
|
|
758
|
+
| { type: "refreshDone" }
|
|
759
|
+
| { type: "toggleHelp" }
|
|
760
|
+
| { type: "quit" }
|
|
761
|
+
| { type: "esc" };
|
|
762
|
+
|
|
763
|
+
/** Context reduce needs for movement: the selectable rows of the current
|
|
764
|
+
* surface (in display order) and the viewport window size. */
|
|
765
|
+
export interface ReduceCtx {
|
|
766
|
+
rows: readonly SelectableRow[];
|
|
767
|
+
window: number;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function bump(s: ExplorerState): ExplorerState {
|
|
771
|
+
return { ...s, version: s.version + 1 };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function clampIndex(idx: number, len: number): number {
|
|
775
|
+
if (len === 0) return -1;
|
|
776
|
+
if (idx >= len) return len - 1;
|
|
777
|
+
return idx;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function scrollForSelection(idx: number, window: number, prev: number): number {
|
|
781
|
+
if (window <= 0) return 0;
|
|
782
|
+
// keep selection visible: offset <= idx < offset + window
|
|
783
|
+
if (idx < prev) return idx;
|
|
784
|
+
if (idx >= prev + window) return idx - window + 1;
|
|
785
|
+
return prev;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function provenanceCycleNext(current: NoteSource | null): NoteSource | null {
|
|
789
|
+
const i = PROVENANCE_CYCLE.indexOf(current);
|
|
790
|
+
const next = PROVENANCE_CYCLE[(i + 1) % PROVENANCE_CYCLE.length];
|
|
791
|
+
return next ?? null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Apply an action to the explorer state. Pure: returns a new state.
|
|
796
|
+
*
|
|
797
|
+
* Movement uses `ctx.rows` (the current surface's selectable rows) and
|
|
798
|
+
* `ctx.window` (viewport height) to clamp the cursor and adjust scroll.
|
|
799
|
+
* The component builds `ctx.rows` from the surface's model function.
|
|
800
|
+
*/
|
|
801
|
+
export function reduce(state: ExplorerState, action: Action, ctx: ReduceCtx = { rows: [], window: 24 }): ExplorerState {
|
|
802
|
+
const rows = ctx.rows;
|
|
803
|
+
const len = rows.length;
|
|
804
|
+
const currentIdx = state.selectedId ? rows.findIndex((r) => r.id === state.selectedId || r.target === state.selectedId) : -1;
|
|
805
|
+
|
|
806
|
+
switch (action.type) {
|
|
807
|
+
case "up":
|
|
808
|
+
case "down":
|
|
809
|
+
case "pageUp":
|
|
810
|
+
case "pageDown":
|
|
811
|
+
case "home":
|
|
812
|
+
case "end": {
|
|
813
|
+
if (len === 0) return state;
|
|
814
|
+
let nextIdx: number;
|
|
815
|
+
if (action.type === "up") nextIdx = currentIdx <= 0 ? 0 : currentIdx - 1;
|
|
816
|
+
else if (action.type === "down") nextIdx = currentIdx < 0 ? 0 : Math.min(currentIdx + 1, len - 1);
|
|
817
|
+
else if (action.type === "pageUp") nextIdx = currentIdx < 0 ? 0 : Math.max(0, currentIdx - ctx.window);
|
|
818
|
+
else if (action.type === "pageDown") nextIdx = currentIdx < 0 ? 0 : Math.min(len - 1, currentIdx + ctx.window);
|
|
819
|
+
else if (action.type === "home") nextIdx = 0;
|
|
820
|
+
else nextIdx = len - 1; // end
|
|
821
|
+
nextIdx = clampIndex(nextIdx, len);
|
|
822
|
+
const row = rows[nextIdx];
|
|
823
|
+
if (!row) return state;
|
|
824
|
+
const selectedId = row.target ?? row.id;
|
|
825
|
+
const scrollOffset = scrollForSelection(nextIdx, ctx.window, state.scrollOffset);
|
|
826
|
+
return bump({ ...state, selectedId, scrollOffset });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
case "left":
|
|
830
|
+
case "right": {
|
|
831
|
+
if (state.surface !== "tree") return state;
|
|
832
|
+
if (!state.selectedId) return state;
|
|
833
|
+
// operate on the tree: find the row for selectedId
|
|
834
|
+
const treeRowIdx = rows.findIndex((r) => r.id === state.selectedId);
|
|
835
|
+
const treeRow = treeRowIdx >= 0 ? (rows[treeRowIdx] as TreeRow | undefined) : undefined;
|
|
836
|
+
const isExpanded = state.expanded.has(state.selectedId);
|
|
837
|
+
if (action.type === "right") {
|
|
838
|
+
if (treeRow && treeRow.hasKids && !isExpanded) {
|
|
839
|
+
const expanded = new Set(state.expanded).add(state.selectedId);
|
|
840
|
+
return bump({ ...state, expanded });
|
|
841
|
+
}
|
|
842
|
+
if (treeRow && isExpanded) {
|
|
843
|
+
// move to first child
|
|
844
|
+
const child = rows[treeRowIdx + 1];
|
|
845
|
+
if (child) return bump({ ...state, selectedId: child.id, scrollOffset: scrollForSelection(treeRowIdx + 1, ctx.window, state.scrollOffset) });
|
|
846
|
+
}
|
|
847
|
+
return state;
|
|
848
|
+
}
|
|
849
|
+
// left: collapse, or jump to parent
|
|
850
|
+
if (treeRow && isExpanded) {
|
|
851
|
+
const expanded = new Set(state.expanded);
|
|
852
|
+
expanded.delete(state.selectedId);
|
|
853
|
+
return bump({ ...state, expanded });
|
|
854
|
+
}
|
|
855
|
+
// jump to parent: the nearest preceding row with smaller depth
|
|
856
|
+
for (let i = treeRowIdx - 1; i >= 0; i--) {
|
|
857
|
+
const r = rows[i] as TreeRow | undefined;
|
|
858
|
+
if (r && r.depth < (treeRow?.depth ?? 0)) {
|
|
859
|
+
return bump({ ...state, selectedId: r.id, scrollOffset: scrollForSelection(i, ctx.window, state.scrollOffset) });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return state;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
case "enter": {
|
|
866
|
+
if (state.searching) {
|
|
867
|
+
// commit search: keep filter, exit search mode
|
|
868
|
+
return bump({ ...state, searching: false, scrollOffset: 0 });
|
|
869
|
+
}
|
|
870
|
+
if (!state.selectedId) return state;
|
|
871
|
+
const row = currentIdx >= 0 ? rows[currentIdx] : undefined;
|
|
872
|
+
if (state.surface === "tree") {
|
|
873
|
+
return bump({ ...state, surface: "detail", detailId: state.selectedId, scrollOffset: 0 });
|
|
874
|
+
}
|
|
875
|
+
if (state.surface === "detail") {
|
|
876
|
+
// jump to the selected link/backlink target
|
|
877
|
+
if (row && row.target) {
|
|
878
|
+
return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
879
|
+
}
|
|
880
|
+
return state;
|
|
881
|
+
}
|
|
882
|
+
if (state.surface === "focus") {
|
|
883
|
+
// re-center on the selected neighbor
|
|
884
|
+
if (row && row.target) {
|
|
885
|
+
return bump({ ...state, focusId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
886
|
+
}
|
|
887
|
+
return state;
|
|
888
|
+
}
|
|
889
|
+
if (state.surface === "health") {
|
|
890
|
+
if (row && row.target) {
|
|
891
|
+
return bump({ ...state, surface: "detail", detailId: row.target, selectedId: row.target, scrollOffset: 0 });
|
|
892
|
+
}
|
|
893
|
+
return state;
|
|
894
|
+
}
|
|
895
|
+
return state;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
case "searchStart": {
|
|
899
|
+
if (state.searching) return state;
|
|
900
|
+
if (state.surface === "detail") return state;
|
|
901
|
+
return bump({ ...state, searching: true });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
case "searchChar": {
|
|
905
|
+
if (!state.searching) return state;
|
|
906
|
+
const q = (state.query + action.ch).slice(0, MAX_FILTER_LEN);
|
|
907
|
+
return bump({ ...state, query: q, scrollOffset: 0 });
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
case "searchBackspace": {
|
|
911
|
+
if (!state.searching) return state;
|
|
912
|
+
return bump({ ...state, query: state.query.slice(0, -1), scrollOffset: 0 });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
case "searchCommit": {
|
|
916
|
+
if (!state.searching) return state;
|
|
917
|
+
return bump({ ...state, searching: false, scrollOffset: 0 });
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
case "esc": {
|
|
921
|
+
// precedence: search > surface-exit > quit
|
|
922
|
+
if (state.searching) {
|
|
923
|
+
return bump({ ...state, searching: false, query: "", scrollOffset: 0 });
|
|
924
|
+
}
|
|
925
|
+
if (state.surface === "detail") {
|
|
926
|
+
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
927
|
+
}
|
|
928
|
+
if (state.surface === "focus") {
|
|
929
|
+
return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
|
|
930
|
+
}
|
|
931
|
+
if (state.surface === "health") {
|
|
932
|
+
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
933
|
+
}
|
|
934
|
+
// tree → quit (component resolves done(null))
|
|
935
|
+
return bump({ ...state });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
case "quit": {
|
|
939
|
+
return bump({ ...state });
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
case "cycleProvenance": {
|
|
943
|
+
if (state.searching) return state;
|
|
944
|
+
return bump({ ...state, provFilter: provenanceCycleNext(state.provFilter), scrollOffset: 0 });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
case "toggleInternals": {
|
|
948
|
+
if (state.searching) return state;
|
|
949
|
+
return bump({ ...state, showInternals: !state.showInternals, scrollOffset: 0 });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
case "focus": {
|
|
953
|
+
if (state.searching) return state;
|
|
954
|
+
if (!state.selectedId) return state;
|
|
955
|
+
return bump({ ...state, surface: "focus", focusId: state.selectedId, scrollOffset: 0 });
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
case "focusExit": {
|
|
959
|
+
return bump({ ...state, surface: "tree", focusId: null, scrollOffset: 0 });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
case "surfaceTree": {
|
|
963
|
+
if (state.searching) return state;
|
|
964
|
+
return bump({ ...state, surface: "tree", scrollOffset: 0 });
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
case "surfaceHealth": {
|
|
968
|
+
if (state.searching) return state;
|
|
969
|
+
return bump({ ...state, surface: "health", scrollOffset: 0 });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
case "refresh": {
|
|
973
|
+
return bump({ ...state, refreshing: true });
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
case "refreshDone": {
|
|
977
|
+
return bump({ ...state, refreshing: false });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
case "toggleHelp": {
|
|
981
|
+
if (state.searching) return state;
|
|
982
|
+
return bump({ ...state, helpOpen: !state.helpOpen });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// Exhaustive over Action; unreachable for valid inputs.
|
|
986
|
+
return state;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Merge explorer state across a refresh (design §6): keep the expanded set,
|
|
991
|
+
* filter, surface, and selected node id. A selected/expanded id that no
|
|
992
|
+
* longer exists drops out; selection falls back to the first root.
|
|
993
|
+
*/
|
|
994
|
+
export function mergeAfterRefresh(prev: ExplorerState, nextRoots: readonly string[]): ExplorerState {
|
|
995
|
+
let selectedId = prev.selectedId;
|
|
996
|
+
if (selectedId !== null && !nextRoots.includes(selectedId)) {
|
|
997
|
+
// selectedId may be a non-root; the component re-resolves it against the
|
|
998
|
+
// new tree rows. Roots fallback only when it is null.
|
|
999
|
+
}
|
|
1000
|
+
if (selectedId === null) selectedId = nextRoots[0] ?? null;
|
|
1001
|
+
return {
|
|
1002
|
+
...prev,
|
|
1003
|
+
refreshing: false,
|
|
1004
|
+
selectedId,
|
|
1005
|
+
version: prev.version + 1,
|
|
1006
|
+
scrollOffset: 0,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Re-export theme helpers some tests reach for.
|
|
1011
|
+
export { provenanceStyle, type ProvenanceStyle };
|