@lapis-notes/search 0.1.3 → 0.1.4

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.
@@ -9,17 +9,25 @@ import { createNotesSearchTool } from "./notes-search-tool";
9
9
  import SearchToolResult from "./search-tool-result.svelte";
10
10
  import { reconcileSearchAfterMetadata } from "./search-startup";
11
11
  import manifestSpec from "@lapis-notes/search/manifest.json";
12
+ import { observeRecoveredSearchSettings } from "./search-settings-observer";
12
13
  export class SearchPlugin extends Plugin {
13
14
  settings;
14
15
  startupRefreshStarted = false;
15
16
  startupRefreshPromise = null;
16
17
  providerReconcileReason = null;
17
18
  providerReconcilePromise = null;
18
- searchManager;
19
+ managerStopped = false;
20
+ currentSearchManager;
19
21
  constructor(app, pluginManifest = manifestSpec) {
20
22
  super(app, pluginManifest);
21
23
  this.settings = mergeSearchSettings(null);
22
- this.searchManager = new SearchManager(app, () => this.settings);
24
+ this.currentSearchManager = new SearchManager(app, () => this.settings);
25
+ }
26
+ showNotice(message) {
27
+ new Notice(message, undefined, this.app);
28
+ }
29
+ get searchManager() {
30
+ return this.currentSearchManager;
23
31
  }
24
32
  getSettings() {
25
33
  return {
@@ -33,13 +41,23 @@ export class SearchPlugin extends Plugin {
33
41
  };
34
42
  }
35
43
  async updateSettings(patch) {
36
- const previousProvider = resolveSearchEmbeddingProviderConfig(this.settings);
44
+ const previous = this.settings;
37
45
  this.settings = patchSearchSettings(this.settings, patch);
38
46
  await this.saveData(this.settings);
47
+ await this.reconfigureSettings(previous);
48
+ }
49
+ async reconfigureSettings(previous) {
50
+ const manager = this.searchManager;
51
+ const previousProvider = resolveSearchEmbeddingProviderConfig(previous);
39
52
  const nextProvider = resolveSearchEmbeddingProviderConfig(this.settings);
40
53
  if (JSON.stringify(previousProvider) !== JSON.stringify(nextProvider)) {
41
54
  await this.app.appDatabase.configureSearchEmbeddingProvider(nextProvider);
42
55
  }
56
+ if (this.app.session?.backgroundIndexing &&
57
+ JSON.stringify(previous.chunking) !==
58
+ JSON.stringify(this.settings.chunking)) {
59
+ await manager.configureIndexing();
60
+ }
43
61
  }
44
62
  getSearchStatus() {
45
63
  return this.searchManager.getStatus();
@@ -48,11 +66,35 @@ export class SearchPlugin extends Plugin {
48
66
  return this.searchManager.refreshFromVault("manual-semantic-rebuild");
49
67
  }
50
68
  async onload() {
69
+ if (this.managerStopped) {
70
+ this.currentSearchManager = new SearchManager(this.app, () => this.settings);
71
+ this.managerStopped = false;
72
+ this.startupRefreshStarted = false;
73
+ this.startupRefreshPromise = null;
74
+ this.providerReconcileReason = null;
75
+ this.providerReconcilePromise = null;
76
+ }
51
77
  this.settings = mergeSearchSettings(await this.loadData());
52
78
  await this.app.appDatabase.configureSearchEmbeddingProvider(resolveSearchEmbeddingProviderConfig(this.settings));
79
+ this.register(observeRecoveredSearchSettings(this.app, this.manifest.id, (settings) => {
80
+ if (this.managerStopped)
81
+ return;
82
+ const previous = this.settings;
83
+ this.settings = settings;
84
+ void this.reconfigureSettings(previous).catch((error) => {
85
+ if (this.nativeObserverDetached())
86
+ return;
87
+ this.showNotice(`Search settings recovery failed: ${error instanceof Error ? error.message : String(error)}`);
88
+ });
89
+ }));
53
90
  this.registerEvent(this.app.searchDocumentProviders.on("changed", ({ reason }) => {
54
91
  if (this.state !== "enabled")
55
92
  return;
93
+ if (this.app.session?.backgroundIndexing &&
94
+ this.app.metadataCache.initialized) {
95
+ this.scheduleProviderReconciliation(`provider-${reason}`);
96
+ return;
97
+ }
56
98
  if (!this.app.metadataCache.initialized ||
57
99
  !this.startupRefreshStarted) {
58
100
  return;
@@ -74,7 +116,7 @@ export class SearchPlugin extends Plugin {
74
116
  command: {
75
117
  id: "open-search",
76
118
  name: "Open Search",
77
- callback: (query) => void this.openSearchInLeftSidebar(query),
119
+ callback: (query, scope) => this.openSearchInLeftSidebar(query, scope),
78
120
  },
79
121
  });
80
122
  this.registerEvent(this.app.metadataCache.on("loaded", () => {
@@ -88,7 +130,7 @@ export class SearchPlugin extends Plugin {
88
130
  name: "Rebuild semantic search embeddings",
89
131
  callback: async () => {
90
132
  const status = await this.rebuildSemanticSearch();
91
- new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
133
+ this.showNotice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
92
134
  },
93
135
  });
94
136
  this.addCommand({
@@ -104,9 +146,10 @@ export class SearchPlugin extends Plugin {
104
146
  id: "rebuild-search-index",
105
147
  name: "Rebuild search index",
106
148
  callback: async () => {
107
- await this.app.appDatabase.rebuildSearchIndex();
149
+ if (!this.app.session?.backgroundIndexing)
150
+ await this.app.appDatabase.rebuildSearchIndex();
108
151
  await this.refreshIndex("rebuild");
109
- new Notice("Search index rebuilt");
152
+ this.showNotice("Search index rebuilt");
110
153
  },
111
154
  });
112
155
  this.addCommand({
@@ -118,7 +161,15 @@ export class SearchPlugin extends Plugin {
118
161
  refreshIndex(reason = "manual") {
119
162
  return this.searchManager.refreshFromVault(reason);
120
163
  }
164
+ async disable() {
165
+ if (this.app.session?.backgroundIndexing) {
166
+ this.managerStopped = true;
167
+ await this.searchManager.dispose();
168
+ }
169
+ await super.disable();
170
+ }
121
171
  async onunload() {
172
+ this.managerStopped = true;
122
173
  await this.searchManager.dispose();
123
174
  }
124
175
  startupRefresh() {
@@ -126,44 +177,68 @@ export class SearchPlugin extends Plugin {
126
177
  return this.startupRefreshPromise;
127
178
  if (this.startupRefreshStarted)
128
179
  return Promise.resolve();
180
+ const manager = this.searchManager;
129
181
  this.startupRefreshStarted = true;
130
- this.startupRefreshPromise = reconcileSearchAfterMetadata(this.app, this.searchManager)
182
+ this.startupRefreshPromise = reconcileSearchAfterMetadata(this.app, manager)
131
183
  .then(() => undefined)
132
184
  .catch((error) => {
185
+ if (this.searchManager !== manager)
186
+ return;
133
187
  this.startupRefreshStarted = false;
134
- new Notice(`Search index refresh failed: ${error instanceof Error ? error.message : String(error)}`);
188
+ if (this.nativeObserverDetached())
189
+ return;
190
+ this.showNotice(`Search index refresh failed: ${error instanceof Error ? error.message : String(error)}`);
135
191
  })
136
192
  .finally(() => {
137
- if (!this.startupRefreshStarted)
193
+ if (this.searchManager === manager && !this.startupRefreshStarted)
138
194
  this.startupRefreshPromise = null;
139
195
  });
140
196
  return this.startupRefreshPromise;
141
197
  }
142
198
  scheduleProviderReconciliation(reason) {
199
+ if (this.managerStopped || this.state !== "enabled")
200
+ return;
143
201
  this.providerReconcileReason = reason;
144
202
  if (this.providerReconcilePromise)
145
203
  return;
204
+ const manager = this.searchManager;
146
205
  this.providerReconcilePromise = (async () => {
147
206
  await this.startupRefresh();
148
- while (this.providerReconcileReason) {
207
+ while (this.providerReconcileReason &&
208
+ !this.managerStopped &&
209
+ this.searchManager === manager &&
210
+ this.state === "enabled") {
149
211
  this.providerReconcileReason = null;
150
- await this.searchManager.reconcileStartup();
212
+ await manager.reconcileStartup();
151
213
  }
152
214
  })()
153
215
  .catch((error) => {
154
- new Notice(`Search provider refresh failed: ${error instanceof Error ? error.message : String(error)}`);
216
+ if (this.searchManager !== manager)
217
+ return;
218
+ if (this.nativeObserverDetached())
219
+ return;
220
+ this.showNotice(`Search provider refresh failed: ${error instanceof Error ? error.message : String(error)}`);
155
221
  })
156
222
  .finally(() => {
223
+ if (this.searchManager !== manager)
224
+ return;
157
225
  this.providerReconcilePromise = null;
226
+ if (this.managerStopped || this.state !== "enabled") {
227
+ this.providerReconcileReason = null;
228
+ return;
229
+ }
158
230
  if (this.providerReconcileReason) {
159
231
  this.scheduleProviderReconciliation(this.providerReconcileReason);
160
232
  }
161
233
  });
162
234
  }
163
- async openSearchInLeftSidebar(query = "") {
235
+ async openSearchInLeftSidebar(query = "", scope = "files") {
164
236
  const existing = this.app.workspace.getLeavesOfType(SearchViewType)[0];
165
237
  const target = existing ?? this.app.workspace.ensureSideLeaf(SearchViewType, "left");
166
- await target.setViewState({ type: SearchViewType, state: { query } });
238
+ await target.setViewState({
239
+ type: SearchViewType,
240
+ state: { query, scope },
241
+ });
167
242
  this.app.workspace.activateLeaf(target, {
168
243
  focusRootHost: false,
169
244
  source: "api",
@@ -171,4 +246,11 @@ export class SearchPlugin extends Plugin {
171
246
  });
172
247
  await this.app.workspace.revealLeaf(target);
173
248
  }
249
+ nativeObserverDetached() {
250
+ return Boolean(this.app.session?.backgroundIndexing &&
251
+ (this.app.backgroundIndexing?.detached ||
252
+ this.managerStopped ||
253
+ this.state === "disabling" ||
254
+ this.state === "disabled"));
255
+ }
174
256
  }
@@ -0,0 +1,25 @@
1
+ import { type SearchQueryExpression, type SearchProviderHit, type SearchProviderStatus, type SearchResultProvider, type SearchResultProviderRegistry, type SearchResultRequest } from "@lapis-notes/api";
2
+ export type ProviderResults = {
3
+ provider: SearchResultProvider;
4
+ hits: SearchProviderHit[];
5
+ busy: boolean;
6
+ next?: string;
7
+ generation?: string;
8
+ status?: SearchProviderStatus;
9
+ unavailable?: string;
10
+ };
11
+ type Input = Omit<SearchResultRequest, "query" | "cursor" | "signal">;
12
+ export declare function searchProviderFields(node: SearchQueryExpression | null): string[];
13
+ /** Independent sources publish pages without clearing successful peer results. */
14
+ export declare class SearchProviderController {
15
+ #private;
16
+ readonly registry: SearchResultProviderRegistry;
17
+ readonly report: (error: unknown) => void;
18
+ constructor(registry: SearchResultProviderRegistry, report: (error: unknown) => void);
19
+ subscribe(listener: (rows: ProviderResults[]) => void): () => void;
20
+ search(input: Input, enabled: boolean, retain?: boolean): void;
21
+ more(id: string): void;
22
+ refresh(): void;
23
+ close(): void;
24
+ }
25
+ export {};
@@ -0,0 +1,157 @@
1
+ import { parseSearchQueryAst, } from "@lapis-notes/api";
2
+ export function searchProviderFields(node) {
3
+ if (!node)
4
+ return [];
5
+ switch (node.type) {
6
+ case "field":
7
+ return [node.name, ...searchProviderFields(node.value)];
8
+ case "property":
9
+ return ["property"];
10
+ case "group":
11
+ return searchProviderFields(node.expression);
12
+ case "not":
13
+ return searchProviderFields(node.operand);
14
+ case "and":
15
+ case "or":
16
+ return node.clauses.flatMap(searchProviderFields);
17
+ case "literal":
18
+ return node.kind === "regex" ? ["regex"] : [];
19
+ default:
20
+ return [];
21
+ }
22
+ }
23
+ /** Independent sources publish pages without clearing successful peer results. */
24
+ export class SearchProviderController {
25
+ registry;
26
+ report;
27
+ #rows = new Map();
28
+ #listeners = new Set();
29
+ #abort = new AbortController();
30
+ #input;
31
+ #enabled = false;
32
+ #epoch = 0;
33
+ #timer;
34
+ #event;
35
+ constructor(registry, report) {
36
+ this.registry = registry;
37
+ this.report = report;
38
+ this.#event = registry.on("changed", () => {
39
+ clearTimeout(this.#timer);
40
+ this.#timer = setTimeout(() => {
41
+ if (this.#input)
42
+ this.search(this.#input, this.#enabled, true);
43
+ }, 75);
44
+ });
45
+ }
46
+ subscribe(listener) {
47
+ this.#listeners.add(listener);
48
+ listener([...this.#rows.values()]);
49
+ return () => {
50
+ this.#listeners.delete(listener);
51
+ };
52
+ }
53
+ #emit() {
54
+ for (const listener of this.#listeners)
55
+ listener([...this.#rows.values()]);
56
+ }
57
+ search(input, enabled, retain = false) {
58
+ this.#input = input;
59
+ this.#enabled = enabled;
60
+ this.#abort.abort();
61
+ this.#abort = new AbortController();
62
+ const epoch = ++this.#epoch;
63
+ const previous = this.#rows;
64
+ this.#rows = new Map();
65
+ if (enabled && input.text.trim()) {
66
+ const query = parseSearchQueryAst(input.text);
67
+ if (!query.diagnostics.length)
68
+ for (const provider of this.registry.getAll()) {
69
+ const unsupported = searchProviderFields(query.expression).filter((field) => !provider.fields.includes(field));
70
+ const mode = input.mode === "auto" ? "lexical" : input.mode;
71
+ const unavailable = unsupported.length
72
+ ? `Unsupported filters: ${[...new Set(unsupported)].join(", ")}`
73
+ : !provider.modes.includes(mode)
74
+ ? `${provider.label} supports text search only`
75
+ : undefined;
76
+ const row = {
77
+ provider,
78
+ hits: retain ? previous.get(provider.id)?.hits ?? [] : [],
79
+ busy: !unavailable,
80
+ unavailable,
81
+ };
82
+ this.#rows.set(provider.id, row);
83
+ if (!unavailable)
84
+ void this.#run(row, epoch, Math.max(input.limit, row.hits.length));
85
+ }
86
+ }
87
+ this.#emit();
88
+ }
89
+ async #run(row, epoch, limit, append = false) {
90
+ if (!this.#input)
91
+ return;
92
+ const signal = this.#abort.signal;
93
+ const input = this.#input;
94
+ let hits = append ? [...row.hits] : [];
95
+ let cursor = append ? row.next : undefined;
96
+ const cursors = new Set();
97
+ row.busy = true;
98
+ this.#emit();
99
+ try {
100
+ // Bounded work even when a source returns pages without matches.
101
+ for (let n = 0; n < 32; n++) {
102
+ const page = await row.provider.search({
103
+ ...input,
104
+ query: parseSearchQueryAst(input.text),
105
+ cursor,
106
+ signal,
107
+ });
108
+ if (signal.aborted || epoch !== this.#epoch)
109
+ return;
110
+ if (cursor && row.generation && row.generation !== page.generation)
111
+ hits = [];
112
+ row.generation = page.generation;
113
+ const merged = new Map(hits.map((hit) => [hit.id, hit]));
114
+ for (const hit of page.hits)
115
+ merged.set(hit.id, hit);
116
+ hits = [...merged.values()];
117
+ Object.assign(row, { hits, status: page.status, next: page.next });
118
+ this.#emit();
119
+ cursor = page.next;
120
+ if (!cursor || hits.length >= limit)
121
+ break;
122
+ if (cursors.has(cursor))
123
+ throw new Error("Search provider returned a repeated cursor");
124
+ cursors.add(cursor);
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (!signal.aborted && epoch === this.#epoch) {
129
+ row.unavailable =
130
+ "Search is temporarily unavailable. Refresh to retry.";
131
+ this.report(error);
132
+ }
133
+ }
134
+ finally {
135
+ if (epoch === this.#epoch) {
136
+ row.busy = false;
137
+ this.#emit();
138
+ }
139
+ }
140
+ }
141
+ more(id) {
142
+ const row = this.#rows.get(id);
143
+ if (row && row.next && !row.busy)
144
+ void this.#run(row, this.#epoch, row.hits.length + (this.#input?.limit ?? 100), true);
145
+ }
146
+ refresh() {
147
+ if (this.#input)
148
+ this.search(this.#input, this.#enabled, true);
149
+ }
150
+ close() {
151
+ ++this.#epoch;
152
+ this.#abort.abort();
153
+ clearTimeout(this.#timer);
154
+ this.registry.offref(this.#event);
155
+ this.#listeners.clear();
156
+ }
157
+ }
@@ -0,0 +1,14 @@
1
+ import type { SearchProviderHit, SearchResultProvider } from "@lapis-notes/api";
2
+ import type { ProviderResults } from "./search-provider-controller";
3
+ export type ProviderVersion = {
4
+ provider: SearchResultProvider;
5
+ hit: SearchProviderHit;
6
+ };
7
+ export type ProviderGroup = {
8
+ id: string;
9
+ title: string;
10
+ path?: string;
11
+ extension?: string;
12
+ versions: ProviderVersion[];
13
+ };
14
+ export declare function groupProviderResults(rows: ProviderResults[]): ProviderGroup[];
@@ -0,0 +1,60 @@
1
+ export function groupProviderResults(rows) {
2
+ const groups = new Map();
3
+ for (const row of rows)
4
+ for (const hit of row.hits) {
5
+ const id = hit.path ?? `${row.provider.id}:${hit.groupId}`;
6
+ let group = groups.get(id);
7
+ if (!group) {
8
+ group = {
9
+ id,
10
+ title: hit.title,
11
+ path: hit.path,
12
+ extension: hit.extension,
13
+ versions: [],
14
+ };
15
+ groups.set(id, group);
16
+ }
17
+ const content = hit.version?.contentId;
18
+ const duplicate = content
19
+ ? group.versions.find((version) => version.provider.id === row.provider.id &&
20
+ version.hit.version?.contentId === content)
21
+ : undefined;
22
+ const reference = (item) => item.version
23
+ ? {
24
+ id: item.version.id,
25
+ label: item.version.label,
26
+ author: item.version.author,
27
+ timestamp: item.version.timestamp,
28
+ target: item.target,
29
+ }
30
+ : undefined;
31
+ if (duplicate && duplicate.hit.version && hit.version) {
32
+ const refs = new Map((duplicate.hit.version.references ?? [reference(duplicate.hit)]).map((ref) => [ref.id, ref]));
33
+ refs.set(hit.version.id, reference(hit));
34
+ const primary = hit.version.timestamp > duplicate.hit.version.timestamp
35
+ ? hit
36
+ : duplicate.hit;
37
+ duplicate.hit = {
38
+ ...primary,
39
+ id: `${hit.groupId}/content/${content}`,
40
+ version: {
41
+ ...primary.version,
42
+ occurrences: refs.size,
43
+ references: [...refs.values()].sort((a, b) => b.timestamp - a.timestamp),
44
+ },
45
+ };
46
+ }
47
+ else
48
+ group.versions.push({
49
+ provider: row.provider,
50
+ hit: content
51
+ ? { ...hit, id: `${hit.groupId}/content/${content}` }
52
+ : hit,
53
+ });
54
+ }
55
+ return [...groups.values()].map((group) => ({
56
+ ...group,
57
+ versions: group.versions.sort((a, b) => (b.hit.timestamp ?? 0) - (a.hit.timestamp ?? 0) ||
58
+ a.hit.id.localeCompare(b.hit.id)),
59
+ }));
60
+ }
@@ -0,0 +1,4 @@
1
+ import type { App } from "@lapis-notes/api";
2
+ import { type SearchPluginSettings } from "./search-settings";
3
+ /** Observe owner-applied recovery without writing the recovered document again. */
4
+ export declare function observeRecoveredSearchSettings(app: Pick<App, "configuration">, pluginId: string, apply: (settings: SearchPluginSettings) => void): () => void;
@@ -0,0 +1,10 @@
1
+ import { mergeSearchSettings, } from "./search-settings";
2
+ /** Observe owner-applied recovery without writing the recovered document again. */
3
+ export function observeRecoveredSearchSettings(app, pluginId, apply) {
4
+ const changed = app.configuration.on("plugin-data-updated", (event) => {
5
+ if (event.pluginId !== pluginId || event.origin === "plugin-save")
6
+ return;
7
+ apply(mergeSearchSettings(event.value));
8
+ });
9
+ return () => app.configuration.offref(changed);
10
+ }
@@ -126,10 +126,10 @@ export class SearchSettingsTab extends PluginSettingTab {
126
126
  this.display();
127
127
  try {
128
128
  const status = await this.searchPlugin.rebuildSemanticSearch();
129
- new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
129
+ new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`, undefined, this.app);
130
130
  }
131
131
  catch (error) {
132
- new Notice(`Semantic search refresh failed: ${error instanceof Error ? error.message : String(error)}`);
132
+ new Notice(`Semantic search refresh failed: ${error instanceof Error ? error.message : String(error)}`, undefined, this.app);
133
133
  }
134
134
  finally {
135
135
  this.rebuilding = false;
@@ -6,12 +6,6 @@
6
6
  gap: 0.375rem;
7
7
  }
8
8
 
9
- [data-ui-component="search-tool-result"] [data-ui-part="empty"] {
10
- padding: 0.375rem;
11
- color: var(--muted-foreground);
12
- font-size: 0.8125rem;
13
- }
14
-
15
9
  [data-ui-component="search-tool-result"] [data-ui-part="hit"] {
16
10
  display: flex;
17
11
  min-width: 0;
@@ -37,7 +31,8 @@
37
31
  [data-ui-component="search-tool-result"] [data-ui-part="file"]:hover,
38
32
  [data-ui-component="search-tool-result"] [data-ui-part="file"]:focus-visible,
39
33
  [data-ui-component="search-tool-result"] [data-ui-part="snippet"]:hover,
40
- [data-ui-component="search-tool-result"] [data-ui-part="snippet"]:focus-visible {
34
+ [data-ui-component="search-tool-result"]
35
+ [data-ui-part="snippet"]:focus-visible {
41
36
  background: var(
42
37
  --ui-workspace-explorer-row-hover-background,
43
38
  var(--sidebar-accent)
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { AgentResultViewProps } from "@lapis-notes/api";
3
+ import * as Empty from "@lapismd/design-core/shadcn/empty";
4
+ import SearchIcon from "@lucide/svelte/icons/search";
3
5
  import type { App } from "@lapis-notes/api";
4
6
  import {
5
7
  fileNameFromPath,
@@ -27,14 +29,19 @@
27
29
 
28
30
  <div data-ui-component="search-tool-result">
29
31
  {#if hits.length === 0}
30
- <p data-ui-part="empty">No matching notes.</p>
32
+ <Empty.Root data-ui-part="empty">
33
+ <Empty.Header>
34
+ <Empty.Media variant="icon"><SearchIcon aria-hidden="true" /></Empty.Media>
35
+ <Empty.Description>No matching notes.</Empty.Description>
36
+ </Empty.Header>
37
+ </Empty.Root>
31
38
  {:else}
32
39
  {#each hits as hit (hit.path)}
33
40
  <div data-ui-part="hit">
34
41
  <button
35
42
  type="button"
36
43
  data-ui-part="file"
37
- aria-label={`Open ${fileNameFromPath(hit.path)}`}
44
+ aria-label={`Open ${fileNameFromPath(hit.path)} ${hit.path}`}
38
45
  onclick={() => void openHit(hit)}
39
46
  >
40
47
  <span data-ui-part="file-label">{fileNameFromPath(hit.path)}</span>
@@ -2,13 +2,17 @@ import { View, type WorkspaceLeaf } from "@lapis-notes/api";
2
2
  export { SearchViewType } from "./search-view-type";
3
3
  export declare class SearchView extends View {
4
4
  private component;
5
+ private snapshot?;
6
+ private closing?;
5
7
  constructor(leaf: WorkspaceLeaf);
6
8
  getViewType(): string;
7
9
  getDisplayText(): string;
8
10
  getIcon(): string;
11
+ getState(): Record<string, unknown>;
9
12
  protected onOpen(): Promise<void>;
10
13
  protected onClose(): Promise<void>;
11
14
  onload(): void;
12
15
  setState(state: Record<string, unknown>): Promise<void>;
13
- onunload(): void;
16
+ onunload(): Promise<void>;
17
+ private stopPanel;
14
18
  }
@@ -1,10 +1,12 @@
1
- import { View } from "@lapis-notes/api";
1
+ import { View, } from "@lapis-notes/api";
2
2
  import { mount, unmount } from "svelte";
3
3
  import SearchPanel from "./search-panel.svelte";
4
4
  import { SearchViewType } from "./search-view-type";
5
5
  export { SearchViewType } from "./search-view-type";
6
6
  export class SearchView extends View {
7
7
  component = null;
8
+ snapshot;
9
+ closing;
8
10
  constructor(leaf) {
9
11
  super(leaf);
10
12
  }
@@ -17,32 +19,58 @@ export class SearchView extends View {
17
19
  getIcon() {
18
20
  return "search";
19
21
  }
22
+ getState() {
23
+ return {
24
+ ...super.getState(),
25
+ ...(this.component?.getSearchState?.() ?? this.snapshot),
26
+ };
27
+ }
20
28
  onOpen() {
21
29
  return Promise.resolve();
22
30
  }
23
31
  onClose() {
24
- return Promise.resolve();
32
+ return this.stopPanel();
25
33
  }
26
34
  onload() {
35
+ this.closing = undefined;
27
36
  this.component = mount(SearchPanel, {
28
37
  target: this.containerEl,
29
38
  props: {
30
39
  app: this.app,
40
+ initialScope: (["files", "history", "all"].includes(String(this.getState()["scope"]))
41
+ ? this.getState()["scope"]
42
+ : "files"),
31
43
  initialQuery: typeof this.getState()["query"] === "string"
32
44
  ? this.getState()["query"]
33
45
  : "",
46
+ onStateChange: () => {
47
+ if (this.component)
48
+ this.app.workspace.requestSaveLayout({
49
+ source: "api",
50
+ operation: "search-state",
51
+ });
52
+ },
34
53
  },
35
54
  });
36
55
  }
37
56
  async setState(state) {
57
+ this.snapshot = undefined;
38
58
  await super.setState(state);
59
+ if (["files", "history", "all"].includes(String(state["scope"])))
60
+ this.component?.setSearchScope?.(state["scope"]);
39
61
  if (typeof state["query"] === "string") {
40
62
  this.component?.setSearchQuery?.(state["query"]);
41
63
  }
42
64
  }
43
65
  onunload() {
44
- if (this.component)
45
- void unmount(this.component);
66
+ return this.stopPanel();
67
+ }
68
+ stopPanel() {
69
+ if (this.closing)
70
+ return this.closing;
71
+ this.snapshot = this.component?.getSearchState?.() ?? this.snapshot;
72
+ const current = this.component;
46
73
  this.component = null;
74
+ return (this.closing = current ? unmount(current) : Promise.resolve());
47
75
  }
48
76
  }