@lapis-notes/search 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +20 -0
  4. package/dist/agent-tools.d.ts +1 -0
  5. package/dist/agent-tools.js +1 -0
  6. package/dist/built-in-search-document-providers.d.ts +5 -0
  7. package/dist/built-in-search-document-providers.js +30 -0
  8. package/dist/highlighted-text.svelte +41 -0
  9. package/dist/highlighted-text.svelte.d.ts +11 -0
  10. package/dist/index.d.ts +11 -0
  11. package/dist/index.js +9 -0
  12. package/dist/notes-search-command.d.ts +2 -0
  13. package/dist/notes-search-command.js +8 -0
  14. package/dist/notes-search-tool.d.ts +8 -0
  15. package/dist/notes-search-tool.js +84 -0
  16. package/dist/public-components.d.ts +2 -0
  17. package/dist/public-components.js +2 -0
  18. package/dist/search-manager.d.ts +92 -0
  19. package/dist/search-manager.js +771 -0
  20. package/dist/search-match-context.d.ts +17 -0
  21. package/dist/search-match-context.js +45 -0
  22. package/dist/search-panel.css +460 -0
  23. package/dist/search-panel.svelte +796 -0
  24. package/dist/search-panel.svelte.d.ts +11 -0
  25. package/dist/search-plugin.d.ts +22 -0
  26. package/dist/search-plugin.js +174 -0
  27. package/dist/search-result-model.d.ts +29 -0
  28. package/dist/search-result-model.js +50 -0
  29. package/dist/search-settings-tab.d.ts +12 -0
  30. package/dist/search-settings-tab.js +139 -0
  31. package/dist/search-settings.d.ts +54 -0
  32. package/dist/search-settings.js +107 -0
  33. package/dist/search-sort.d.ts +8 -0
  34. package/dist/search-sort.js +36 -0
  35. package/dist/search-startup.d.ts +3 -0
  36. package/dist/search-startup.js +4 -0
  37. package/dist/search-tool-result.css +70 -0
  38. package/dist/search-tool-result.d.ts +16 -0
  39. package/dist/search-tool-result.js +111 -0
  40. package/dist/search-tool-result.svelte +55 -0
  41. package/dist/search-tool-result.svelte.d.ts +6 -0
  42. package/dist/search-view-type.d.ts +1 -0
  43. package/dist/search-view-type.js +1 -0
  44. package/dist/search-view.d.ts +14 -0
  45. package/dist/search-view.js +48 -0
  46. package/dist/styles.css +2 -0
  47. package/manifest.json +28 -0
  48. package/package.json +80 -0
  49. package/styles.css +1 -0
