pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,197 +1,67 @@
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
1
  import type { GraphPayload, NoteSearchHit, WireGraphNode, WireNodeKind } from "../../shared/wire";
34
2
 
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
3
  export type SearchResultKind = "note" | "node";
47
4
 
48
- /** One row of the palette, fully resolved for rendering. */
49
5
  export interface SearchResult {
50
- /** The graph node id. What gets written to §1.3's `selectedId`. */
51
6
  readonly id: string;
52
7
  readonly kind: SearchResultKind;
53
- /** The note's title, or the node's label. */
54
8
  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
9
  readonly detail: string;
61
- /** The kind word shown beside the label: `note`, `module`, `file`, … */
62
10
  readonly badge: string;
63
- /** The rank. Higher first. See {@link labelScore}. */
64
11
  readonly score: number;
65
12
  }
66
13
 
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
14
  export const MAX_RESULTS = 20;
15
+ export const MAX_EVIDENCE = 10;
16
+ export const DEBOUNCE_MS = 140;
77
17
 
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
18
  export function labelScore(label: string, query: string): number {
94
19
  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;
20
+ if (!q) return 0;
21
+ const labelLower = label.toLowerCase();
22
+ const index = labelLower.indexOf(q);
23
+ if (index < 0) return 0;
24
+ if (labelLower === q) return 100;
25
+ if (index === 0) return 70;
26
+ return /[a-z0-9]/.test(labelLower.charAt(index - 1)) ? 30 : 50;
105
27
  }
106
28
 
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
29
  export function evidenceScore(score: number): number {
120
30
  return Math.min(Math.max(score, 0), MAX_EVIDENCE) * 2;
121
31
  }
122
32
 
123
- /** The graph node id for a note slug. The one place this shape is written. */
124
33
  export function noteNodeId(slug: string): string {
125
34
  return `note:${slug}`;
126
35
  }
127
36
 
128
- /** Rank a server-side note hit. */
129
37
  export function noteScore(hit: NoteSearchHit, query: string): number {
130
38
  return labelScore(hit.summary.title, query) + evidenceScore(hit.score);
131
39
  }
132
40
 
133
- /** Rank a graph node. Label only — a node has no body to search. */
134
41
  export function nodeScore(node: WireGraphNode, query: string): number {
135
42
  return labelScore(node.label, query);
136
43
  }
137
44
 
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
45
  export const NODE_DETAIL_KEYS: readonly string[] = ["path", "manifest", "url", "slug"];
148
46
 
149
- /** The detail line for a node, or `""`. */
150
47
  export function nodeDetail(node: WireGraphNode): string {
151
48
  for (const key of NODE_DETAIL_KEYS) {
152
49
  const value = node.detail[key];
153
- if (typeof value === "string" && value !== "") return value;
50
+ if (typeof value === "string" && value) return value;
154
51
  }
155
52
  return "";
156
53
  }
157
54
 
158
- /** The badge word for a node kind. The wire kind, verbatim — it is the truth. */
159
55
  export function nodeBadge(kind: WireNodeKind): string {
160
56
  return kind;
161
57
  }
162
58
 
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
59
  export function compareResults(a: SearchResult, b: SearchResult): number {
173
60
  if (a.score !== b.score) return b.score - a.score;
174
61
  if (a.kind !== b.kind) return a.kind === "note" ? -1 : 1;
175
62
  return a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
176
63
  }
177
64
 
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
65
  export function mergeResults(
196
66
  hits: readonly NoteSearchHit[],
197
67
  hitQuery: string,
@@ -199,390 +69,99 @@ export function mergeResults(
199
69
  nodeQuery: string,
200
70
  ): SearchResult[] {
201
71
  const byId = new Map<string, SearchResult>();
202
-
203
72
  for (const hit of hits) {
204
73
  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
- });
74
+ byId.set(id, { id, kind: "note", label: hit.summary.title, detail: hit.snippet, badge: "note", score: noteScore(hit, hitQuery) });
213
75
  }
214
-
215
76
  for (const node of nodes) {
216
77
  if (byId.has(node.id)) continue;
217
78
  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 });
79
+ if (score) byId.set(node.id, { id: node.id, kind: "node", label: node.label, detail: nodeDetail(node), badge: nodeBadge(node.kind), score });
220
80
  }
221
-
222
81
  return [...byId.values()].sort(compareResults).slice(0, MAX_RESULTS);
223
82
  }
224
83
 
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
84
  export interface SearchState {
245
- /** Live text in the input. */
246
85
  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
86
  readonly answered: string;
253
- /** Monotonic request counter. */
254
- readonly seq: number;
255
- /** Highest `seq` whose response has been applied. */
256
- readonly applied: number;
257
87
  readonly hits: readonly NoteSearchHit[];
258
- /** Cursor into the merged result list. Clamped at render, not here. */
259
88
  readonly cursor: number;
260
- /** A debounce timer is armed. */
261
- readonly pending: boolean;
262
- /** A request is in flight. Drives the "searching…" line. */
263
89
  readonly loading: boolean;
264
- /** The last applied response was a failure. */
265
90
  readonly failed: boolean;
266
91
  }
267
92
 
