pi-weave 0.1.7 → 0.1.9

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 (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Everything the tree column *decides* (weave-workspace §1.2, §3, §10).
3
+ *
4
+ * The column itself is `treeRows` with a different renderer — that is §3's
5
+ * whole claim, and this module is what makes it true in the browser: it holds
6
+ * the view state the TUI keeps in `ExplorerState`, the reducers that move it,
7
+ * and the presentation mapping from a `TreeRow` to something a `<li>` can
8
+ * render. `Tree.tsx` is left with a `useState`, a `map` and four handlers.
9
+ *
10
+ * That split is not stylistic. §10 forbids a DOM test environment, so a branch
11
+ * inside a `.tsx` is a branch no test can reach; every branch the tree needs
12
+ * lives here, where an ordinary unit test covers it.
13
+ *
14
+ * ## Tier rules (§2)
15
+ *
16
+ * `src/web/client/**`: `src/web/shared` and browser deps only. The view-models
17
+ * arrive through `../../shared/view`, the one sanctioned door onto
18
+ * `src/core/view` (§2.1) — never from `src/core` directly, even though the
19
+ * modules behind the door are proven node-free. This file also touches no DOM
20
+ * type at all, which is what lets the root `tsconfig.json` project (no `DOM`
21
+ * lib) compile the tests that import it.
22
+ */
23
+
24
+ import type { TreeRow, TreeState, ViewGraphModel } from "../../shared/view";
25
+ import { formatTreeMeta, treeEmptyHint, treeRows } from "../../shared/view";
26
+ import type { GraphPayload, WireNodeKind, WireNoteSource } from "../../shared/wire";
27
+
28
+ // --- the view state ------------------------------------------------------------
29
+
30
+ /**
31
+ * The tree's own state — core's {@link TreeState}, verbatim.
32
+ *
33
+ * Deliberately an alias rather than a parallel interface. `treeRows` reads
34
+ * `expanded`, `showInternals`, `provFilter` and `query`; a client-side copy
35
+ * with the same four fields would be a second declaration of one contract, and
36
+ * the first time core added a fifth the browser would silently stop honouring
37
+ * it.
38
+ *
39
+ * It is **not** `src/web/client/state.ts`'s `TreeState`. That one is a P1
40
+ * placeholder carrying only an expanded-id list, and it stays where it is:
41
+ * this state is owned by the tree column, lives in the component that renders
42
+ * it, and never crosses the context bus. §1.3's bus is `selectedId` — which
43
+ * rows happen to be open is not something the note column or the graph has any
44
+ * business reacting to.
45
+ */
46
+ export type TreeViewState = TreeState;
47
+
48
+ /**
49
+ * The provenance filter cycle: all → human → agent → generated → all.
50
+ *
51
+ * The same order as the TUI's `p` key (`src/pi/viewer/tui/theme.ts`), and
52
+ * declared again here because the client tier may not import `src/pi` and the
53
+ * order is not part of `src/core/view`. That is a real, if small, duplication
54
+ * — so it is one array with a test asserting its contents, rather than an
55
+ * `if/else` chain in two places. If a fourth provenance is ever added, the
56
+ * honest fix is to promote this into `src/core/view` and take it through the
57
+ * door; today it is four literals and a promotion would be ceremony.
58
+ */
59
+ export const PROVENANCE_CYCLE: readonly (WireNoteSource | null)[] = [null, "human", "agent", "generated"];
60
+
61
+ /**
62
+ * The tree as it opens: nothing expanded but the roots, no filter.
63
+ *
64
+ * Roots are expanded eagerly because a tree whose every row is collapsed shows
65
+ * two words (`vault`, `repository`) and reads as an empty column. The TUI's
66
+ * `initialState` does the same thing for the same reason.
67
+ *
68
+ * A fresh `Set` per call, so two callers cannot alias — and, more importantly,
69
+ * so a reducer that mutated one could not corrupt the default for the next
70
+ * mount.
71
+ */
72
+ export function initialTreeView(roots: readonly string[] = ["vault", "repository"]): TreeViewState {
73
+ return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
74
+ }
75
+
76
+ // --- reassembling the wire payload -------------------------------------------------
77
+
78
+ /**
79
+ * The wire payload as the view-models want it.
80
+ *
81
+ * `WireGraphModel` is `Omit<GraphModel, "danglingLinks">` — the payload hoists
82
+ * that map to its own top-level `dangling` rather than shipping it twice
83
+ * (§4.2) — so exactly one field has to be put back before `treeRows` or
84
+ * `detailModel` will accept it. The door (`shared/view.ts`) deliberately does
85
+ * not do this: it is a wire concern, and a door that carried a transformation
86
+ * would be a second implementation rather than a re-export.
87
+ *
88
+ * So it is done here, once, and every caller in the client goes through it.
89
+ */
90
+ export function viewModel(payload: GraphPayload): ViewGraphModel {
91
+ return { ...payload.model, danglingLinks: payload.dangling };
92
+ }
93
+
94
+ /**
95
+ * The rows to render, or `[]` before the first graph arrives.
96
+ *
97
+ * `null` is not an error state — it is the half-second between mount and the
98
+ * first `/api/graph` response, and it happens on every single load. Returning
99
+ * an empty array rather than throwing keeps the caller a `map`.
100
+ */
101
+ export function rowsFor(payload: GraphPayload | null, state: TreeViewState): TreeRow[] {
102
+ if (payload === null) return [];
103
+ return treeRows(viewModel(payload), state);
104
+ }
105
+
106
+ // --- reducers -----------------------------------------------------------------------
107
+
108
+ /**
109
+ * Open or close a row.
110
+ *
111
+ * Returns a **new** state with a new `Set`. Mutating the existing one would be
112
+ * cheaper and would not re-render: Preact's `useState` bails out on
113
+ * `Object.is` equality, so an in-place `expanded.add(id)` produces a correct
114
+ * model and a frozen screen. That failure is silent and maddening, which is
115
+ * why every reducer here copies.
116
+ */
117
+ export function toggleExpanded(state: TreeViewState, id: string): TreeViewState {
118
+ const expanded = new Set(state.expanded);
119
+ if (!expanded.delete(id)) expanded.add(id);
120
+ return { ...state, expanded };
121
+ }
122
+
123
+ /** Force a row open. Idempotent — the right-arrow key's half of the pair. */
124
+ export function expand(state: TreeViewState, id: string): TreeViewState {
125
+ if (state.expanded.has(id)) return state;
126
+ const expanded = new Set(state.expanded);
127
+ expanded.add(id);
128
+ return { ...state, expanded };
129
+ }
130
+
131
+ /** Force a row closed. Idempotent. */
132
+ export function collapse(state: TreeViewState, id: string): TreeViewState {
133
+ if (!state.expanded.has(id)) return state;
134
+ const expanded = new Set(state.expanded);
135
+ expanded.delete(id);
136
+ return { ...state, expanded };
137
+ }
138
+
139
+ /**
140
+ * Set the substring filter.
141
+ *
142
+ * No debounce and no minimum length: `treeRows` is a synchronous walk over an
143
+ * in-memory model of a few hundred nodes, so filtering on every keystroke is
144
+ * cheaper than the timer that would avoid it, and a filter that lags behind
145
+ * the text box is the single most irritating thing a filter can do.
146
+ */
147
+ export function setQuery(state: TreeViewState, query: string): TreeViewState {
148
+ return state.query === query ? state : { ...state, query };
149
+ }
150
+
151
+ /** Advance the provenance filter one step around {@link PROVENANCE_CYCLE}. */
152
+ export function cycleProvenance(state: TreeViewState): TreeViewState {
153
+ const at = PROVENANCE_CYCLE.indexOf(state.provFilter);
154
+ // `indexOf` returns -1 for a value outside the cycle, and `(-1 + 1) % 4` is
155
+ // 0 — which lands on "all". A state that somehow held an unknown filter
156
+ // therefore recovers on the next press instead of sticking.
157
+ const next = PROVENANCE_CYCLE[(at + 1) % PROVENANCE_CYCLE.length] ?? null;
158
+ return { ...state, provFilter: next };
159
+ }
160
+
161
+ /** Show or hide repo plumbing (gitState / external / package / entryPoint). */
162
+ export function toggleInternals(state: TreeViewState): TreeViewState {
163
+ return { ...state, showInternals: !state.showInternals };
164
+ }
165
+
166
+ // --- keyboard navigation --------------------------------------------------------------
167
+
168
+ /** What a key produced: the next state, and the row that should be selected. */
169
+ export interface TreeKeyResult {
170
+ readonly state: TreeViewState;
171
+ readonly selectedId: string | null;
172
+ /** `false` when the key meant nothing here and the browser should keep it. */
173
+ readonly handled: boolean;
174
+ }
175
+
176
+ /**
177
+ * The index of `id` among the currently visible rows, or `-1`.
178
+ *
179
+ * Rows are the flattened, filtered, expansion-resolved list — so "the next
180
+ * row" is genuinely the next thing on screen, not the next sibling in the
181
+ * graph. Deriving the cursor from the id rather than storing an index is the
182
+ * same choice the TUI made (`ExplorerState.selectedId` is the source of truth)
183
+ * and for the same reason: an index goes stale the moment a filter changes the
184
+ * row set, and goes stale *plausibly*, pointing at a real but wrong row.
185
+ */
186
+ export function indexOfRow(rows: readonly TreeRow[], id: string | null): number {
187
+ if (id === null) return -1;
188
+ return rows.findIndex((row) => row.id === id);
189
+ }
190
+
191
+ /**
192
+ * The id at a row index, or `null` when the index is outside the list.
193
+ *
194
+ * Exported and used by every caller below rather than each writing
195
+ * `rows[i]?.id ?? null` inline, and the reason is coverage rather than
196
+ * brevity. `noUncheckedIndexedAccess` makes an index access `T | undefined`
197
+ * even where the surrounding arithmetic has already clamped it into range, so
198
+ * an inline `?? null` is a branch that *cannot* be taken and therefore cannot
199
+ * be covered — a permanent hole in a gate that is supposed to mean something.
200
+ * Funnelling those accesses through one function turns the same check into a
201
+ * branch that is genuinely reachable, because an out-of-range index is a legal
202
+ * argument here and is tested as one.
203
+ */
204
+ export function idAt(rows: readonly TreeRow[], index: number): string | null {
205
+ return rows[index]?.id ?? null;
206
+ }
207
+
208
+ /**
209
+ * Move the cursor `delta` rows, clamped to the ends.
210
+ *
211
+ * Clamped rather than wrapped: wrapping from the last note back to `vault` is
212
+ * disorienting in a tree, where position carries meaning. With nothing
213
+ * selected, a downward move starts at the top and an upward move at the
214
+ * bottom, so the first arrow key after a fresh load always lands somewhere.
215
+ */
216
+ export function moveSelection(rows: readonly TreeRow[], id: string | null, delta: number): string | null {
217
+ if (rows.length === 0) return null;
218
+ const at = indexOfRow(rows, id);
219
+ const from = at === -1 ? (delta > 0 ? -1 : rows.length) : at;
220
+ return idAt(rows, Math.min(rows.length - 1, Math.max(0, from + delta)));
221
+ }
222
+
223
+ /**
224
+ * The id of the row that visually contains `id` — the row above it with a
225
+ * smaller depth.
226
+ *
227
+ * Used by the left arrow on an already-collapsed row, which is the one tree
228
+ * gesture users expect and nobody implements. Scanning upwards for the first
229
+ * shallower row is exact for a flattened tree: whatever that row is, it is the
230
+ * parent, because every row between them is a descendant of it.
231
+ *
232
+ * Written as a reversed `slice` rather than a descending index loop for the
233
+ * reason {@link idAt} exists: iterating yields a `TreeRow`, where indexing
234
+ * yields `TreeRow | undefined` and forces an unreachable guard. The copy is of
235
+ * the rows above the cursor, which is bounded by what is on screen.
236
+ */
237
+ export function parentOf(rows: readonly TreeRow[], id: string): string | null {
238
+ const at = indexOfRow(rows, id);
239
+ // `at <= 0` covers both "not visible" (-1) and "the first row, which has
240
+ // nothing above it" (0), and in either case there is no parent to find.
241
+ const self = at <= 0 ? undefined : rows[at];
242
+ if (self === undefined) return null;
243
+ for (const row of rows.slice(0, at).reverse()) {
244
+ if (row.depth < self.depth) return row.id;
245
+ }
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Vim-ish aliases: `j` is `ArrowDown`, `k` is `ArrowUp` (§11 P4).
251
+ *
252
+ * A *normalizer* rather than two more branches in {@link treeKey}, because
253
+ * the aliasing and the navigation are separate concerns and folding them
254
+ * together would double the arrow tests. Everything below `treeKey`'s first
255
+ * line then works in terms of arrows only.
256
+ *
257
+ * ## Why the aliases are tree-scoped and not global
258
+ *
259
+ * §11 says "vim-ish `j/k` **in the tree**", and the qualifier is load-bearing
260
+ * on both sides. A global `j` would move the tree's cursor while the user is
261
+ * reading the note column — an invisible change to a surface they are not
262
+ * looking at — and it would make `j` untypeable in the graph's depth control.
263
+ * Scoping it here means the tree's own `onKeyDown` applies it, which is only
264
+ * reached when focus is genuinely inside the tree.
265
+ *
266
+ * Case is preserved deliberately: `J` and `K` are Shift-modified keys that
267
+ * vim itself binds to something else, so they are left alone rather than
268
+ * folded onto their lowercase forms.
269
+ */
270
+ export const VIM_KEYS: Readonly<Record<string, string>> = { j: "ArrowDown", k: "ArrowUp" };
271
+
272
+ /** Resolve a vim alias, or return the key unchanged. */
273
+ export function normalizeTreeKey(key: string): string {
274
+ return VIM_KEYS[key] ?? key;
275
+ }
276
+
277
+ /**
278
+ * Apply a key to the tree.
279
+ *
280
+ * `ArrowDown`/`ArrowUp` move, `Home`/`End` jump, `ArrowRight` opens (or
281
+ * descends into an already-open row), `ArrowLeft` closes (or climbs to the
282
+ * parent). That last pair is the whole reason this is a function rather than a
283
+ * switch in the component: each arrow has two behaviours depending on the
284
+ * row's state, which is four branches the coverage gate would never see inside
285
+ * a `.tsx`.
286
+ *
287
+ * `handled: false` for everything else, so the caller knows not to
288
+ * `preventDefault` a key it did not consume — swallowing Tab would trap
289
+ * keyboard users in the column, which is the accessibility bug P4 is meant to
290
+ * be fixing rather than introducing.
291
+ */
292
+ export function treeKey(
293
+ rows: readonly TreeRow[],
294
+ state: TreeViewState,
295
+ selectedId: string | null,
296
+ rawKey: string,
297
+ ): TreeKeyResult {
298
+ const unchanged = { state, selectedId, handled: false } as const;
299
+ const moved = (id: string | null): TreeKeyResult => ({ state, selectedId: id, handled: true });
300
+ const key = normalizeTreeKey(rawKey);
301
+
302
+ if (key === "ArrowDown") return moved(moveSelection(rows, selectedId, 1));
303
+ if (key === "ArrowUp") return moved(moveSelection(rows, selectedId, -1));
304
+ if (key === "Home") return moved(idAt(rows, 0));
305
+ if (key === "End") return moved(idAt(rows, rows.length - 1));
306
+
307
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return unchanged;
308
+
309
+ const at = indexOfRow(rows, selectedId);
310
+ const row = at === -1 ? undefined : rows[at];
311
+ if (row === undefined || selectedId === null) return unchanged;
312
+
313
+ if (key === "ArrowRight") {
314
+ // Closed and has children → open it. Already open → step onto the first
315
+ // child, which is the next row by construction. A leaf does nothing.
316
+ if (!row.hasKids) return unchanged;
317
+ if (!row.expanded) return { state: expand(state, selectedId), selectedId, handled: true };
318
+ return moved(moveSelection(rows, selectedId, 1));
319
+ }
320
+
321
+ // ArrowLeft: open → close it; closed (or a leaf) → climb to the parent.
322
+ if (row.hasKids && row.expanded) return { state: collapse(state, selectedId), selectedId, handled: true };
323
+ const parent = parentOf(rows, selectedId);
324
+ return parent === null ? unchanged : moved(parent);
325
+ }
326
+
327
+ // --- presentation ---------------------------------------------------------------------
328
+
329
+ /**
330
+ * Kind → glyph.
331
+ *
332
+ * A parallel table to the TUI's `kindStyle` (`src/pi/viewer/tui/theme.ts`),
333
+ * which the client tier may not import. The glyphs are deliberately the same
334
+ * characters: someone who has used `/weave-view tui` should recognise the tree
335
+ * in the browser, and two products' worth of icon vocabulary is a cost with no
336
+ * benefit. Colour is *not* duplicated — that stays in the stylesheet, keyed by
337
+ * {@link TreeRowView.kind}.
338
+ */
339
+ const KIND_GLYPHS: Readonly<Record<WireNodeKind, string>> = {
340
+ vault: "◆",
341
+ note: "▪",
342
+ repository: "▣",
343
+ module: "▤",
344
+ package: "◈",
345
+ entryPoint: "▷",
346
+ gitState: "◇",
347
+ external: "↗",
348
+ file: "·",
349
+ };
350
+
351
+ /** The glyph for a node kind. */
352
+ export function kindGlyph(kind: WireNodeKind): string {
353
+ return KIND_GLYPHS[kind];
354
+ }
355
+
356
+ /**
357
+ * Provenance → glyph.
358
+ *
359
+ * `●` human, `◐` agent, `○` generated — the TUI's vocabulary again, and the
360
+ * one place AGENTS.md rule 4 shows up in the browser: agent-written content
361
+ * must never look human-authored, so the marker is a filled/half/hollow shape
362
+ * that survives greyscale, colour-blindness and a screenshot, rather than a
363
+ * colour alone. Structural nodes have no provenance and get nothing.
364
+ */
365
+ const PROVENANCE_GLYPHS: Readonly<Record<WireNoteSource, string>> = {
366
+ human: "●",
367
+ agent: "◐",
368
+ generated: "○",
369
+ };
370
+
371
+ /** The provenance marker for a row, or `""` for a structural node. */
372
+ export function provenanceGlyph(provenance: WireNoteSource | null): string {
373
+ return provenance === null ? "" : PROVENANCE_GLYPHS[provenance];
374
+ }
375
+
376
+ /** The `title=` on the provenance marker. Spells out what the shape means. */
377
+ export function provenanceTitle(provenance: WireNoteSource | null): string {
378
+ return provenance === null ? "" : `${provenance}-authored`;
379
+ }
380
+
381
+ /** The disclosure state of a row: which triangle, or none. */
382
+ export type Twisty = "open" | "closed" | "leaf";
383
+
384
+ /** `▾` / `▸` / nothing. */
385
+ export const TWISTY_GLYPHS: Readonly<Record<Twisty, string>> = { open: "▾", closed: "▸", leaf: "" };
386
+
387
+ // --- ARIA ---------------------------------------------------------------------------
388
+
389
+ /**
390
+ * The `id` attribute for a tree row.
391
+ *
392
+ * Needed because focus lives on the `<ul role="tree">`, not on the rows — a
393
+ * roving `tabindex` would put every row in the Tab order and make Tab a
394
+ * fourth way to walk the tree. The standard alternative is
395
+ * `aria-activedescendant`, which is an **id reference**, so the rows need ids.
396
+ *
397
+ * Derived from the row id with everything outside `[A-Za-z0-9_-]` replaced,
398
+ * for `rowDomId`'s reason in `search.model.ts`: a graph id is
399
+ * `module:src/web/client`, which is a legal HTML `id` and an illegal CSS
400
+ * selector, and an assistive technology that builds a selector from the
401
+ * reference gets nothing. The replacement is injective enough for this — two
402
+ * ids differing only in punctuation would collide, and the graph builder
403
+ * derives ids from slugs and paths, where `:` is always the kind separator.
404
+ */
405
+ export function rowDomId(id: string): string {
406
+ return `weave-row-${id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
407
+ }
408
+
409
+ /** `aria-posinset` / `aria-setsize` for one row. */
410
+ export interface TreePosition {
411
+ readonly posinset: number;
412
+ readonly setsize: number;
413
+ }
414
+
415
+ /**
416
+ * Position-in-set for every row, from the flattened list.
417
+ *
418
+ * A `role="tree"` whose items are not nested in `role="group"` elements is
419
+ * the *flat* tree pattern, and it is the right one here — the rows come from
420
+ * core's `treeRows` already flattened, and rebuilding a nested `<ul>` in the
421
+ * browser would be a second tree structure to keep in agreement with the
422
+ * TUI's. The pattern's cost is that `aria-level` alone tells a screen reader
423
+ * the depth and nothing about extent: "level 2" with no "3 of 7" leaves a
424
+ * user unable to tell a short list from a long one without walking it, which
425
+ * is precisely the orientation a tree is for.
426
+ *
427
+ * ## Why a stack rather than a contiguous run
428
+ *
429
+ * The obvious implementation — "siblings are consecutive rows at the same
430
+ * depth" — is wrong for exactly the shape a tree always has. Given
431
+ *
432
+ * ```text
433
+ * vault depth 0
434
+ * Graph model depth 1
435
+ * Viewer depth 1
436
+ * repository depth 0
437
+ * ```
438
+ *
439
+ * `vault` and `repository` are siblings, and they are *not* consecutive: two
440
+ * children sit between them. A run-based pass reports both as "1 of 1", which
441
+ * is a confidently wrong announcement rather than a missing one. So the open
442
+ * set at each depth is kept on a stack, and a row at depth *d* closes every
443
+ * set deeper than *d* — the standard flattened-tree walk, and the only one
444
+ * that gets the roots right.
445
+ *
446
+ * One pass over the list, because the per-row version is O(rows²) on a
447
+ * repository with a few thousand files.
448
+ */
449
+ export function treePositions(rows: readonly TreeRow[]): TreePosition[] {
450
+ const out: TreePosition[] = rows.map(() => ({ posinset: 1, setsize: 1 }));
451
+ /** Indices of the still-open sibling set at each depth. */
452
+ const open: number[][] = [];
453
+
454
+ /**
455
+ * Close every set deeper than `depth` and write its members' positions.
456
+ *
457
+ * `splice` rather than a `pop` loop, and that is a coverage decision as
458
+ * much as a brevity one: `open.pop()` is `number[] | undefined` under
459
+ * `noUncheckedIndexedAccess` even where the loop guard has already proved
460
+ * the array non-empty, so the `?? []` it needs is a branch that *cannot*
461
+ * be taken and therefore cannot be covered — a permanent hole in a gate
462
+ * that is supposed to mean something. `splice` returns an array of arrays
463
+ * with no such element. The same reasoning as {@link idAt}'s.
464
+ */
465
+ const closeBelow = (depth: number): void => {
466
+ for (const set of open.splice(depth + 1)) {
467
+ for (const [index, at] of set.entries()) out[at] = { posinset: index + 1, setsize: set.length };
468
+ }
469
+ };
470
+
471
+ for (const [index, row] of rows.entries()) {
472
+ closeBelow(row.depth);
473
+ // A gap in depth cannot happen from `treeRows` (a child is always exactly
474
+ // one deeper than its parent), but a truncated payload could produce one,
475
+ // and a missing slot would silently drop the row from every set. Filling
476
+ // up to the depth and then reading the *last* slot keeps the access
477
+ // total: `at(-1)` on an array just proved non-empty still needs a guard
478
+ // under `noUncheckedIndexedAccess`, so the set is pushed and reused
479
+ // instead of re-indexed.
480
+ let set = open[row.depth];
481
+ if (set === undefined) {
482
+ set = [];
483
+ while (open.length < row.depth) open.push([]);
484
+ open.push(set);
485
+ }
486
+ set.push(index);
487
+ }
488
+ closeBelow(-1);
489
+ return out;
490
+ }
491
+
492
+ /** A `TreeRow`, resolved to the strings and flags a list item renders. */
493
+ export interface TreeRowView {
494
+ readonly id: string;
495
+ /** The `id` attribute, for `aria-activedescendant`. */
496
+ readonly domId: string;
497
+ readonly depth: number;
498
+ readonly label: string;
499
+ readonly kind: WireNodeKind;
500
+ readonly kindGlyph: string;
501
+ readonly provenance: WireNoteSource | null;
502
+ readonly provenanceGlyph: string;
503
+ readonly provenanceTitle: string;
504
+ readonly twisty: Twisty;
505
+ readonly twistyGlyph: string;
506
+ readonly hasKids: boolean;
507
+ readonly expanded: boolean;
508
+ readonly selected: boolean;
509
+ /** The trailing annotation, already formatted against `now`. */
510
+ readonly meta: string;
511
+ /** ARIA `aria-level`, which is 1-based where `depth` is 0-based. */
512
+ readonly level: number;
513
+ /** ARIA `aria-posinset`: which sibling this is, 1-based. */
514
+ readonly posinset: number;
515
+ /** ARIA `aria-setsize`: how many siblings there are. */
516
+ readonly setsize: number;
517
+ }
518
+
519
+ /**
520
+ * Resolve one row for rendering.
521
+ *
522
+ * `now` is a parameter rather than a `Date.now()` call, per AGENTS.md: a
523
+ * relative timestamp read off the wall clock is untestable, and this is the
524
+ * function that turns `{kind:"relTime", iso}` into `"12m ago"`.
525
+ */
526
+ export function rowView(row: TreeRow, selectedId: string | null, now: number, position: TreePosition = { posinset: 1, setsize: 1 }): TreeRowView {
527
+ const twisty: Twisty = !row.hasKids ? "leaf" : row.expanded ? "open" : "closed";
528
+ return {
529
+ id: row.id,
530
+ domId: rowDomId(row.id),
531
+ posinset: position.posinset,
532
+ setsize: position.setsize,
533
+ depth: row.depth,
534
+ label: row.label,
535
+ kind: row.kind,
536
+ kindGlyph: kindGlyph(row.kind),
537
+ provenance: row.provenance,
538
+ provenanceGlyph: provenanceGlyph(row.provenance),
539
+ provenanceTitle: provenanceTitle(row.provenance),
540
+ twisty,
541
+ twistyGlyph: TWISTY_GLYPHS[twisty],
542
+ hasKids: row.hasKids,
543
+ expanded: row.expanded,
544
+ selected: row.id === selectedId,
545
+ meta: formatTreeMeta(row.meta, now),
546
+ level: row.depth + 1,
547
+ };
548
+ }
549
+
550
+ /** Resolve a whole row list, positions included. The component's single `map`. */
551
+ export function rowViews(rows: readonly TreeRow[], selectedId: string | null, now: number): TreeRowView[] {
552
+ const positions = treePositions(rows);
553
+ return rows.map((row, index) => rowView(row, selectedId, now, positions[index]));
554
+ }
555
+
556
+ /**
557
+ * `aria-activedescendant` for the tree, or `null`.
558
+ *
559
+ * `null` when the selection is not a *visible* row — the selection is the
560
+ * §1.3 bus and can name a node the tree has filtered away or collapsed under
561
+ * a closed parent. Pointing `aria-activedescendant` at an id that is not in
562
+ * the DOM is worse than omitting it: the attribute is a promise that the
563
+ * element exists, and a screen reader that follows a dangling one announces
564
+ * nothing while the user is certain something is selected.
565
+ */
566
+ export function treeActiveDescendant(rows: readonly TreeRow[], selectedId: string | null): string | null {
567
+ return indexOfRow(rows, selectedId) === -1 ? null : rowDomId(selectedId as string);
568
+ }
569
+
570
+ /**
571
+ * The per-row indent, as a custom property.
572
+ *
573
+ * A number, not a pixel width: the stylesheet multiplies it by an indent step
574
+ * it owns, so the tree's density is a CSS decision and this module only says
575
+ * how deep the row is. Applied through Preact's `style` prop, which reaches
576
+ * the DOM via `CSSStyleDeclaration.setProperty` and is therefore **not**
577
+ * governed by `style-src` — the same CSSOM path `cssvars.ts` documents and
578
+ * verified in `preact/src/diff/props.js`. No `'unsafe-inline'` is involved.
579
+ */
580
+ export function depthVar(depth: number): Readonly<Record<string, string>> {
581
+ return { "--weave-depth": String(Math.max(0, depth)) };
582
+ }
583
+
584
+ // --- the control strip -------------------------------------------------------------
585
+
586
+ /** The provenance button's label. `all` is the unfiltered state. */
587
+ export function provenanceLabel(provenance: WireNoteSource | null): string {
588
+ return provenance === null ? "all" : provenance;
589
+ }
590
+
591
+ /** The provenance button's tooltip — names what pressing it will do next. */
592
+ export function provenanceHint(provenance: WireNoteSource | null): string {
593
+ const at = PROVENANCE_CYCLE.indexOf(provenance);
594
+ const next = PROVENANCE_CYCLE[(at + 1) % PROVENANCE_CYCLE.length] ?? null;
595
+ return `provenance filter: ${provenanceLabel(provenance)} — click for ${provenanceLabel(next)}`;
596
+ }
597
+
598
+ /** The internals toggle's label. */
599
+ export function internalsLabel(showInternals: boolean): string {
600
+ return showInternals ? "internals" : "knowledge";
601
+ }
602
+
603
+ /** The internals toggle's tooltip. */
604
+ export function internalsHint(showInternals: boolean): string {
605
+ return showInternals
606
+ ? "showing git state, packages, externals and entry points — click to hide"
607
+ : "hiding git state, packages, externals and entry points — click to show";
608
+ }
609
+
610
+ /** Placeholder for the filter box. */
611
+ export const FILTER_PLACEHOLDER = "filter…";
612
+
613
+ /**
614
+ * The tree's accessible name.
615
+ *
616
+ * Names *what is in it* rather than what it is: "Tree" is already the
617
+ * column's heading and the role announces the widget type, so a second
618
+ * "tree" would be read three times. "Vault and repository" is the sentence
619
+ * §1.1 uses for the same thing.
620
+ */
621
+ export const TREE_LABEL = "Vault and repository";
622
+
623
+ /** The filter box's `aria-label`, and the hint naming the key that focuses it. */
624
+ export const FILTER_LABEL = "Filter the tree";
625
+
626
+ /** The filter box's `title`. Teaches `/`, which is the global way in. */
627
+ export const FILTER_HINT = "Filter the tree (/)";
628
+
629
+ // --- the empty states ---------------------------------------------------------------
630
+
631
+ /**
632
+ * What the column says instead of rows.
633
+ *
634
+ * Four genuinely different situations, and conflating them is how a UI ends up
635
+ * telling a user their vault is empty because they typed a typo into a filter
636
+ * box. `null` means "there are rows — render them".
637
+ *
638
+ * ## Why core's hint is consulted before the row count
639
+ *
640
+ * The obvious order — "no rows? then work out why" — is wrong here, and it is
641
+ * wrong in the case that matters most. A brand-new vault is not a graph with
642
+ * no rows: `treeRows` still emits the `vault` root, so the column renders one
643
+ * word and nothing else, and a user's first ever session says nothing about
644
+ * how to add a note. `treeEmptyHint` is core's answer to exactly that
645
+ * question, and it is deliberately narrow — it returns a string only for a
646
+ * vault with no notes *and* no repository — so consulting it first cannot
647
+ * suppress a tree that has real content.
648
+ *
649
+ * It also outranks the filter message, for the same reason: when the vault is
650
+ * genuinely empty, "nothing matches this filter" is true and useless.
651
+ *
652
+ * Using core's sentence rather than writing one here is §3: the TUI's empty
653
+ * tree and the browser's say the same thing about the same vault, because
654
+ * there is one sentence.
655
+ */
656
+ export function treeEmptyMessage(
657
+ payload: GraphPayload | null,
658
+ rows: readonly TreeRow[],
659
+ state: TreeViewState,
660
+ ): string | null {
661
+ if (payload === null) return "loading…";
662
+ const hint = treeEmptyHint(viewModel(payload));
663
+ if (hint !== null) return hint;
664
+ if (rows.length > 0) return null;
665
+ if (state.query.length > 0 || state.provFilter !== null) return "nothing matches this filter";
666
+ // No rows, no filter, and core had no opinion — a payload with no roots at
667
+ // all, which the server does not produce but a truncated response could.
668
+ return "nothing to show";
669
+ }
670
+
671
+ /** `34 notes · 127 nodes`-style count line under the tree. Rows, not nodes. */
672
+ export function rowCountLabel(rows: readonly TreeRow[]): string {
673
+ return rows.length === 1 ? "1 row" : `${rows.length} rows`;
674
+ }