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.
- package/README.md +7 -3
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +400 -167
- package/src/web/client/graph/Graph.tsx +148 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +476 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/Shell.tsx +79 -21
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +11 -1
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +260 -61
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/client/trail/TrailStrip.tsx +0 -1
- package/src/web/client/trail/trail.model.ts +61 -6
- package/src/web/client/tree/Tree.tsx +3 -7
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -1,20 +1,59 @@
|
|
|
1
|
-
import type { GraphPayload, NoteSearchHit, WireGraphNode
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
74
|
-
byId.set(id,
|
|
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,
|
|
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
|
-
|
|
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 ·
|
|
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
|
|
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
|
-
|
|
141
|
-
return count === 1 ? "1 result" : `${count} results`;
|
|
142
|
-
}
|
|
488
|
+
// --- the view ------------------------------------------------------------------------
|
|
143
489
|
|
|
144
|
-
export interface SearchRowView
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
}
|