pi-weave 0.3.9 → 0.3.11

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.
@@ -1,20 +1,59 @@
1
- import type { GraphPayload, NoteSearchHit, WireGraphNode, WireNodeKind } from "../../shared/wire";
1
+ import type { GraphPayload, NoteSearchHit, WireGraphNode } from "../../shared/wire";
2
+ import { relTime } from "../../shared/view";
3
+ import { ICONS } from "../shell/icons.model";
4
+ import type { IconDef, IconName } from "../shell/icons.model";
2
5
 
3
- export type SearchResultKind = "note" | "node";
6
+ /**
7
+ * The ⌘K palette's decisions: what matched, which group it belongs to, how a
8
+ * scope narrows it, what a row says, and what a key does.
9
+ *
10
+ * `SearchPalette.tsx` renders these and nothing else. Results come from two
11
+ * places: the server's note search (`/api/search`, notes only) and the graph
12
+ * payload the palette already holds, which supplies repository nodes, vault
13
+ * folders and artifacts by label, plus the tag index. Tags are not searchable
14
+ * on the server, so tag hits are derived here from `GraphPayload.tags`, and a
15
+ * query that starts with `#` is a tag query answered from the payload alone.
16
+ */
17
+
18
+ export type SearchResultKind = "note" | "node" | "tag";
19
+
20
+ /** The three result groups, in display order. */
21
+ export type SearchGroup = "notes" | "code" | "tags";
22
+
23
+ /** A scope narrows the list to one group, or shows them all. */
24
+ export type SearchScope = "all" | SearchGroup;
25
+
26
+ /** A result's icon: a sprite glyph, or the palette's own tag glyph. */
27
+ export type SearchIcon = IconName | "tag";
4
28
 
5
29
  export interface SearchResult {
6
30
  readonly id: string;
7
31
  readonly kind: SearchResultKind;
32
+ readonly group: SearchGroup;
33
+ readonly icon: SearchIcon;
8
34
  readonly label: string;
35
+ /** Raw text the snippet is cut from: a search snippet, a preview, a path. */
9
36
  readonly detail: string;
37
+ /** What the thing is, in words; the right-hand meta for code rows. */
10
38
  readonly badge: string;
11
39
  readonly score: number;
40
+ /** Vault-relative path for notes and artifacts, `""` otherwise. */
41
+ readonly path: string;
42
+ /** ISO time of the last change, `""` when unknown. */
43
+ readonly updated: string;
12
44
  }
13
45
 
14
46
  export const MAX_RESULTS = 20;
15
47
  export const MAX_EVIDENCE = 10;
16
48
  export const DEBOUNCE_MS = 140;
17
49
 
