@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.
- package/CHANGELOG.md +6 -0
- package/LICENSE.md +8 -0
- package/README.md +20 -0
- package/dist/agent-tools.d.ts +1 -0
- package/dist/agent-tools.js +1 -0
- package/dist/built-in-search-document-providers.d.ts +5 -0
- package/dist/built-in-search-document-providers.js +30 -0
- package/dist/highlighted-text.svelte +41 -0
- package/dist/highlighted-text.svelte.d.ts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/notes-search-command.d.ts +2 -0
- package/dist/notes-search-command.js +8 -0
- package/dist/notes-search-tool.d.ts +8 -0
- package/dist/notes-search-tool.js +84 -0
- package/dist/public-components.d.ts +2 -0
- package/dist/public-components.js +2 -0
- package/dist/search-manager.d.ts +92 -0
- package/dist/search-manager.js +771 -0
- package/dist/search-match-context.d.ts +17 -0
- package/dist/search-match-context.js +45 -0
- package/dist/search-panel.css +460 -0
- package/dist/search-panel.svelte +796 -0
- package/dist/search-panel.svelte.d.ts +11 -0
- package/dist/search-plugin.d.ts +22 -0
- package/dist/search-plugin.js +174 -0
- package/dist/search-result-model.d.ts +29 -0
- package/dist/search-result-model.js +50 -0
- package/dist/search-settings-tab.d.ts +12 -0
- package/dist/search-settings-tab.js +139 -0
- package/dist/search-settings.d.ts +54 -0
- package/dist/search-settings.js +107 -0
- package/dist/search-sort.d.ts +8 -0
- package/dist/search-sort.js +36 -0
- package/dist/search-startup.d.ts +3 -0
- package/dist/search-startup.js +4 -0
- package/dist/search-tool-result.css +70 -0
- package/dist/search-tool-result.d.ts +16 -0
- package/dist/search-tool-result.js +111 -0
- package/dist/search-tool-result.svelte +55 -0
- package/dist/search-tool-result.svelte.d.ts +6 -0
- package/dist/search-view-type.d.ts +1 -0
- package/dist/search-view-type.js +1 -0
- package/dist/search-view.d.ts +14 -0
- package/dist/search-view.js +48 -0
- package/dist/styles.css +2 -0
- package/manifest.json +28 -0
- package/package.json +80 -0
- 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>
|