@@ -0,0 +1,796 @@
1
+ <script lang="ts">
2
+ import "./search-panel.css";
3
+ import {
4
+ canCollectSearchQueryTerms,
5
+ collectSearchQueryTerms,
6
+ createVaultSearchFilterSyntax,
7
+ parseSearchQueryAst,
8
+ searchQueryLanguageSupport,
9
+ Notice,
10
+ type App,
11
+ type EditorPosition,
12
+ type TFile,
13
+ TextFileView,
14
+ WorkspaceLeaf,
15
+ } from "@lapis-notes/api";
16
+ import { SearchFilterBar } from "@lapismd/design-core/filter";
17
+ import {
18
+ FilterCommandPicker,
19
+ type FilterCommandOption,
20
+ } from "@lapismd/design-core/forms/filter-command-picker";
21
+ import { Badge } from "@lapismd/design-core/shadcn/badge";
22
+ import { Button } from "@lapismd/design-core/shadcn/button";
23
+ import * as Collapsible from "@lapismd/design-core/shadcn/collapsible";
24
+ import * as Popover from "@lapismd/design-core/shadcn/popover";
25
+ import { ScrollArea } from "@lapismd/design-core/shadcn/scroll-area";
26
+ import * as Sidebar from "@lapismd/design-core/shadcn/sidebar";
27
+ import { Switch } from "@lapismd/design-core/shadcn/switch";
28
+ import ChevronDown from "@lucide/svelte/icons/chevron-down";
29
+ import ChevronRight from "@lucide/svelte/icons/chevron-right";
30
+ import ChevronUp from "@lucide/svelte/icons/chevron-up";
31
+ import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
32
+ import Copy from "@lucide/svelte/icons/copy";
33
+ import FileText from "@lucide/svelte/icons/file-text";
34
+ import Hash from "@lucide/svelte/icons/hash";
35
+ import RefreshCw from "@lucide/svelte/icons/refresh-cw";
36
+ import { onMount, untrack } from "svelte";
37
+ import HighlightedText from "./highlighted-text.svelte";
38
+ import {
39
+ expandSearchMatchContext,
40
+ sliceSearchMatchContext,
41
+ type SearchMatchContextDirection,
42
+ } from "./search-match-context";
43
+ import type { SearchManager, SearchRuntimeStatus } from "./search-manager";
44
+ import {
45
+ SEARCH_VIEW_SORT_OPTIONS,
46
+ type SearchPluginSettings,
47
+ type SearchPluginSettingsPatch,
48
+ type SearchResultFacet,
49
+ type SearchRetrievalMode,
50
+ resolveSearchRetrievalModeForQuery,
51
+ resolveSearchSnippetLength,
52
+ } from "./search-settings";
53
+ import { formatSearchViewSortLabel, sortSearchResults } from "./search-sort";
54
+ import {
55
+ searchResultFromHit,
56
+ type SearchMatch,
57
+ type SearchResult,
58
+ } from "./search-result-model";
59
+ import { SearchViewType } from "./search-view-type";
60
+
61
+ type SearchPanelPlugin = {
62
+ searchManager: SearchManager;
63
+ getSettings(): SearchPluginSettings;
64
+ updateSettings(patch: SearchPluginSettingsPatch): Promise<void>;
65
+ refreshIndex(reason?: string): Promise<SearchRuntimeStatus>;
66
+ };
67
+
68
+ const RESULT_FACET_OPTIONS = [
69
+ { value: "all", label: "All" },
70
+ { value: "markdown", label: "Markdown" },
71
+ { value: "canvas", label: "Canvas" },
72
+ ] satisfies FilterCommandOption[];
73
+
74
+ const RETRIEVAL_MODE_OPTIONS = [
75
+ { value: "auto", label: "Auto" },
76
+ { value: "lexical", label: "Lexical" },
77
+ { value: "vector", label: "Vector" },
78
+ { value: "hybrid", label: "Hybrid" },
79
+ ] satisfies FilterCommandOption[];
80
+
81
+ let {
82
+ app,
83
+ initialQuery = "",
84
+ }: {
85
+ app: App;
86
+ initialQuery?: string;
87
+ } = $props();
88
+
89
+ const plugin = untrack(() => {
90
+ const registered = app.plugins.plugins.get("search") as
91
+ | SearchPanelPlugin
92
+ | undefined;
93
+ if (!registered) throw new Error("Search plugin is not registered");
94
+ return registered;
95
+ });
96
+
97
+ let query = $state("");
98
+ let settings = $state(untrack(() => plugin.getSettings()));
99
+ let results = $state<SearchResult[]>([]);
100
+ let resultCount = $state(0);
101
+ let resultOpenState = $state<Record<string, boolean>>({});
102
+ let contextLoading = $state<Record<string, boolean>>({});
103
+ let resultIdentity = "";
104
+ let searching = $state(false);
105
+ let indexing = $state(false);
106
+ let filtersExpanded = $state(false);
107
+ let metadataTags = $state<string[]>([]);
108
+ let metadataFacetError = $state<string | null>(null);
109
+ let metadataFacetGeneration = 0;
110
+ let searchRevision = 0;
111
+ const databaseRefreshDelayMs = 75;
112
+
113
+ const parsedQuery = $derived(parseSearchQueryAst(query));
114
+ const diagnostic = $derived(
115
+ query.trim() ? (parsedQuery.diagnostics[0]?.message ?? null) : null,
116
+ );
117
+ const structuredQuery = $derived(
118
+ Boolean(
119
+ query.trim() &&
120
+ parsedQuery.diagnostics.length === 0 &&
121
+ !canCollectSearchQueryTerms(parsedQuery),
122
+ ),
123
+ );
124
+ const searchRequestKey = $derived(
125
+ JSON.stringify([
126
+ query,
127
+ settings.view.matchCase,
128
+ settings.view.showMoreContext,
129
+ settings.view.semanticSearchInStructuredQueries,
130
+ settings.view.retrievalMode,
131
+ ]),
132
+ );
133
+ const explanation = $derived.by(() => {
134
+ if (!query.trim() || !settings.view.explainSearchTerms) return null;
135
+ const terms = (
136
+ canCollectSearchQueryTerms(parsedQuery)
137
+ ? collectSearchQueryTerms(parsedQuery)
138
+ : query.trim().split(/\s+/u).filter(Boolean)
139
+ )
140
+ .slice(0, 8)
141
+ .join(", ");
142
+ return terms
143
+ ? `Matching filenames, paths, tags, metadata, and content for: ${terms}.`
144
+ : "Matching filenames, paths, tags, metadata, and content.";
145
+ });
146
+ const filteredResults = $derived.by(() => {
147
+ const facet = settings.view.resultFacet;
148
+ const matching = results.filter((result) => {
149
+ if (facet === "all") return true;
150
+ if (facet === "canvas") return result.file.extension === "canvas";
151
+ return ["md", "markdown"].includes(result.file.extension);
152
+ });
153
+ return sortSearchResults(matching, settings.view.sortMode);
154
+ });
155
+ const filterSyntax = $derived.by(() => {
156
+ const files = app.vault.getFiles();
157
+ const paths = [
158
+ ...new Set(files.map((file) => file.parent?.path).filter(Boolean)),
159
+ ]
160
+ .sort()
161
+ .slice(0, 100) as string[];
162
+ const names = files
163
+ .map((file) => file.name)
164
+ .sort()
165
+ .slice(0, 100);
166
+ return createVaultSearchFilterSyntax({
167
+ fileNames: names,
168
+ paths,
169
+ tags: metadataTags,
170
+ });
171
+ });
172
+
173
+ async function refreshMetadataFacets(): Promise<void> {
174
+ const generation = ++metadataFacetGeneration;
175
+ metadataFacetError = null;
176
+ try {
177
+ const rows = await app.metadataCache.queryFacets({
178
+ kind: "tag",
179
+ limit: 100,
180
+ });
181
+ if (generation !== metadataFacetGeneration) return;
182
+ metadataTags = rows
183
+ .flatMap((row) =>
184
+ typeof row.value === "string"
185
+ ? [`#${row.value.replace(/^#/u, "")}`]
186
+ : [],
187
+ )
188
+ .sort();
189
+ } catch (error) {
190
+ if (generation !== metadataFacetGeneration) return;
191
+ metadataFacetError =
192
+ error instanceof Error ? error.message : String(error);
193
+ }
194
+ }
195
+
196
+ export function setSearchQuery(next: string): void {
197
+ query = next;
198
+ }
199
+
200
+ function retrievalModeForQuery(): SearchRetrievalMode {
201
+ return resolveSearchRetrievalModeForQuery(
202
+ settings.view.retrievalMode,
203
+ structuredQuery,
204
+ settings.view.semanticSearchInStructuredQueries,
205
+ );
206
+ }
207
+
208
+ function snippetLength(): number {
209
+ return resolveSearchSnippetLength(
210
+ settings.query.snippetLength,
211
+ settings.view.showMoreContext,
212
+ );
213
+ }
214
+
215
+ function refreshOpenState(items: SearchResult[], identity: string): void {
216
+ if (identity === resultIdentity) return;
217
+ resultIdentity = identity;
218
+ const defaultOpen = !settings.view.collapseResults;
219
+ resultOpenState = Object.fromEntries(
220
+ items.map((item) => [item.file.path, defaultOpen]),
221
+ );
222
+ }
223
+
224
+ function setResultOpen(path: string, open: boolean): void {
225
+ resultOpenState = { ...resultOpenState, [path]: open };
226
+ }
227
+
228
+ async function rememberSearch(value: string): Promise<void> {
229
+ const term = value.trim();
230
+ if (!term) return;
231
+ const recentSearches = [
232
+ term,
233
+ ...settings.view.recentSearches.filter((item) => item !== term),
234
+ ].slice(0, 10);
235
+ if (
236
+ recentSearches.length === settings.view.recentSearches.length &&
237
+ recentSearches.every(
238
+ (item, index) => item === settings.view.recentSearches[index],
239
+ )
240
+ ) {
241
+ return;
242
+ }
243
+ await patchSettings({ view: { recentSearches } });
244
+ }
245
+
246
+ async function patchSettings(
247
+ patch: SearchPluginSettingsPatch,
248
+ ): Promise<void> {
249
+ await plugin.updateSettings(patch);
250
+ settings = plugin.getSettings();
251
+ if (patch.view?.collapseResults !== undefined) {
252
+ const defaultOpen = !settings.view.collapseResults;
253
+ resultOpenState = Object.fromEntries(
254
+ results.map((result) => [result.file.path, defaultOpen]),
255
+ );
256
+ }
257
+ }
258
+
259
+ async function executeSearch(term: string, revision: number): Promise<void> {
260
+ if (!term.trim() || diagnostic) {
261
+ results = [];
262
+ resultCount = 0;
263
+ searching = false;
264
+ refreshOpenState([], term.trim());
265
+ return;
266
+ }
267
+ searching = true;
268
+ try {
269
+ const response = await plugin.searchManager.query({
270
+ term,
271
+ caseSensitive: settings.view.matchCase,
272
+ snippetLength: snippetLength(),
273
+ mode: retrievalModeForQuery(),
274
+ });
275
+ if (revision !== searchRevision) return;
276
+ const nextResults = response.hits
277
+ .map((hit) => searchResultFromHit(app, hit))
278
+ .filter((result): result is SearchResult => result !== null);
279
+ resultCount = response.count;
280
+ const identity = `${term}\u0000${nextResults
281
+ .map(
282
+ (result) =>
283
+ `${result.file.path}:${result.hit.document.checksum}:${result.matches.length}`,
284
+ )
285
+ .join("\u0000")}`;
286
+ if (identity !== resultIdentity) results = nextResults;
287
+ refreshOpenState(nextResults, identity);
288
+ void rememberSearch(term);
289
+ } finally {
290
+ if (revision === searchRevision) searching = false;
291
+ }
292
+ }
293
+
294
+ $effect(() => {
295
+ const requestKey = searchRequestKey;
296
+ const term = query;
297
+ const revision = ++searchRevision;
298
+ const timer = window.setTimeout(() => {
299
+ if (requestKey !== searchRequestKey) return;
300
+ void executeSearch(term, revision);
301
+ }, 250);
302
+ return () => window.clearTimeout(timer);
303
+ });
304
+
305
+ async function refreshIndex(): Promise<void> {
306
+ if (indexing) return;
307
+ indexing = true;
308
+ try {
309
+ await plugin.refreshIndex("search-panel");
310
+ const revision = ++searchRevision;
311
+ await executeSearch(query, revision);
312
+ } finally {
313
+ indexing = false;
314
+ }
315
+ }
316
+
317
+ async function copySearchResults(): Promise<void> {
318
+ const lines = filteredResults.flatMap((result) => [
319
+ result.file.path,
320
+ ...result.matches.map((match) => ` ${match.key}: ${match.text}`),
321
+ ]);
322
+ const text = lines.join("\n");
323
+ try {
324
+ await navigator.clipboard.writeText(text);
325
+ new Notice("Search results copied");
326
+ } catch {
327
+ const textarea = document.createElement("textarea");
328
+ textarea.value = text;
329
+ textarea.style.position = "fixed";
330
+ textarea.style.opacity = "0";
331
+ document.body.append(textarea);
332
+ textarea.select();
333
+ const copied = document.execCommand("copy");
334
+ textarea.remove();
335
+ new Notice(
336
+ copied ? "Search results copied" : "Unable to copy search results",
337
+ );
338
+ }
339
+ }
340
+
341
+ function mainLeaf(): WorkspaceLeaf {
342
+ const selected = app.workspace.rootSplit.iterateAllTabs((tabs) => {
343
+ const child = tabs.children[tabs.selectedIndex] ?? tabs.children[0];
344
+ return child instanceof WorkspaceLeaf ? child : child?.getSelectedLeaf();
345
+ });
346
+ return selected?.view.getViewType() === SearchViewType
347
+ ? app.workspace.getLeaf("tab")
348
+ : (selected ?? app.workspace.getLeaf("tab"));
349
+ }
350
+
351
+ async function openResult(
352
+ result: SearchResult,
353
+ pos?: EditorPosition,
354
+ ): Promise<void> {
355
+ const leaf = mainLeaf();
356
+ await leaf.openFile(result.file);
357
+ app.workspace.activeLeaf = leaf;
358
+ app.workspace.revealLeaf(leaf);
359
+ if (pos && leaf.view instanceof TextFileView)
360
+ leaf.view.editor.setCursor(pos);
361
+ }
362
+
363
+ async function expandMatchContext(
364
+ result: SearchResult,
365
+ match: SearchMatch,
366
+ direction: SearchMatchContextDirection,
367
+ ): Promise<void> {
368
+ if (!match.context) return;
369
+ const loadingKey = `${result.file.path}\u0000${match.id}`;
370
+ if (contextLoading[loadingKey]) return;
371
+ contextLoading = { ...contextLoading, [loadingKey]: true };
372
+ try {
373
+ const indexedSource = result.hit.document.content;
374
+ let source = indexedSource;
375
+ try {
376
+ const currentSource = await app.vault.cachedRead(result.file);
377
+ const expectedSlice = indexedSource.slice(
378
+ match.context.start,
379
+ match.context.end,
380
+ );
381
+ if (
382
+ currentSource.slice(match.context.start, match.context.end) ===
383
+ expectedSlice
384
+ ) {
385
+ source = currentSource;
386
+ }
387
+ } catch {
388
+ // The indexed source remains a valid fallback for the visible result.
389
+ }
390
+
391
+ const expanded = expandSearchMatchContext(
392
+ source,
393
+ match.context,
394
+ direction,
395
+ );
396
+ const sliced = sliceSearchMatchContext(source, expanded);
397
+ results = results.map((currentResult) =>
398
+ currentResult.hit !== result.hit
399
+ ? currentResult
400
+ : {
401
+ ...currentResult,
402
+ matches: currentResult.matches.map((currentMatch) =>
403
+ currentMatch.id === match.id
404
+ ? {
405
+ ...currentMatch,
406
+ ...sliced,
407
+ context: {
408
+ ...expanded,
409
+ sourceLength: source.length,
410
+ },
411
+ }
412
+ : currentMatch,
413
+ ),
414
+ },
415
+ );
416
+ } finally {
417
+ contextLoading = { ...contextLoading, [loadingKey]: false };
418
+ }
419
+ }
420
+
421
+ onMount(() => {
422
+ query = initialQuery;
423
+ void refreshMetadataFacets();
424
+ let databaseRefreshTimer: number | undefined;
425
+ const databaseChanged = app.appDatabase.subscribeToChanges((change) => {
426
+ if (!change.reset && !change.domains.includes("search")) return;
427
+ window.clearTimeout(databaseRefreshTimer);
428
+ databaseRefreshTimer = window.setTimeout(() => {
429
+ const revision = ++searchRevision;
430
+ void executeSearch(query, revision);
431
+ }, databaseRefreshDelayMs);
432
+ });
433
+ const changed = app.metadataCache.on("index-changed", (change) => {
434
+ if (change.reset || change.domains.includes("metadata")) {
435
+ void refreshMetadataFacets();
436
+ }
437
+ });
438
+ const loaded = app.metadataCache.on(
439
+ "loaded",
440
+ () => void refreshMetadataFacets(),
441
+ );
442
+ return () => {
443
+ searchRevision += 1;
444
+ metadataFacetGeneration += 1;
445
+ window.clearTimeout(databaseRefreshTimer);
446
+ databaseChanged();
447
+ app.metadataCache.offref(changed);
448
+ app.metadataCache.offref(loaded);
449
+ };
450
+ });
451
+ </script>
452
+
453
+ <div
454
+ class="search-panel"
455
+ data-testid="search-panel"
456
+ data-ui-component="search-panel"
457
+ >
458
+ <div class="search-panel__chrome" data-ui-part="chrome">
459
+ <div class="search-panel__query-row">
460
+ <SearchFilterBar
461
+ value={query}
462
+ inputMode="filter-query"
463
+ {filterSyntax}
464
+ editorExtensions={[searchQueryLanguageSupport()]}
465
+ error={diagnostic}
466
+ showFilterToggle
467
+ bind:filtersExpanded
468
+ ariaLabel="Search vault"
469
+ placeholder="Search all files…"
470
+ onValueChange={(value) => (query = value)}
471
+ onClearSearch={() => {
472
+ query = "";
473
+ }}
474
+ >
475
+ {#snippet actions()}
476
+ <Button
477
+ variant="ghost"
478
+ size="icon-sm"
479
+ aria-label="Refresh search index"
480
+ disabled={indexing}
481
+ onclick={refreshIndex}
482
+ >
483
+ <RefreshCw aria-hidden="true" />
484
+ </Button>
485
+ {/snippet}
486
+ {#snippet filters()}
487
+ <div class="search-panel__filters">
488
+ <FilterCommandPicker
489
+ label="File type"
490
+ ariaLabel="Filter by file type"
491
+ options={RESULT_FACET_OPTIONS}
492
+ value={settings.view.resultFacet}
493
+ onChange={(value) =>
494
+ patchSettings({
495
+ view: { resultFacet: value as SearchResultFacet },
496
+ })}
497
+ />
498
+ <FilterCommandPicker
499
+ label="Retrieval"
500
+ ariaLabel="Filter by retrieval mode"
501
+ options={RETRIEVAL_MODE_OPTIONS}
502
+ value={settings.view.retrievalMode}
503
+ onChange={(value) =>
504
+ patchSettings({
505
+ view: { retrievalMode: value as SearchRetrievalMode },
506
+ })}
507
+ />
508
+ <section
509
+ class="search-panel__settings"
510
+ aria-label="Search view settings"
511
+ >
512
+ <label>
513
+ <span>Match case</span>
514
+ <Switch
515
+ size="sm"
516
+ bind:checked={
517
+ () => settings.view.matchCase,
518
+ (checked) =>
519
+ void patchSettings({ view: { matchCase: checked } })
520
+ }
521
+ />
522
+ </label>
523
+ <label>
524
+ <span>Collapse results</span>
525
+ <Switch
526
+ size="sm"
527
+ bind:checked={
528
+ () => settings.view.collapseResults,
529
+ (checked) =>
530
+ void patchSettings({ view: { collapseResults: checked } })
531
+ }
532
+ />
533
+ </label>
534
+ <label>
535
+ <span>Show more context</span>
536
+ <Switch
537
+ size="sm"
538
+ bind:checked={
539
+ () => settings.view.showMoreContext,
540
+ (checked) =>
541
+ void patchSettings({ view: { showMoreContext: checked } })
542
+ }
543
+ />
544
+ </label>
545
+ <label>
546
+ <span>Explain search terms</span>
547
+ <Switch
548
+ size="sm"
549
+ bind:checked={
550
+ () => settings.view.explainSearchTerms,
551
+ (checked) =>
552
+ void patchSettings({
553
+ view: { explainSearchTerms: checked },
554
+ })
555
+ }
556
+ />
557
+ </label>
558
+ <label class="search-panel__structured-semantic">
559
+ <span>
560
+ <strong>Semantic search in structured queries</strong>
561
+ <small
562
+ >Allow semantic candidates alongside filters, OR, and
563
+ negation.</small
564
+ >
565
+ </span>
566
+ <Switch
567
+ size="sm"
568
+ aria-label="Semantic search in structured queries"
569
+ bind:checked={
570
+ () => settings.view.semanticSearchInStructuredQueries,
571
+ (checked) =>
572
+ void patchSettings({
573
+ view: { semanticSearchInStructuredQueries: checked },
574
+ })
575
+ }
576
+ />
577
+ </label>
578
+ </section>
579
+ </div>
580
+ {/snippet}
581
+ </SearchFilterBar>
582
+ </div>
583
+ {#if metadataFacetError}
584
+ <p class="search-panel__empty" role="alert">
585
+ Unable to load metadata facets: {metadataFacetError}
586
+ </p>
587
+ {/if}
588
+
589
+ <div class="search-panel__summary">
590
+ <Button
591
+ variant="ghost"
592
+ size="sm"
593
+ class="search-panel__summary-control"
594
+ aria-label="Copy search results"
595
+ disabled={!filteredResults.length}
596
+ onclick={copySearchResults}
597
+ >
598
+ <Copy aria-hidden="true" />
599
+ {searching
600
+ ? "Searching…"
601
+ : `${resultCount} result${resultCount === 1 ? "" : "s"}`}
602
+ </Button>
603
+ <Popover.Root>
604
+ <Popover.Trigger>
605
+ {#snippet child({ props })}
606
+ <Button
607
+ {...props}
608
+ variant="ghost"
609
+ size="sm"
610
+ class="search-panel__summary-control search-panel__sort"
611
+ >
612
+ <span>{formatSearchViewSortLabel(settings.view.sortMode)}</span>
613
+ <ChevronsUpDown aria-hidden="true" />
614
+ </Button>
615
+ {/snippet}
616
+ </Popover.Trigger>
617
+ <Popover.Content align="end" class="search-panel__sort-popover">
618
+ {#each SEARCH_VIEW_SORT_OPTIONS as option (option.value)}
619
+ <Button
620
+ variant={option.value === settings.view.sortMode
621
+ ? "secondary"
622
+ : "ghost"}
623
+ class="search-panel__sort-option"
624
+ onclick={() =>
625
+ void patchSettings({ view: { sortMode: option.value } })}
626
+ >{option.label}</Button
627
+ >
628
+ {/each}
629
+ </Popover.Content>
630
+ </Popover.Root>
631
+ </div>
632
+
633
+ {#if explanation}
634
+ <p class="search-panel__explanation">{explanation}</p>
635
+ {/if}
636
+ </div>
637
+
638
+ <ScrollArea class="search-panel__scroll-area">
639
+ <Sidebar.Content class="search-panel__results" data-ui-part="content">
640
+ {#if !query.trim()}
641
+ <p class="search-panel__empty">Type to search.</p>
642
+ {:else if !searching && !diagnostic && filteredResults.length === 0}
643
+ <p class="search-panel__empty">No matches found.</p>
644
+ {:else}
645
+ <div class="search-panel__tree-inset">
646
+ <Sidebar.Menu
647
+ role="tree"
648
+ aria-label="Search results"
649
+ class="search-panel__tree"
650
+ >
651
+ {#each filteredResults as result (result.file.path)}
652
+ {@const open =
653
+ resultOpenState[result.file.path] ??
654
+ !settings.view.collapseResults}
655
+ <Sidebar.MenuItem role="none" class="search-panel__tree-item">
656
+ <Collapsible.Root
657
+ {open}
658
+ onOpenChange={(next) => setResultOpen(result.file.path, next)}
659
+ class="search-panel__result"
660
+ >
661
+ <Collapsible.Trigger
662
+ class="search-panel__file"
663
+ role="treeitem"
664
+ aria-level={1}
665
+ aria-selected="false"
666
+ aria-expanded={open}
667
+ aria-label={`${result.file.path}, ${result.matches.length} matches`}
668
+ >
669
+ <ChevronRight
670
+ class="search-panel__disclosure"
671
+ aria-hidden="true"
672
+ />
673
+ {#if result.file.extension === "canvas"}
674
+ <Hash
675
+ class="search-panel__file-icon"
676
+ aria-hidden="true"
677
+ />
678
+ {:else}
679
+ <FileText
680
+ class="search-panel__file-icon"
681
+ aria-hidden="true"
682
+ />
683
+ {/if}
684
+ <span class="search-panel__file-label">
685
+ <HighlightedText
686
+ text={result.file.basename}
687
+ ranges={result.title?.ranges ?? []}
688
+ />
689
+ </span>
690
+ <Sidebar.MenuBadge class="search-panel__count-badge">
691
+ {result.matches.length}
692
+ </Sidebar.MenuBadge>
693
+ </Collapsible.Trigger>
694
+ <Collapsible.Content class="search-panel__match-list">
695
+ <div class="search-panel__match-header">
696
+ <span
697
+ class="search-panel__match-path"
698
+ title={result.file.path}
699
+ >
700
+ {result.file.path}
701
+ </span>
702
+ <Badge variant="outline" class="search-panel__mode-badge">
703
+ {result.hit.retrievalMode}
704
+ </Badge>
705
+ </div>
706
+ <Sidebar.MenuSub role="group">
707
+ {#each result.matches as match, index (`${result.file.path}:${index}`)}
708
+ <Sidebar.MenuSubItem role="none">
709
+ {@const loadingKey = `${result.file.path}\u0000${match.id}`}
710
+ <div
711
+ class="search-panel__match-shell"
712
+ role="treeitem"
713
+ tabindex="0"
714
+ aria-level="2"
715
+ aria-selected="false"
716
+ data-context-before={Boolean(match.context?.start)}
717
+ data-context-after={Boolean(
718
+ match.context &&
719
+ match.context.end < match.context.sourceLength,
720
+ )}
721
+ onclick={() => openResult(result, match.pos)}
722
+ onkeydown={(event) => {
723
+ if (event.key !== "Enter" && event.key !== " ")
724
+ return;
725
+ event.preventDefault();
726
+ void openResult(result, match.pos);
727
+ }}
728
+ >
729
+ {#if match.context?.start}
730
+ <Button
731
+ variant="ghost"
732
+ size="icon-xs"
733
+ class="search-panel__context-control search-panel__context-control--before"
734
+ aria-label="Show more context before this match"
735
+ disabled={contextLoading[loadingKey]}
736
+ onclick={(event) => {
737
+ event.stopPropagation();
738
+ void expandMatchContext(
739
+ result,
740
+ match,
741
+ "before",
742
+ );
743
+ }}
744
+ >
745
+ <ChevronUp aria-hidden="true" />
746
+ </Button>
747
+ {/if}
748
+ <div class="search-panel__match">
749
+ <span class="search-panel__match-text">
750
+ <HighlightedText
751
+ text={match.text}
752
+ ranges={match.ranges}
753
+ />
754
+ </span>
755
+ <span class="search-panel__match-meta">
756
+ <Badge
757
+ variant="outline"
758
+ class="search-panel__match-key"
759
+ >
760
+ {match.key}
761
+ </Badge>
762
+ </span>
763
+ </div>
764
+ {#if match.context && match.context.end < match.context.sourceLength}
765
+ <Button
766
+ variant="ghost"
767
+ size="icon-xs"
768
+ class="search-panel__context-control search-panel__context-control--after"
769
+ aria-label="Show more context after this match"
770
+ disabled={contextLoading[loadingKey]}
771
+ onclick={(event) => {
772
+ event.stopPropagation();
773
+ void expandMatchContext(
774
+ result,
775
+ match,
776
+ "after",
777
+ );
778
+ }}
779
+ >
780
+ <ChevronDown aria-hidden="true" />
781
+ </Button>
782
+ {/if}
783
+ </div>
784
+ </Sidebar.MenuSubItem>
785
+ {/each}
786
+ </Sidebar.MenuSub>
787
+ </Collapsible.Content>
788
+ </Collapsible.Root>
789
+ </Sidebar.MenuItem>
790
+ {/each}
791
+ </Sidebar.Menu>
792
+ </div>
793
+ {/if}
794
+ </Sidebar.Content>
795
+ </ScrollArea>
796
+ </div>