50
+ export const SEARCH_GROUPS: readonly SearchGroup[] = ["notes", "code", "tags"];
51
+ export const SEARCH_SCOPES: readonly SearchScope[] = ["all", ...SEARCH_GROUPS];
52
+ export const SCOPE_LABELS: Readonly<Record<SearchScope, string>> = { all: "All", notes: "Notes", code: "Code", tags: "Tags" };
53
+
54
+ /** Id prefixes of the vault-side nodes the graph builder emits besides notes. */
55
+ export const VAULT_ID_PREFIXES: readonly string[] = ["note:", "vfolder:", "artifact:"];
56
+
18
57
  export function labelScore(label: string, query: string): number {
19
58
  const q = query.trim().toLowerCase();
20
59
  if (!q) return 0;
@@ -52,8 +91,41 @@ export function nodeDetail(node: WireGraphNode): string {
52
91
  return "";
53
92
  }
54
93
 
55
- export function nodeBadge(kind: WireNodeKind): string {
56
- return kind;
94
+ /** Prose a node carries, preferred over its locator as snippet text. */
95
+ export const NODE_TEXT_KEYS: readonly string[] = ["preview", "summary", "description"];
96
+
97
+ /** The text a node row's snippet is cut from; empty when it would only repeat the label. */
98
+ export function nodeText(node: WireGraphNode): string {
99
+ for (const key of NODE_TEXT_KEYS) {
100
+ const value = node.detail[key];
101
+ if (typeof value === "string" && value) return value;
102
+ }
103
+ const detail = nodeDetail(node);
104
+ return detail === node.label ? "" : detail;
105
+ }
106
+
107
+ const KIND_WORDS: Readonly<Record<string, string>> = {
108
+ entryPoint: "entry point",
109
+ gitState: "git",
110
+ external: "remote",
111
+ };
112
+
113
+ /** A node's kind in words. Vault folders and artifacts are named for what they are, not the kind they reuse. */
114
+ export function nodeBadge(node: WireGraphNode): string {
115
+ if (node.id.startsWith("vfolder:")) return "folder";
116
+ if (node.id.startsWith("artifact:")) return "artifact";
117
+ return KIND_WORDS[node.kind] ?? node.kind;
118
+ }
119
+
120
+ /** Notes for everything in the vault, code for everything in the repository. */
121
+ export function nodeGroup(node: WireGraphNode): SearchGroup {
122
+ if (node.kind === "vault") return "notes";
123
+ return VAULT_ID_PREFIXES.some((prefix) => node.id.startsWith(prefix)) ? "notes" : "code";
124
+ }
125
+
126
+ function nodePath(node: WireGraphNode): string {
127
+ if (node.id.startsWith("note:")) return node.id.slice("note:".length);
128
+ return node.id.startsWith("artifact:") ? node.id.slice("artifact:".length) : "";
57
129
  }
58
130
 
59
131
  export function compareResults(a: SearchResult, b: SearchResult): number {
@@ -62,36 +134,288 @@ export function compareResults(a: SearchResult, b: SearchResult): number {
62
134
  return a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
63
135
  }
64
136
 
137
+ // --- tags ------------------------------------------------------------------------
138
+
139
+ /** The tag a `#` query asks for (possibly `""`), or `null` for an ordinary query. */
140
+ export function tagQuery(query: string): string | null {
141
+ const q = query.trim();
142
+ return q.startsWith("#") ? q.slice(1).trim() : null;
143
+ }
144
+
145
+ /** What to ask the server; `""` means do not ask (no query, or a tag query the payload answers). */
146
+ export function serverQuery(query: string): string {
147
+ return tagQuery(query) === null ? query.trim() : "";
148
+ }
149
+
150
+ export function noteCountLabel(count: number): string {
151
+ return count === 1 ? "1 note" : `${count} notes`;
152
+ }
153
+
154
+ /**
155
+ * Tag hits from the payload's tag index. A bare `#` lists every tag; any other
156
+ * query ranks by the tag name. Equal scores keep the index's order, which is
157
+ * count descending (§4.3), so popular tags come first.
158
+ */
159
+ export function tagResults(tags: Readonly<Record<string, readonly string[]>>, query: string): SearchResult[] {
160
+ const tag = tagQuery(query);
161
+ const out: SearchResult[] = [];
162
+ for (const [name, slugs] of Object.entries(tags)) {
163
+ const score = tag === "" ? 1 : labelScore(name, tag ?? query);
164
+ if (score) out.push({ id: `tag:${name}`, kind: "tag", group: "tags", icon: "tag", label: `#${name}`, detail: noteCountLabel(slugs.length), badge: "tag", score, path: "", updated: "" });
165
+ }
166
+ return out.sort((a, b) => b.score - a.score);
167
+ }
168
+
169
+ /** slug → the best score of any matching tag it carries. */
170
+ export function taggedSlugs(tags: Readonly<Record<string, readonly string[]>>, tag: string): Map<string, number> {
171
+ const out = new Map<string, number>();
172
+ for (const [name, slugs] of Object.entries(tags)) {
173
+ const score = labelScore(name, tag);
174
+ if (!score) continue;
175
+ for (const slug of slugs) out.set(slug, Math.max(score, out.get(slug) ?? 0));
176
+ }
177
+ return out;
178
+ }
179
+
180
+ // --- merging -----------------------------------------------------------------------
181
+
182
+ function noteResult(hit: NoteSearchHit, query: string): SearchResult {
183
+ const { slug, title, updated } = hit.summary;
184
+ return { id: noteNodeId(slug), kind: "note", group: "notes", icon: "note", label: title, detail: hit.snippet, badge: "note", score: noteScore(hit, query), path: slug, updated };
185
+ }
186
+
187
+ function nodeResult(node: WireGraphNode, score: number): SearchResult {
188
+ return {
189
+ id: node.id,
190
+ kind: "node",
191
+ group: nodeGroup(node),
192
+ icon: node.kind,
193
+ label: node.label,
194
+ detail: nodeText(node),
195
+ badge: nodeBadge(node),
196
+ score,
197
+ path: nodePath(node),
198
+ updated: node.detail["updated"] ?? "",
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Every result, grouped (notes, code, tags) and ranked within each group,
204
+ * each group capped at {@link MAX_RESULTS}.
205
+ *
206
+ * A `#` query skips label matching: its notes are the ones carrying a
207
+ * matching tag, and nothing from the repository matches a tag.
208
+ */
65
209
  export function mergeResults(
66
210
  hits: readonly NoteSearchHit[],
67
211
  hitQuery: string,
68
212
  nodes: readonly WireGraphNode[],
69
213
  nodeQuery: string,
214
+ tags: Readonly<Record<string, readonly string[]>> = {},
70
215
  ): SearchResult[] {
216
+ const tag = tagQuery(nodeQuery);
217
+ const tagged = tag ? taggedSlugs(tags, tag) : new Map<string, number>();
71
218
  const byId = new Map<string, SearchResult>();
72
219
  for (const hit of hits) {
73
- const id = noteNodeId(hit.summary.slug);
74
- byId.set(id, { id, kind: "note", label: hit.summary.title, detail: hit.snippet, badge: "note", score: noteScore(hit, hitQuery) });
220
+ const result = noteResult(hit, hitQuery);
221
+ byId.set(result.id, result);
75
222
  }
76
223
  for (const node of nodes) {
77
224
  if (byId.has(node.id)) continue;
78
- const score = nodeScore(node, nodeQuery);
79
- if (score) byId.set(node.id, { id: node.id, kind: "node", label: node.label, detail: nodeDetail(node), badge: nodeBadge(node.kind), score });
225
+ const score = tag === null ? nodeScore(node, nodeQuery) : (node.kind === "note" ? tagged.get(nodePath(node)) ?? 0 : 0);
226
+ if (score) byId.set(node.id, nodeResult(node, score));
227
+ }
228
+ const ranked = [...byId.values()].sort(compareResults);
229
+ const inGroup = (group: SearchGroup): SearchResult[] => ranked.filter((result) => result.group === group).slice(0, MAX_RESULTS);
230
+ return [...inGroup("notes"), ...inGroup("code"), ...tagResults(tags, nodeQuery).slice(0, MAX_RESULTS)];
231
+ }
232
+
233
+ // --- snippets ----------------------------------------------------------------------
234
+
235
+ /**
236
+ * `**TLDR:**` and its spellings, as the notepad skill writes it at the top of
237
+ * a finalized note. It must read as a label, bold or followed by a colon, so
238
+ * prose that merely mentions a TLDR is not mistaken for one.
239
+ */
240
+ const TLDR_RE = /(?:\*\*|__)TL;?DR[ \t]*:?[ \t]*(?:\*\*|__)[ \t]*:?|\bTL;?DR[ \t]*:/i;
241
+
242
+ /**
243
+ * The TLDR in a piece of note text, or `null`.
244
+ *
245
+ * It runs to the first blank line or heading. Server snippets have their
246
+ * whitespace collapsed, so the heading is often the only boundary left; the
247
+ * row ellipsizes whatever follows anyway.
248
+ */
249
+ export function tldrOf(text: string): string | null {
250
+ const match = TLDR_RE.exec(text);
251
+ if (match === null) return null;
252
+ const rest = text.slice(match.index + match[0].length);
253
+ const end = rest.search(/\n[ \t]*\n|(?:^|\s)#{1,6}\s/);
254
+ const tldr = (end < 0 ? rest : rest.slice(0, end)).trim();
255
+ return tldr === "" ? null : tldr;
256
+ }
257
+
258
+ const CODE_MARK = "\u0000";
259
+
260
+ /**
261
+ * Markdown as one line of plain text, for a snippet.
262
+ *
263
+ * Server snippets are cut out of a body and have their whitespace collapsed,
264
+ * so this works on fragments: wikilinks and emphasis may be cut at either end,
265
+ * and line-start syntax (headings, bullets) can sit mid-line. Code spans are
266
+ * set aside first so their contents are never read as emphasis.
267
+ */
268
+ export function plainText(markdown: string): string {
269
+ const code: string[] = [];
270
+ return markdown
271
+ .replace(/`{3,}[^\s`]*/g, " ")
272
+ .replace(/`([^`\n]*)`/g, (_match, text: string) => `${CODE_MARK}${code.push(text) - 1}${CODE_MARK}`)
273
+ .replace(/!?\[\[([^\][|]+)(?:\|([^\]]*))?\]\]/g, (_match, target: string, alias: string | undefined) => (alias || target).trim())
274
+ .replace(/^(…?)(?:[^[\]|]*\|)?([^[\]]*)\]\]/, "$1$2")
275
+ .replace(/\[\[([^\]|]*)(?:\|([^\]]*))?$/, (_match, target: string, alias: string | undefined) => alias || target)
276
+ .replace(/\[\[|\]\]/g, "")
277
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
278
+ .replace(/<\/?[a-z][^>]*>/gi, " ")
279
+ .replace(/(^|\s)#{1,6}(?=\s)/g, "$1")
280
+ .replace(/(^|\n)[ \t]*>[ \t]?/g, "$1")
281
+ .replace(/(^|\s)([-*_])(?:[ \t]?\2){2,}(?=\s|$)/g, "$1")
282
+ .replace(/(^|\s)[-*+][ \t]+\[[ xX]\][ \t]*/g, "$1")
283
+ .replace(/(^|\n)[ \t]*(?:[-*+]|\d+[.)])[ \t]+/g, "$1")
284
+ .replace(/(\s)\*(?=\s)/g, "$1")
285
+ .replace(/(\*\*|~~)(?=\S)([\s\S]*?\S)\1/g, "$2")
286
+ .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, "$1$2")
287
+ .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2")
288
+ .replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2")
289
+ .replace(/\*{2,}|~~/g, "")
290
+ .replace(/\\([\\`*_#[\]|])/g, "$1")
291
+ .replace(new RegExp(`${CODE_MARK}(\\d+)${CODE_MARK}`, "g"), (_match, at: string) => code[Number(at)] as string)
292
+ .replace(/\s+/g, " ")
293
+ .trim();
294
+ }
295
+
296
+ /** The text a snippet shows: the note's TLDR when the text has one, else the text itself. */
297
+ export function snippetText(raw: string): string {
298
+ return plainText(tldrOf(raw) ?? raw);
299
+ }
300
+
301
+ /** Shorter query words are too common to be worth marking. */
302
+ export const MIN_TERM = 2;
303
+
304
+ /** What to mark: the whole query, and each of its words. A leading `#` is a tag sigil, not text. */
305
+ export function highlightTerms(query: string): string[] {
306
+ const q = query.trim().replace(/^#/, "").trim();
307
+ if (!q) return [];
308
+ return [...new Set([q, ...q.split(/\s+/).filter((word) => word.length >= MIN_TERM)])];
309
+ }
310
+
311
+ export type MatchRange = readonly [start: number, end: number];
312
+
313
+ /** Case-insensitive matches of the query in `text`, sorted and merged. */
314
+ export function matchRanges(text: string, query: string): MatchRange[] {
315
+ const found: Array<[number, number]> = [];
316
+ for (const term of highlightTerms(query)) {
317
+ const re = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
318
+ for (const match of text.matchAll(re)) found.push([match.index, match.index + match[0].length]);
80
319
  }
81
- return [...byId.values()].sort(compareResults).slice(0, MAX_RESULTS);
320
+ found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
321
+ const merged: Array<[number, number]> = [];
322
+ for (const range of found) {
323
+ const last = merged[merged.length - 1];
324
+ if (last !== undefined && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
325
+ else merged.push(range);
326
+ }
327
+ return merged;
328
+ }
329
+
330
+ export interface SnippetPart {
331
+ readonly text: string;
332
+ readonly mark: boolean;
333
+ }
334
+
335
+ /** A first match further in than this is brought forward, so the row's ellipsis cannot hide it. */
336
+ export const SNIPPET_LEAD = 40;
337
+ /** How much text is kept before a match that was brought forward. */
338
+ export const SNIPPET_CONTEXT = 16;
339
+
340
+ /** Split `text` at `ranges` into plain and marked parts. */
341
+ export function toParts(text: string, ranges: readonly MatchRange[]): SnippetPart[] {
342
+ const parts: SnippetPart[] = [];
343
+ let at = 0;
344
+ for (const [start, end] of ranges) {
345
+ if (start > at) parts.push({ text: text.slice(at, start), mark: false });
346
+ parts.push({ text: text.slice(start, end), mark: true });
347
+ at = end;
348
+ }
349
+ if (at < text.length) parts.push({ text: text.slice(at), mark: false });
350
+ return parts;
351
+ }
352
+
353
+ /**
354
+ * The snippet a row shows, as parts to mark up.
355
+ *
356
+ * Markdown is reduced to plain text, a TLDR is preferred, and when the first
357
+ * match sits past {@link SNIPPET_LEAD} the text before it is cut back to a
358
+ * word boundary {@link SNIPPET_CONTEXT} characters ahead of it, behind a `…`.
359
+ */
360
+ export function snippetParts(raw: string, query: string): SnippetPart[] {
361
+ const text = snippetText(raw);
362
+ const ranges = matchRanges(text, query);
363
+ const first = ranges[0];
364
+ if (first === undefined || first[0] <= SNIPPET_LEAD) return toParts(text, ranges);
365
+ const cut = first[0] - SNIPPET_CONTEXT;
366
+ const space = text.indexOf(" ", cut);
367
+ const from = space >= 0 && space < first[0] ? space + 1 : cut;
368
+ const shift = from - 1;
369
+ return toParts(`…${text.slice(from)}`, ranges.map(([start, end]) => [start - shift, end - shift] as const));
82
370
  }
83
371
 
372
+ // --- row labels ----------------------------------------------------------------------
373
+
374
+ /** The folder a vault path sits in, `""` at the top level. */
375
+ export function folderOf(path: string): string {
376
+ return path.split("/").slice(0, -1).join("/");
377
+ }
378
+
379
+ /** A compact age: `2h`, `3d`, `just now`. */
380
+ export function ageLabel(iso: string, now: number): string {
381
+ return relTime(iso, now).replace(/ ago$/, "");
382
+ }
383
+
384
+ /**
385
+ * The muted text at a row's right edge: `folder · age` for notes and
386
+ * artifacts, the kind for anything else. Code would show `line N`, but the
387
+ * graph carries no line numbers, so code rows name their kind instead.
388
+ * Tag rows carry their note count in the snippet and need none.
389
+ */
390
+ export function resultMeta(result: SearchResult, now: number): string {
391
+ if (result.kind === "tag") return "";
392
+ if (!result.updated) return result.badge;
393
+ return [folderOf(result.path), ageLabel(result.updated, now)].filter((part) => part !== "").join(" · ");
394
+ }
395
+
396
+ /** The magnifier at the head of the input row. */
397
+ export const SEARCH_GLYPH: IconDef = { d: ["M11.25 7a4.25 4.25 0 1 1-8.5 0 4.25 4.25 0 0 1 8.5 0Z", "M10.25 10.25 13.5 13.5"], filled: false };
398
+ /** A luggage tag with its eyelet, for tag rows. */
399
+ export const TAG_GLYPH: IconDef = { d: ["M2.75 2.75h5l5.5 5.5-5 5-5.5-5.5Z", "M5.5 5.5h.01"], filled: false };
400
+
401
+ export function iconDef(icon: SearchIcon): IconDef {
402
+ return icon === "tag" ? TAG_GLYPH : ICONS[icon];
403
+ }
404
+
405
+ // --- state and keys ------------------------------------------------------------------
406
+
84
407
  export interface SearchState {
85
408
  readonly query: string;
86
409
  readonly answered: string;
87
410
  readonly hits: readonly NoteSearchHit[];
88
411
  readonly cursor: number;
412
+ readonly scope: SearchScope;
89
413
  readonly loading: boolean;
90
414
  readonly failed: boolean;
91
415
  }
92
416
 
93
417
  export function initialSearchState(): SearchState {
94
- return { query: "", answered: "", hits: [], cursor: 0, loading: false, failed: false };
418
+ return { query: "", answered: "", hits: [], cursor: 0, scope: "all", loading: false, failed: false };
95
419
  }
96
420
 
97
421
  export function clampCursor(cursor: number, count: number): number {
@@ -102,10 +426,6 @@ export function wrapCursor(cursor: number, delta: number, count: number): number
102
426
  return count <= 0 ? 0 : (((cursor + delta) % count) + count) % count;
103
427
  }
104
428
 
105
- export function resultIdAt(results: readonly SearchResult[], index: number | null): string | null {
106
- return index === null ? null : (results[index]?.id ?? null);
107
- }
108
-
109
429
  export interface SearchKeyResult {
110
430
  readonly cursor: number;
111
431
  readonly activate: number | null;
@@ -125,43 +445,230 @@ export function searchKey(cursor: number, key: string, count: number): SearchKey
125
445
  return { ...unchanged, cursor: here, activate: here, dismiss: true, handled: true };
126
446
  }
127
447
 
448
+ export type ScopeCounts = Readonly<Record<SearchScope, number>>;
449
+
450
+ export function scopeCounts(results: readonly SearchResult[]): ScopeCounts {
451
+ const count = (group: SearchGroup): number => results.filter((result) => result.group === group).length;
452
+ return { all: results.length, notes: count("notes"), code: count("code"), tags: count("tags") };
453
+ }
454
+
455
+ /** All, then every group that has results. */
456
+ export function visibleScopes(counts: ScopeCounts): SearchScope[] {
457
+ return SEARCH_SCOPES.filter((scope) => scope === "all" || counts[scope] > 0);
458
+ }
459
+
460
+ /** The chosen scope while it has results; All once it has none. */
461
+ export function effectiveScope(scope: SearchScope, counts: ScopeCounts): SearchScope {
462
+ return visibleScopes(counts).includes(scope) ? scope : "all";
463
+ }
464
+
465
+ /** The next visible scope after `scope` (Tab), or before it (Shift+Tab), wrapping. */
466
+ export function cycleScope(visible: readonly SearchScope[], scope: SearchScope, backwards: boolean): SearchScope {
467
+ return visible[wrapCursor(Math.max(visible.indexOf(scope), 0), backwards ? -1 : 1, visible.length)] ?? "all";
468
+ }
469
+
128
470
  export const PALETTE_TITLE = "Search the workspace";
129
471
  export const PALETTE_PLACEHOLDER = "Search notes and repository…";
130
- export const PALETTE_HINT = "↑↓ move · ↵ open · esc close";
472
+ export const PALETTE_HINT = "↑↓ move · ↵ open · ⌘↵ open in new tab · Tab scope";
473
+ export const SCOPE_GROUP_LABEL = "Search scope";
474
+ export const LIGHT_UP_KEY = "⇧↵";
475
+
476
+ export function lightUpLabel(count: number): string {
477
+ return `Light up ${count} in the graph`;
478
+ }
131
479
 
132
480
  export function searchStatus(state: SearchState, count: number): string | null {
133
- if (!state.query.trim()) return "Type to search notes, modules, files and entry points.";
481
+ if (!state.query.trim()) return "Type to search notes and code. Start with # to find tags.";
134
482
  if (count > 0) return null;
135
483
  if (state.failed) return "search failed — the workspace server may be gone";
136
484
  if (state.loading) return "searching…";
137
485
  return `no matches for “${state.query.trim()}”`;
138
486
  }
139
487
 
140
- export function resultCountLabel(count: number): string {
141
- return count === 1 ? "1 result" : `${count} results`;
142
- }
488
+ // --- the view ------------------------------------------------------------------------
143
489
 
144
- export interface SearchRowView extends SearchResult {
490
+ export interface SearchRowView {
491
+ readonly id: string;
492
+ readonly kind: SearchResultKind;
493
+ readonly group: SearchGroup;
494
+ readonly label: string;
495
+ readonly icon: IconDef;
496
+ /** Paths read better in the mono face. */
497
+ readonly mono: boolean;
498
+ readonly parts: readonly SnippetPart[];
499
+ readonly meta: string;
145
500
  readonly active: boolean;
146
501
  readonly domId: string;
502
+ /** Position in the flat list the arrow keys walk. */
503
+ readonly index: number;
504
+ }
505
+
506
+ export interface SearchGroupView {
507
+ readonly group: SearchGroup;
508
+ /** `Notes · 4`; the sheet sets it in capitals. */
509
+ readonly heading: string;
510
+ readonly domId: string;
511
+ readonly rows: readonly SearchRowView[];
512
+ }
513
+
514
+ export interface ScopeView {
515
+ readonly scope: SearchScope;
516
+ readonly label: string;
517
+ readonly count: number;
518
+ readonly active: boolean;
147
519
  }
148
520
 
149
521
  export interface PaletteModel {
522
+ readonly groups: readonly SearchGroupView[];
523
+ /** The visible rows as one list, across groups. */
150
524
  readonly rows: readonly SearchRowView[];
525
+ /** Empty while there is no query. */
526
+ readonly scopes: readonly ScopeView[];
527
+ readonly scope: SearchScope;
151
528
  readonly status: string | null;
152
529
  readonly count: number;
153
- readonly countLabel: string;
154
530
  readonly cursor: number;
155
531
  readonly activeDomId: string | null;
532
+ /** The trimmed query. */
533
+ readonly query: string;
534
+ /**
535
+ * The ids of results that are graph nodes, whatever the scope: what lighting
536
+ * up shows. They go to the graph with the query, so a note found by its text
537
+ * lights too, not only one whose title or tags carry the words.
538
+ */
539
+ readonly lightUp: readonly string[];
156
540
  }
157
541
 
158
542
  export function rowDomId(index: number): string {
159
543
  return `weave-search-row-${index}`;
160
544
  }
161
545
 
162
- export function paletteModel(state: SearchState, payload: GraphPayload | null): PaletteModel {
163
- const results = mergeResults(state.hits, state.answered, payload?.model.nodes ?? [], state.query);
164
- const cursor = clampCursor(state.cursor, results.length);
165
- const rows = results.map((result, index) => ({ ...result, active: index === cursor, domId: rowDomId(index) }));
166
- return { rows, status: searchStatus(state, results.length), count: results.length, countLabel: resultCountLabel(results.length), cursor, activeDomId: results.length ? rowDomId(cursor) : null };
546
+ export function groupDomId(group: SearchGroup): string {
547
+ return `weave-search-group-${group}`;
548
+ }
549
+
550
+ export function rowView(result: SearchResult, index: number, cursor: number, query: string, now: number): SearchRowView {
551
+ return {
552
+ id: result.id,
553
+ kind: result.kind,
554
+ group: result.group,
555
+ label: result.label,
556
+ icon: iconDef(result.icon),
557
+ mono: result.group === "code",
558
+ parts: snippetParts(result.detail, query),
559
+ meta: resultMeta(result, now),
560
+ active: index === cursor,
561
+ domId: rowDomId(index),
562
+ index,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * What ⇧↵ lights. Server hits answer `answered`, which lags the typed query
568
+ * by the debounce and the request; until the server catches up only the
569
+ * graph's own matches for the query as typed light, so ⇧↵ never lights
570
+ * another query's notes.
571
+ */
572
+ function lightUpIds(state: SearchState, payload: GraphPayload | null, results: readonly SearchResult[]): string[] {
573
+ const current = state.answered === serverQuery(state.query)
574
+ ? results
575
+ : mergeResults([], "", payload?.model.nodes ?? [], state.query, payload?.tags ?? {});
576
+ return current.filter((result) => result.kind !== "tag").map((result) => result.id);
577
+ }
578
+
579
+ export function paletteModel(state: SearchState, payload: GraphPayload | null, now: number): PaletteModel {
580
+ const query = state.query.trim();
581
+ const results = mergeResults(state.hits, state.answered, payload?.model.nodes ?? [], state.query, payload?.tags ?? {});
582
+ const counts = scopeCounts(results);
583
+ const scope = effectiveScope(state.scope, counts);
584
+ const visible = scope === "all" ? results : results.filter((result) => result.group === scope);
585
+ const cursor = clampCursor(state.cursor, visible.length);
586
+ const rows = visible.map((result, index) => rowView(result, index, cursor, state.query, now));
587
+ const groups = SEARCH_GROUPS.flatMap((group) => {
588
+ const inGroup = rows.filter((row) => row.group === group);
589
+ return inGroup.length ? [{ group, heading: `${SCOPE_LABELS[group]} · ${inGroup.length}`, domId: groupDomId(group), rows: inGroup }] : [];
590
+ });
591
+ const scopes = query ? visibleScopes(counts).map((each) => ({ scope: each, label: SCOPE_LABELS[each], count: counts[each], active: each === scope })) : [];
592
+ return {
593
+ groups,
594
+ rows,
595
+ scopes,
596
+ scope,
597
+ status: searchStatus(state, visible.length),
598
+ count: visible.length,
599
+ cursor,
600
+ activeDomId: rows.length ? rowDomId(cursor) : null,
601
+ query,
602
+ lightUp: lightUpIds(state, payload, results),
603
+ };
604
+ }
605
+
606
+ // --- intents -------------------------------------------------------------------------
607
+
608
+ /** What a key, click or hover asks the palette to do. */
609
+ export type PaletteIntent =
610
+ | { readonly type: "none" }
611
+ | { readonly type: "move"; readonly cursor: number }
612
+ | { readonly type: "scope"; readonly scope: SearchScope }
613
+ | { readonly type: "open"; readonly id: string; readonly newTab: boolean }
614
+ | { readonly type: "tag"; readonly query: string }
615
+ | { readonly type: "lightUp"; readonly query: string; readonly ids: readonly string[] }
616
+ | { readonly type: "close" };
617
+
618
+ export const NO_INTENT: PaletteIntent = { type: "none" };
619
+
620
+ /**
621
+ * Activating a row. A note or node opens; a tag has nothing to open, so it
622
+ * becomes a `#tag` query, which lists the notes carrying it.
623
+ */
624
+ export function rowIntent(row: Pick<SearchRowView, "id" | "kind">, newTab: boolean): PaletteIntent {
625
+ return row.kind === "tag" ? { type: "tag", query: `#${row.id.slice("tag:".length)}` } : { type: "open", id: row.id, newTab };
626
+ }
627
+
628
+ /** Lighting up the results, when there is a query and something to light. */
629
+ export function lightUpIntent(model: Pick<PaletteModel, "query" | "lightUp">): PaletteIntent {
630
+ return model.query && model.lightUp.length > 0 ? { type: "lightUp", query: model.query, ids: model.lightUp } : NO_INTENT;
631
+ }
632
+
633
+ export interface PaletteKeyInput {
634
+ readonly key: string;
635
+ readonly shift: boolean;
636
+ /** ⌘ or Ctrl. */
637
+ readonly mod: boolean;
638
+ /** The query input has focus. */
639
+ readonly inInput: boolean;
640
+ }
641
+
642
+ /**
643
+ * One key, as an intent.
644
+ *
645
+ * Tab and Shift+Tab cycle the scope while the input has focus and there is
646
+ * more than one scope to cycle; otherwise they are left to the focus trap.
647
+ * Shift+Enter lights the results up in the graph when the shell offers that,
648
+ * and ⌘/Ctrl+Enter opens in a new tab.
649
+ */
650
+ export function paletteKey(model: PaletteModel, input: PaletteKeyInput, canLightUp: boolean): PaletteIntent {
651
+ if (input.key === "Tab") {
652
+ if (!input.inInput || input.mod || model.scopes.length < 2) return NO_INTENT;
653
+ return { type: "scope", scope: cycleScope(model.scopes.map((each) => each.scope), model.scope, input.shift) };
654
+ }
655
+ if (input.key === "Enter" && input.shift && canLightUp) return lightUpIntent(model);
656
+ const next = searchKey(model.cursor, input.key, model.count);
657
+ if (!next.handled) return NO_INTENT;
658
+ if (next.activate !== null) return rowIntent(model.rows[next.activate] as SearchRowView, input.mod);
659
+ return next.dismiss ? { type: "close" } : { type: "move", cursor: next.cursor };
660
+ }
661
+
662
+ /** The state after an intent that keeps the palette open. */
663
+ export function nextState(state: SearchState, intent: PaletteIntent): SearchState {
664
+ // A hover reports a move on every mouse event; an unchanged cursor keeps the state, so it renders nothing.
665
+ if (intent.type === "move") return intent.cursor === state.cursor ? state : { ...state, cursor: intent.cursor };
666
+ if (intent.type === "scope") return { ...state, scope: intent.scope, cursor: 0 };
667
+ if (intent.type === "tag") return { ...state, query: intent.query, scope: "notes", cursor: 0 };
668
+ return state;
669
+ }
670
+
671
+ /** Whether an intent ends with the palette closed. */
672
+ export function closesPalette(intent: PaletteIntent): boolean {
673
+ return intent.type === "open" || intent.type === "lightUp" || intent.type === "close";
167
674
  }