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.
- package/README.md +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- 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
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
|
|
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
|
|
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
|
|
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: "",
|
|
94
|
+
return { query: "", answered: "", hits: [], cursor: 0, loading: false, failed: false };
|
|
271
95
|
}
|
|
272
96
|
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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()
|
|
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
|
|
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
|
}
|