pi-weave 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The ⌘K palette (weave-workspace §1.2, P4).
3
+ *
4
+ * Props in, JSX out. Ranking, merging, debouncing, stale-response rejection,
5
+ * cursor movement and every string are `search.model.ts` and `search.ts`; the
6
+ * focus trap is `focus.model.ts` behind `useFocusTrap`. What is left is a
7
+ * `useState`, one memo and three handlers. §10's rule.
8
+ */
9
+
10
+ import { useMemo, useState } from "preact/hooks";
11
+ import type { GraphPayload } from "../../shared/wire";
12
+ import { useFocusTrap } from "../shell/FocusTrap";
13
+ import type { SearchRowView } from "./search.model";
14
+ import { PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
15
+ import type { SearchOptions } from "./search";
16
+ import { createSearch } from "./search";
17
+
18
+ export interface SearchPaletteProps {
19
+ graph: GraphPayload | null;
20
+ /** The §1.3 context bus. */
21
+ onSelect: (id: string) => void;
22
+ onClose: () => void;
23
+ /** The controller's ports (§10). Supplied by the shell. */
24
+ ports: Omit<SearchOptions, "onChange">;
25
+ }
26
+
27
+ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void; onHover: () => void }) {
28
+ return (
29
+ <li
30
+ id={row.domId}
31
+ role="option"
32
+ aria-selected={row.active}
33
+ class={`weave-hit weave-hit-${row.kind}${row.active ? " weave-hit-on" : ""}`}
34
+ onMouseMove={onHover}
35
+ onClick={onPick}
36
+ >
37
+ <span class="weave-hit-badge">{row.badge}</span>
38
+ <span class="weave-hit-label">{row.label}</span>
39
+ <span class="weave-hit-detail">{row.detail}</span>
40
+ </li>
41
+ );
42
+ }
43
+
44
+ export function SearchPalette(props: SearchPaletteProps) {
45
+ const [state, setState] = useState(initialSearchState);
46
+ const search = useMemo(() => createSearch({ ...props.ports, onChange: setState }), []);
47
+ const trap = useFocusTrap();
48
+ const model = paletteModel(state, props.graph);
49
+
50
+ const pick = (index: number | null): void => {
51
+ const id = resultIdAt(model.rows, index);
52
+ if (id !== null) props.onSelect(id);
53
+ search.dismiss();
54
+ props.onClose();
55
+ };
56
+
57
+ return (
58
+ <div class="weave-scrim" onClick={() => pick(null)}>
59
+ <div
60
+ class="weave-palette"
61
+ role="dialog"
62
+ aria-modal="true"
63
+ aria-label={PALETTE_TITLE}
64
+ tabIndex={-1}
65
+ ref={trap.ref as { current: HTMLDivElement | null }}
66
+ onClick={(event) => event.stopPropagation()}
67
+ onKeyDown={(event) => {
68
+ if (trap.onKeyDown(event as unknown as KeyboardEvent)) return;
69
+ const next = searchKey(state, event.key, model.count);
70
+ if (!next.handled) return;
71
+ event.preventDefault();
72
+ search.setCursor(next.state.cursor);
73
+ if (next.dismiss) pick(next.activate);
74
+ }}
75
+ >
76
+ <input
77
+ type="search"
78
+ class="weave-palette-input"
79
+ autofocus
80
+ value={state.query}
81
+ placeholder={PALETTE_PLACEHOLDER}
82
+ aria-label={PALETTE_TITLE}
83
+ aria-controls="weave-search-results"
84
+ aria-activedescendant={model.activeDomId ?? undefined}
85
+ onInput={(event) => search.setQuery(event.currentTarget.value)}
86
+ />
87
+ {model.status === null ? (
88
+ <ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
89
+ {model.rows.map((row, index) => (
90
+ <Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => search.setCursor(index)} />
91
+ ))}
92
+ </ul>
93
+ ) : (
94
+ <p class="weave-palette-status" role="status">
95
+ {model.status}
96
+ </p>
97
+ )}
98
+ <p class="weave-palette-foot">
99
+ <span>{model.countLabel}</span>
100
+ <span class="weave-palette-hint">{PALETTE_HINT}</span>
101
+ </p>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Everything the ⌘K palette *decides* (weave-workspace §1.1, §1.3, §10, P4).
3
+ *
4
+ * The palette is the one surface in the workspace that spans both faces of
5
+ * the product at once — the vault (notes, searched on the server by
6
+ * `searchNotes`, with snippets) and the repository (modules, files, entry
7
+ * points, which exist only as labels in the graph payload the client already
8
+ * holds). §1.1 says the graph and the tree are *lenses onto the same
9
+ * knowledge*; a search box that could only see one of them would contradict
10
+ * that on the very first keystroke.
11
+ *
12
+ * So this module does four things and `SearchPalette.tsx` does none of them:
13
+ *
14
+ * 1. **Ranks** a note hit and a graph node on one comparable scale
15
+ * ({@link labelScore}, {@link noteScore}, {@link nodeScore}).
16
+ * 2. **Merges and dedupes** the two sources into one ordered list
17
+ * ({@link mergeResults}), because a note is *both* a search hit and a
18
+ * graph node and must appear once.
19
+ * 3. **Debounces** typing and **discards stale responses**
20
+ * ({@link reduceSearch}) — as a reducer over an injected clock, never a
21
+ * timer this module owns.
22
+ * 4. **Moves the cursor** ({@link searchKey}), in the same
23
+ * `handled: false`-for-keys-we-did-not-consume shape as `treeKey`.
24
+ *
25
+ * ## Tier rules (§2)
26
+ *
27
+ * `src/web/client/**`: `src/web/shared` and browser deps only, never
28
+ * `src/core` and never `node:*`. This file additionally names no DOM type at
29
+ * all — the clock arrives as a number — which is what lets the root
30
+ * `tsconfig.json` project (no `DOM` lib) compile the tests that import it.
31
+ */
32
+
33
+ import type { GraphPayload, NoteSearchHit, WireGraphNode, WireNodeKind } from "../../shared/wire";
34
+
35
+ // --- results -------------------------------------------------------------------
36
+
37
+ /**
38
+ * Which half of the workspace a row came from.
39
+ *
40
+ * Two values, not nine. The user is choosing between "a note I wrote" and "a
41
+ * thing in the repository", and the node's precise kind is carried separately
42
+ * in {@link SearchResult.badge} — collapsing that distinction into the kind
43
+ * would make the two sources indistinguishable, and P4's brief is explicit
44
+ * that they must be labelled distinctly.
45
+ */
46
+ export type SearchResultKind = "note" | "node";
47
+
48
+ /** One row of the palette, fully resolved for rendering. */
49
+ export interface SearchResult {
50
+ /** The graph node id. What gets written to §1.3's `selectedId`. */
51
+ readonly id: string;
52
+ readonly kind: SearchResultKind;
53
+ /** The note's title, or the node's label. */
54
+ readonly label: string;
55
+ /**
56
+ * The second line: the server's snippet for a note, the node's path (or
57
+ * URL, or slug) for a node. Empty when the node carries none, in which case
58
+ * the row is one line — better than a placeholder that says nothing.
59
+ */
60
+ readonly detail: string;
61
+ /** The kind word shown beside the label: `note`, `module`, `file`, … */
62
+ readonly badge: string;
63
+ /** The rank. Higher first. See {@link labelScore}. */
64
+ readonly score: number;
65
+ }
66
+
67
+ /**
68
+ * How many rows the palette will show.
69
+ *
70
+ * A cap rather than a scroll region: a one-character query matches most of a
71
+ * repository, and a palette that answers with four hundred rows has answered
72
+ * with nothing. Twenty is roughly a screen, and the ranking below is what
73
+ * makes the truncation safe — the rows that survive are the strongest
74
+ * matches, not the first twenty in graph order.
75
+ */
76
+ export const MAX_RESULTS = 20;
77
+
78
+ // --- ranking -------------------------------------------------------------------
79
+
80
+ /**
81
+ * How well a label matches a query, 0 (not at all) to 100 (exactly).
82
+ *
83
+ * Four tiers, and the gaps between them are deliberate: they are wide enough
84
+ * that no amount of secondary evidence ({@link evidenceScore}, capped at 20)
85
+ * can lift a weaker *kind* of match above a stronger one. A note that merely
86
+ * mentions "layout" in its body must never outrank the module actually called
87
+ * `layout`, and the only way to guarantee that with a summed score is to make
88
+ * the tiers further apart than the addend.
89
+ *
90
+ * Case-insensitive and trimmed, because the query comes from a text input and
91
+ * a trailing space is a typo, not a filter.
92
+ */
93
+ export function labelScore(label: string, query: string): number {
94
+ const q = query.trim().toLowerCase();
95
+ if (q === "") return 0;
96
+ const l = label.toLowerCase();
97
+ const at = l.indexOf(q);
98
+ if (at === -1) return 0;
99
+ if (l === q) return 100;
100
+ if (at === 0) return 70;
101
+ // A match after a separator — `graph/layout` for "layout", `note-model` for
102
+ // "model" — is what a user means far more often than one in the middle of a
103
+ // word, and path-shaped labels are most of this graph.
104
+ return /[a-z0-9]/.test(l.charAt(at - 1)) ? 30 : 50;
105
+ }
106
+
107
+ /**
108
+ * The most a server-side hit's own score may contribute.
109
+ *
110
+ * `searchNotes` scores title 3, tag 2 and up to 5 body occurrences, so its
111
+ * range is 0–10; doubling it gives 0–20, which sits below the weakest
112
+ * {@link labelScore} tier (30) by construction. That is the invariant: body
113
+ * evidence *breaks ties* and surfaces notes whose title says nothing, but it
114
+ * never overturns a label match.
115
+ */
116
+ export const MAX_EVIDENCE = 10;
117
+
118
+ /** A server hit's score, clamped and weighted into the 0–20 band. */
119
+ export function evidenceScore(score: number): number {
120
+ return Math.min(Math.max(score, 0), MAX_EVIDENCE) * 2;
121
+ }
122
+
123
+ /** The graph node id for a note slug. The one place this shape is written. */
124
+ export function noteNodeId(slug: string): string {
125
+ return `note:${slug}`;
126
+ }
127
+
128
+ /** Rank a server-side note hit. */
129
+ export function noteScore(hit: NoteSearchHit, query: string): number {
130
+ return labelScore(hit.summary.title, query) + evidenceScore(hit.score);
131
+ }
132
+
133
+ /** Rank a graph node. Label only — a node has no body to search. */
134
+ export function nodeScore(node: WireGraphNode, query: string): number {
135
+ return labelScore(node.label, query);
136
+ }
137
+
138
+ /**
139
+ * The second line for a graph node.
140
+ *
141
+ * `detail` is a display-only bag the graph builder fills differently per kind
142
+ * (`path` for a module or a file, `url` for an external, `slug` for a note),
143
+ * so this picks the first present in preference order rather than assuming a
144
+ * key that half the kinds do not have. An empty string means "render one
145
+ * line", which is what a `vault` or `gitState` row honestly is.
146
+ */
147
+ export const NODE_DETAIL_KEYS: readonly string[] = ["path", "manifest", "url", "slug"];
148
+
149
+ /** The detail line for a node, or `""`. */
150
+ export function nodeDetail(node: WireGraphNode): string {
151
+ for (const key of NODE_DETAIL_KEYS) {
152
+ const value = node.detail[key];
153
+ if (typeof value === "string" && value !== "") return value;
154
+ }
155
+ return "";
156
+ }
157
+
158
+ /** The badge word for a node kind. The wire kind, verbatim — it is the truth. */
159
+ export function nodeBadge(kind: WireNodeKind): string {
160
+ return kind;
161
+ }
162
+
163
+ /**
164
+ * Order two results.
165
+ *
166
+ * Score first, then notes ahead of nodes, then label, then id. The last two
167
+ * exist so the list is **totally** ordered: a palette whose rows reshuffle
168
+ * between two keystrokes that produced the same scores is one nobody can hit
169
+ * Enter on with confidence, and `Array.prototype.sort` is only stable within
170
+ * one call — the two input arrays are rebuilt from scratch every render.
171
+ */
172
+ export function compareResults(a: SearchResult, b: SearchResult): number {
173
+ if (a.score !== b.score) return b.score - a.score;
174
+ if (a.kind !== b.kind) return a.kind === "note" ? -1 : 1;
175
+ return a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
176
+ }
177
+
178
+ /**
179
+ * Merge the two sources into the ordered, deduped, capped result list.
180
+ *
181
+ * **Notes win a collision.** Every note is also a graph node, so a query
182
+ * matching a note's title matches twice; the hit is kept because it is
183
+ * strictly richer — it carries a snippet and the server's body evidence,
184
+ * where the node carries a label. Dropping the node rather than the hit is
185
+ * also what keeps the counts honest: the palette says "8 results" and there
186
+ * are eight distinct things to select.
187
+ *
188
+ * The two arguments are deliberately scored against *different* queries at
189
+ * the call site — see {@link SearchState}. Graph labels are local and rank
190
+ * against what the user has typed *now*; note hits arrived from a request and
191
+ * rank against the query that request carried. That is the only way local
192
+ * results can be instant while remote ones are debounced, and it is why the
193
+ * query is a parameter here rather than read off the state.
194
+ */
195
+ export function mergeResults(
196
+ hits: readonly NoteSearchHit[],
197
+ hitQuery: string,
198
+ nodes: readonly WireGraphNode[],
199
+ nodeQuery: string,
200
+ ): SearchResult[] {
201
+ const byId = new Map<string, SearchResult>();
202
+
203
+ for (const hit of hits) {
204
+ const id = noteNodeId(hit.summary.slug);
205
+ byId.set(id, {
206
+ id,
207
+ kind: "note",
208
+ label: hit.summary.title,
209
+ detail: hit.snippet,
210
+ badge: "note",
211
+ score: noteScore(hit, hitQuery),
212
+ });
213
+ }
214
+
215
+ for (const node of nodes) {
216
+ if (byId.has(node.id)) continue;
217
+ const score = nodeScore(node, nodeQuery);
218
+ if (score === 0) continue;
219
+ byId.set(node.id, { id: node.id, kind: "node", label: node.label, detail: nodeDetail(node), badge: nodeBadge(node.kind), score });
220
+ }
221
+
222
+ return [...byId.values()].sort(compareResults).slice(0, MAX_RESULTS);
223
+ }
224
+
225
+ // --- state ---------------------------------------------------------------------
226
+
227
+ /**
228
+ * Everything the palette remembers.
229
+ *
230
+ * Three of these fields exist solely to make asynchrony correct, and they are
231
+ * worth naming individually because each defends against a different bug:
232
+ *
233
+ * - `issued` — the query the most recent request carried. Guards against
234
+ * re-issuing an identical request when a stray timer fires.
235
+ * - `seq` / `applied` — a monotonic request counter and the highest one whose
236
+ * response has been used. This is the stale-response guard: two requests in
237
+ * flight can complete in either order, and without `applied` the *slower,
238
+ * older* one overwrites the newer results with answers to a query the user
239
+ * has already finished typing past. That failure is intermittent, invisible
240
+ * in a fast test, and extremely obvious to a user typing at speed.
241
+ * - `pending` — whether a debounce timer is already armed. One chain at a
242
+ * time, so a burst of keystrokes cannot accumulate one timer each.
243
+ */
244
+ export interface SearchState {
245
+ /** Live text in the input. */
246
+ readonly query: string;
247
+ /** Epoch ms of the most recent keystroke. The debounce reads this. */
248
+ readonly typedAt: number;
249
+ /** The query of the last issued request, or `""` when none has been. */
250
+ readonly issued: string;
251
+ /** The query the currently-held {@link hits} answer. */
252
+ readonly answered: string;
253
+ /** Monotonic request counter. */
254
+ readonly seq: number;
255
+ /** Highest `seq` whose response has been applied. */
256
+ readonly applied: number;
257
+ readonly hits: readonly NoteSearchHit[];
258
+ /** Cursor into the merged result list. Clamped at render, not here. */
259
+ readonly cursor: number;
260
+ /** A debounce timer is armed. */
261
+ readonly pending: boolean;
262
+ /** A request is in flight. Drives the "searching…" line. */
263
+ readonly loading: boolean;
264
+ /** The last applied response was a failure. */
265
+ readonly failed: boolean;
266
+ }
267
+
268
+ /** A palette that has never been used. */
269
+ export function initialSearchState(): SearchState {
270
+ return { query: "", typedAt: 0, issued: "", answered: "", seq: 0, applied: 0, hits: [], cursor: 0, pending: false, loading: false, failed: false };
271
+ }
272
+
273
+ /**
274
+ * How long after the last keystroke a request is issued.
275
+ *
276
+ * 140 ms is below the ~200 ms at which a delay starts reading as lag and
277
+ * above a fast typist's inter-key interval, so a word typed at speed costs
278
+ * one request rather than five. The tree's filter box deliberately has *no*
279
+ * debounce (`setQuery` in `tree.model.ts`) and the difference is not
280
+ * inconsistency: that filter is a synchronous walk over memory, this one is a
281
+ * round trip that reads every note in the vault off disk.
282
+ */
283
+ export const DEBOUNCE_MS = 140;
284
+
285
+ /** What the reducer wants done as a result of a transition. */
286
+ export interface SearchRequest {
287
+ readonly seq: number;
288
+ readonly query: string;
289
+ }
290
+
291
+ /**
292
+ * The outcome of one transition.
293
+ *
294
+ * Effects are *returned*, never performed: this module cannot fetch and must
295
+ * not own a timer, so it says what should happen and `search.ts` does it.
296
+ * That is what makes the debounce testable without waiting 140 ms.
297
+ */
298
+ export interface SearchTransition {
299
+ readonly state: SearchState;
300
+ /** Issue this request now. */
301
+ readonly request: SearchRequest | null;
302
+ /** Arm a timer for this many ms, then dispatch a `tick`. */
303
+ readonly schedule: number | null;
304
+ }
305
+
306
+ /** Everything that can happen to the palette. */
307
+ export type SearchEvent =
308
+ /** The text changed. `now` is the clock, injected. */
309
+ | { readonly type: "query"; readonly query: string; readonly now: number }
310
+ /** A debounce timer fired. May issue, may re-arm — see {@link reduceSearch}. */
311
+ | { readonly type: "tick"; readonly now: number }
312
+ | { readonly type: "response"; readonly seq: number; readonly query: string; readonly hits: readonly NoteSearchHit[] }
313
+ | { readonly type: "failed"; readonly seq: number }
314
+ /** Pointer hover, or a programmatic move. */
315
+ | { readonly type: "cursor"; readonly cursor: number }
316
+ /** The palette closed. Disarms the debounce; keeps the results. */
317
+ | { readonly type: "dismiss" };
318
+
319
+ function idle(state: SearchState): SearchTransition {
320
+ return { state, request: null, schedule: null };
321
+ }
322
+
323
+ /**
324
+ * Whether a response may be applied.
325
+ *
326
+ * `>` and not `>=`: a duplicate delivery of the response already applied is
327
+ * as unwelcome as an older one, and `applied` starts at 0 while `seq` starts
328
+ * at 1, so the first response is never rejected.
329
+ */
330
+ export function isFresh(state: SearchState, seq: number): boolean {
331
+ return seq > state.applied;
332
+ }
333
+
334
+ /**
335
+ * The palette's state machine.
336
+ *
337
+ * The debounce is the interesting part, and it is a **trailing** one built
338
+ * from a single re-arming timer rather than a cancel-and-replace:
339
+ *
340
+ * ```text
341
+ * query → record `typedAt`; arm a timer only if none is armed
342
+ * tick → elapsed >= DEBOUNCE_MS ? issue : re-arm for the remainder
343
+ * ```
344
+ *
345
+ * The re-arm branch is what makes one timer sufficient. A keystroke at t=0
346
+ * arms a tick for t=140; a keystroke at t=100 does not arm anything, so the
347
+ * t=140 tick fires with only 40 ms elapsed and re-arms itself for t=240. The
348
+ * request goes out 140 ms after the *last* keystroke, exactly as intended,
349
+ * and at no point are two timers alive. Cancelling would need a handle, which
350
+ * means owning a timer id, which means this module could not be pure.
351
+ *
352
+ * An empty query is not a request. `GET /api/search?q=` is valid and answers
353
+ * with no hits (`api.ts`), but spending a round trip to be told what we
354
+ * already know is worse than clearing locally — and clearing must be
355
+ * immediate, because the alternative is 140 ms of results for a query the box
356
+ * no longer contains.
357
+ */
358
+ export function reduceSearch(state: SearchState, event: SearchEvent): SearchTransition {
359
+ switch (event.type) {
360
+ case "query": {
361
+ // The cursor goes home on every edit: the row under it was chosen from
362
+ // a different result list, and silently re-pointing it at whatever is
363
+ // now third is how a palette opens the wrong thing on Enter.
364
+ const next = { ...state, query: event.query, typedAt: event.now, cursor: 0 };
365
+ if (event.query.trim() === "") {
366
+ // Local, immediate, and it also disarms: there is nothing to fetch.
367
+ return idle({ ...next, hits: [], answered: "", issued: "", loading: false, failed: false, pending: false });
368
+ }
369
+ // Already armed → the running chain will pick this text up on its next
370
+ // tick. That is the whole re-arm mechanism; see the doc comment.
371
+ if (state.pending) return idle(next);
372
+ return { state: { ...next, pending: true }, request: null, schedule: DEBOUNCE_MS };
373
+ }
374
+
375
+ case "tick": {
376
+ if (!state.pending) return idle(state);
377
+ const waited = event.now - state.typedAt;
378
+ if (waited < DEBOUNCE_MS) return { state, request: null, schedule: DEBOUNCE_MS - waited };
379
+ const query = state.query.trim();
380
+ // A stray tick for text we have already asked about, or for an empty
381
+ // box, costs nothing but must not cost a request either.
382
+ if (query === "" || query === state.issued) return idle({ ...state, pending: false });
383
+ const seq = state.seq + 1;
384
+ return { state: { ...state, pending: false, loading: true, seq, issued: query }, request: { seq, query }, schedule: null };
385
+ }
386
+
387
+ case "response": {
388
+ if (!isFresh(state, event.seq)) return idle(state);
389
+ return idle({
390
+ ...state,
391
+ applied: event.seq,
392
+ hits: event.hits,
393
+ answered: event.query,
394
+ // Still loading if a *newer* request is out; the response we just
395
+ // applied is not the one the box is waiting for.
396
+ loading: event.seq < state.seq,
397
+ failed: false,
398
+ cursor: 0,
399
+ });
400
+ }
401
+
402
+ case "failed": {
403
+ if (!isFresh(state, event.seq)) return idle(state);
404
+ // The hits are left alone. Stale results beside a failure notice are
405
+ // more useful than a blank palette, and `api.ts` returns failures as
406
+ // values precisely so this layer can make that choice.
407
+ return idle({ ...state, applied: event.seq, loading: event.seq < state.seq, failed: true });
408
+ }
409
+
410
+ case "cursor":
411
+ return idle({ ...state, cursor: event.cursor });
412
+
413
+ case "dismiss":
414
+ // Disarm, so a timer already in flight lands on `pending: false` and
415
+ // no-ops instead of firing a request at a palette nobody is looking at.
416
+ return idle({ ...state, pending: false });
417
+ }
418
+ }
419
+
420
+ // --- keyboard ------------------------------------------------------------------
421
+
422
+ /** What a key did in the palette. */
423
+ export interface SearchKeyResult {
424
+ readonly state: SearchState;
425
+ /** The result index to open, or `null`. */
426
+ readonly activate: number | null;
427
+ /** The palette should close. */
428
+ readonly dismiss: boolean;
429
+ /** `false` when the key meant nothing here and the browser should keep it. */
430
+ readonly handled: boolean;
431
+ }
432
+
433
+ /**
434
+ * Keep a cursor inside a list.
435
+ *
436
+ * Applied at render rather than stored, for `indexOfRow`'s reason: the result
437
+ * list is rebuilt from a query and a payload that both change underneath the
438
+ * cursor, so an index validated when it was set is not an index that is still
439
+ * valid now.
440
+ */
441
+ export function clampCursor(cursor: number, count: number): number {
442
+ if (count <= 0) return 0;
443
+ return Math.min(Math.max(cursor, 0), count - 1);
444
+ }
445
+
446
+ /**
447
+ * Move the cursor, **wrapping** at both ends.
448
+ *
449
+ * The opposite of `moveSelection` in `tree.model.ts`, which clamps, and the
450
+ * difference is a real one rather than an oversight. A tree is spatial —
451
+ * position carries meaning and jumping from the last note to `vault` is
452
+ * disorienting. A ranked result list is not: it is short, it has no
453
+ * structure, and ↑ from the top to reach the last row is what every palette a
454
+ * user has ever used does.
455
+ */
456
+ export function wrapCursor(cursor: number, delta: number, count: number): number {
457
+ if (count <= 0) return 0;
458
+ return (((cursor + delta) % count) + count) % count;
459
+ }
460
+
461
+ /** The id at a result index, or `null`. Exists for `idAt`'s reason. */
462
+ export function resultIdAt(results: readonly SearchResult[], index: number | null): string | null {
463
+ return index === null ? null : (results[index]?.id ?? null);
464
+ }
465
+
466
+ /**
467
+ * Apply a key inside the palette.
468
+ *
469
+ * `handled: false` for everything else — the same contract `treeKey` states,
470
+ * and for the same reason: this handler sits on an overlay containing a text
471
+ * input, so swallowing anything it did not consume would break typing, Tab,
472
+ * ⌘R and text selection in one go.
473
+ *
474
+ * `Tab` is *deliberately* not listed. The focus trap owns it (`focus.model.ts`),
475
+ * because trapping is a property of the dialog and not of the result list, and
476
+ * two handlers both claiming Tab is how a trap ends up moving focus twice.
477
+ */
478
+ export function searchKey(state: SearchState, key: string, count: number): SearchKeyResult {
479
+ const unchanged: SearchKeyResult = { state, activate: null, dismiss: false, handled: false };
480
+ const at = (cursor: number): SearchKeyResult => ({ state: { ...state, cursor }, activate: null, dismiss: false, handled: true });
481
+ const here = clampCursor(state.cursor, count);
482
+
483
+ if (key === "ArrowDown") return at(wrapCursor(here, 1, count));
484
+ if (key === "ArrowUp") return at(wrapCursor(here, -1, count));
485
+ if (key === "Home") return at(0);
486
+ if (key === "End") return at(clampCursor(count - 1, count));
487
+ if (key === "Escape") return { state, activate: null, dismiss: true, handled: true };
488
+ if (key !== "Enter") return unchanged;
489
+ // Enter on an empty list is not "handled": there is nothing to open, and
490
+ // eating the key would break a form submit if this palette ever sits in one.
491
+ if (count === 0) return unchanged;
492
+ return { state, activate: here, dismiss: true, handled: true };
493
+ }
494
+
495
+ // --- copy ----------------------------------------------------------------------
496
+
497
+ /** The dialog's accessible name, and its visible heading. */
498
+ export const PALETTE_TITLE = "Search the workspace";
499
+
500
+ /** The input's placeholder and `aria-label`. */
501
+ export const PALETTE_PLACEHOLDER = "Search notes and repository…";
502
+
503
+ /** The footer hint, teaching the keys the palette responds to. */
504
+ export const PALETTE_HINT = "↑↓ move · ↵ open · esc close";
505
+
506
+ /**
507
+ * What the palette says instead of rows.
508
+ *
509
+ * Five situations, and conflating them is how a search box ends up telling a
510
+ * user there is nothing in their vault because the server restarted.
511
+ * `null` means "there are rows — render them".
512
+ */
513
+ export function searchStatus(state: SearchState, count: number): string | null {
514
+ if (state.query.trim() === "") return "Type to search notes, modules, files and entry points.";
515
+ if (count > 0) return null;
516
+ if (state.failed) return "search failed — the workspace server may be gone";
517
+ if (state.loading || state.pending) return "searching…";
518
+ return `no matches for “${state.query.trim()}”`;
519
+ }
520
+
521
+ /** `8 results` / `1 result`, for the footer count. */
522
+ export function resultCountLabel(count: number): string {
523
+ return count === 1 ? "1 result" : `${count} results`;
524
+ }
525
+
526
+ // --- the whole palette, resolved ------------------------------------------------
527
+
528
+ /** One row, with the presentation flags a list item needs. */
529
+ export interface SearchRowView extends SearchResult {
530
+ readonly active: boolean;
531
+ /** `id` of the `<li>`, so `aria-activedescendant` can point at it. */
532
+ readonly domId: string;
533
+ }
534
+
535
+ /** Everything `SearchPalette.tsx` renders. Built by {@link paletteModel}. */
536
+ export interface PaletteModel {
537
+ readonly rows: readonly SearchRowView[];
538
+ /** The message shown instead of rows, or `null`. */
539
+ readonly status: string | null;
540
+ readonly count: number;
541
+ readonly countLabel: string;
542
+ /** The clamped cursor. */
543
+ readonly cursor: number;
544
+ /** `aria-activedescendant`, or `null` when there is no active row. */
545
+ readonly activeDomId: string | null;
546
+ }
547
+
548
+ /**
549
+ * The `id` attribute for a result row.
550
+ *
551
+ * A listbox announces its selection through `aria-activedescendant`, which is
552
+ * an **id reference** — so the rows need ids, and they need to be derived
553
+ * from the index rather than from the node id: a graph id contains `:` and
554
+ * `/` (`module:src/web/client`), which are legal in an HTML `id` but not in
555
+ * the CSS selector a screen reader's implementation may build from it.
556
+ */
557
+ export function rowDomId(index: number): string {
558
+ return `weave-search-row-${index}`;
559
+ }
560
+
561
+ /**
562
+ * Resolve the whole palette from its state and the graph the client holds.
563
+ *
564
+ * The one function `SearchPalette.tsx` calls, and the reason that component
565
+ * has no branch in it: ranking, merging, clamping, the empty-state choice and
566
+ * the ARIA id all happen here, where a test can reach them.
567
+ *
568
+ * Note the two queries handed to {@link mergeResults}: `state.answered` for
569
+ * the server hits (the query they actually answer) and `state.query` for the
570
+ * graph nodes (which are local and rank against what is on screen right now).
571
+ * That is what makes the repository half of the palette feel instant while
572
+ * the vault half is debounced, and it is why a mid-flight palette shows
573
+ * matching modules immediately with the notes arriving a moment later rather
574
+ * than showing nothing at all.
575
+ */
576
+ export function paletteModel(state: SearchState, payload: GraphPayload | null): PaletteModel {
577
+ const results = mergeResults(state.hits, state.answered, payload?.model.nodes ?? [], state.query);
578
+ const cursor = clampCursor(state.cursor, results.length);
579
+ const rows = results.map((result, index) => ({ ...result, active: index === cursor, domId: rowDomId(index) }));
580
+ return {
581
+ rows,
582
+ status: searchStatus(state, results.length),
583
+ count: results.length,
584
+ countLabel: resultCountLabel(results.length),
585
+ cursor,
586
+ activeDomId: results.length === 0 ? null : rowDomId(cursor),
587
+ };
588
+ }