268
- /** A palette that has never been used. */
269
93
  export function initialSearchState(): SearchState {
270
- return { query: "", typedAt: 0, issued: "", answered: "", seq: 0, applied: 0, hits: [], cursor: 0, pending: false, loading: false, failed: false };
94
+ return { query: "", answered: "", hits: [], cursor: 0, loading: false, failed: false };
271
95
  }
272
96
 
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 };
97
+ export function clampCursor(cursor: number, count: number): number {
98
+ return count <= 0 ? 0 : Math.min(Math.max(cursor, 0), count - 1);
321
99
  }
322
100
 
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;
101
+ export function wrapCursor(cursor: number, delta: number, count: number): number {
102
+ return count <= 0 ? 0 : (((cursor + delta) % count) + count) % count;
332
103
  }
333
104
 
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
- }
105
+ export function resultIdAt(results: readonly SearchResult[], index: number | null): string | null {
106
+ return index === null ? null : (results[index]?.id ?? null);
418
107
  }
419
108
 
420
- // --- keyboard ------------------------------------------------------------------
421
-
422
- /** What a key did in the palette. */
423
109
  export interface SearchKeyResult {
424
- readonly state: SearchState;
425
- /** The result index to open, or `null`. */
110
+ readonly cursor: number;
426
111
  readonly activate: number | null;
427
- /** The palette should close. */
428
112
  readonly dismiss: boolean;
429
- /** `false` when the key meant nothing here and the browser should keep it. */
430
113
  readonly handled: boolean;
431
114
  }
432
115
 
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;
116
+ export function searchKey(cursor: number, key: string, count: number): SearchKeyResult {
117
+ const unchanged = { cursor, activate: null, dismiss: false, handled: false } as const;
118
+ const here = clampCursor(cursor, count);
119
+ if (key === "ArrowDown") return { ...unchanged, cursor: wrapCursor(here, 1, count), handled: true };
120
+ if (key === "ArrowUp") return { ...unchanged, cursor: wrapCursor(here, -1, count), handled: true };
121
+ if (key === "Home") return { ...unchanged, cursor: 0, handled: true };
122
+ if (key === "End") return { ...unchanged, cursor: clampCursor(count - 1, count), handled: true };
123
+ if (key === "Escape") return { ...unchanged, dismiss: true, handled: true };
124
+ if (key !== "Enter" || count === 0) return unchanged;
125
+ return { ...unchanged, cursor: here, activate: here, dismiss: true, handled: true };
459
126
  }
460
127
 
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
128
  export const PALETTE_TITLE = "Search the workspace";
499
-
500
- /** The input's placeholder and `aria-label`. */
501
129
  export const PALETTE_PLACEHOLDER = "Search notes and repository…";
502
-
503
- /** The footer hint, teaching the keys the palette responds to. */
504
130
  export const PALETTE_HINT = "↑↓ move · ↵ open · esc close";
505
131
 
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
132
  export function searchStatus(state: SearchState, count: number): string | null {
514
- if (state.query.trim() === "") return "Type to search notes, modules, files and entry points.";
133
+ if (!state.query.trim()) return "Type to search notes, modules, files and entry points.";
515
134
  if (count > 0) return null;
516
135
  if (state.failed) return "search failed — the workspace server may be gone";
517
- if (state.loading || state.pending) return "searching…";
136
+ if (state.loading) return "searching…";
518
137
  return `no matches for “${state.query.trim()}”`;
519
138
  }
520
139
 
521
- /** `8 results` / `1 result`, for the footer count. */
522
140
  export function resultCountLabel(count: number): string {
523
141
  return count === 1 ? "1 result" : `${count} results`;
524
142
  }
525
143
 
526
- // --- the whole palette, resolved ------------------------------------------------
527
-
528
- /** One row, with the presentation flags a list item needs. */
529
144
  export interface SearchRowView extends SearchResult {
530
145
  readonly active: boolean;
531
- /** `id` of the `<li>`, so `aria-activedescendant` can point at it. */
532
146
  readonly domId: string;
533
147
  }
534
148
 
535
- /** Everything `SearchPalette.tsx` renders. Built by {@link paletteModel}. */
536
149
  export interface PaletteModel {
537
150
  readonly rows: readonly SearchRowView[];
538
- /** The message shown instead of rows, or `null`. */
539
151
  readonly status: string | null;
540
152
  readonly count: number;
541
153
  readonly countLabel: string;
542
- /** The clamped cursor. */
543
154
  readonly cursor: number;
544
- /** `aria-activedescendant`, or `null` when there is no active row. */
545
155
  readonly activeDomId: string | null;
546
156
  }
547
157
 
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
158
  export function rowDomId(index: number): string {
558
159
  return `weave-search-row-${index}`;
559
160
  }
560
161
 
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
162
  export function paletteModel(state: SearchState, payload: GraphPayload | null): PaletteModel {
577
163
  const results = mergeResults(state.hits, state.answered, payload?.model.nodes ?? [], state.query);
578
164
  const cursor = clampCursor(state.cursor, results.length);
579
165
  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
- };
166
+ return { rows, status: searchStatus(state, results.length), count: results.length, countLabel: resultCountLabel(results.length), cursor, activeDomId: results.length ? rowDomId(cursor) : null };
588
167
  }