@mseep/obsidian-agent-client 0.10.6

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 (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Vault Service
3
+ *
4
+ * Unified service implementing IVaultAccess port for Obsidian's Vault API.
5
+ * Combines vault file access, fuzzy search (formerly NoteMentionService),
6
+ * and editor selection tracking into a single service.
7
+ */
8
+
9
+ import type AgentClientPlugin from "../plugin";
10
+ import {
11
+ TFile,
12
+ MarkdownView,
13
+ prepareFuzzySearch,
14
+ type EventRef,
15
+ type EditorSelection,
16
+ } from "obsidian";
17
+ import { EditorView } from "@codemirror/view";
18
+ import { Compartment, StateEffect } from "@codemirror/state";
19
+ import { getLogger, Logger } from "../utils/logger";
20
+
21
+ // ============================================================================
22
+ // Port Types (from vault-access.port.ts)
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Position in the editor (line and character).
27
+ * Line numbers are 0-indexed.
28
+ */
29
+ export interface EditorPosition {
30
+ /** Line number (0-indexed) */
31
+ line: number;
32
+ /** Character position within the line */
33
+ ch: number;
34
+ }
35
+
36
+ /**
37
+ * Metadata for a note in the vault.
38
+ *
39
+ * Contains essential information about a note file without
40
+ * exposing Obsidian's internal TFile structure.
41
+ */
42
+ export interface NoteMetadata {
43
+ /** Full path to the note within the vault (e.g., "folder/note.md") */
44
+ path: string;
45
+
46
+ /** Filename without extension (e.g., "note") */
47
+ name: string;
48
+
49
+ /** File extension (usually "md") */
50
+ extension: string;
51
+
52
+ /** Creation timestamp (milliseconds since epoch) */
53
+ created: number;
54
+
55
+ /** Last modified timestamp (milliseconds since epoch) */
56
+ modified: number;
57
+
58
+ /** Optional aliases from frontmatter */
59
+ aliases?: string[];
60
+
61
+ /** Optional text selection range in the editor */
62
+ selection?: {
63
+ from: EditorPosition;
64
+ to: EditorPosition;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Interface for accessing vault notes and files.
70
+ *
71
+ * Provides methods for searching, reading, and listing notes
72
+ * in the Obsidian vault. This port will be implemented by adapters
73
+ * that use Obsidian's Vault API.
74
+ */
75
+ export interface IVaultAccess {
76
+ /**
77
+ * Read the content of a note.
78
+ *
79
+ * @param path - Path to the note within the vault
80
+ * @returns Promise resolving to note content as plain text
81
+ * @throws Error if note doesn't exist or cannot be read
82
+ */
83
+ readNote(path: string): Promise<string>;
84
+
85
+ /**
86
+ * Search for notes matching a query.
87
+ *
88
+ * Uses fuzzy search against note names, paths, and aliases.
89
+ * Returns up to 5 best matches sorted by relevance.
90
+ * If query is empty, returns recently modified files.
91
+ *
92
+ * @param query - Search query string (can be empty for recent files)
93
+ * @returns Promise resolving to array of matching note metadata
94
+ */
95
+ searchNotes(query: string): Promise<NoteMetadata[]>;
96
+
97
+ /**
98
+ * Get the currently active note in the editor.
99
+ *
100
+ * @returns Promise resolving to active note metadata, or null if no note is active
101
+ */
102
+ getActiveNote(): Promise<NoteMetadata | null>;
103
+
104
+ /**
105
+ * List all markdown notes in the vault.
106
+ *
107
+ * @returns Promise resolving to array of all note metadata
108
+ */
109
+ listNotes(): Promise<NoteMetadata[]>;
110
+ }
111
+
112
+ /**
113
+ * Unified vault service for note access, fuzzy search, and selection tracking.
114
+ *
115
+ * Implements IVaultAccess port by wrapping Obsidian's Vault API,
116
+ * providing built-in fuzzy search (formerly NoteMentionService),
117
+ * and tracking editor selection state.
118
+ */
119
+ export class VaultService implements IVaultAccess {
120
+ private files: TFile[] = [];
121
+ private lastBuild = 0;
122
+ private logger: Logger;
123
+ private vaultEventRefs: ReturnType<typeof this.plugin.app.vault.on>[] = [];
124
+
125
+ private currentSelection: {
126
+ filePath: string;
127
+ selection: { from: EditorPosition; to: EditorPosition };
128
+ } | null = null;
129
+ private selectionListeners = new Set<() => void>();
130
+ private activeLeafRef: EventRef | null = null;
131
+ private detachEditorListenerFn: (() => void) | null = null;
132
+ private selectionCompartment: Compartment | null = null;
133
+ private lastSelectionKey = "";
134
+
135
+ constructor(private plugin: AgentClientPlugin) {
136
+ // File index init
137
+ this.logger = getLogger();
138
+ this.rebuildIndex();
139
+ this.registerVaultEvents();
140
+
141
+ // Selection tracking init
142
+ this.currentSelection = null;
143
+ this.selectionListeners = new Set();
144
+ }
145
+
146
+ // ========================================================================
147
+ // File Index (formerly NoteMentionService)
148
+ // ========================================================================
149
+
150
+ private rebuildIndex() {
151
+ this.files = this.plugin.app.vault.getMarkdownFiles();
152
+ this.lastBuild = Date.now();
153
+ this.logger.log(
154
+ `[VaultService] Rebuilt index with ${this.files.length} files`,
155
+ );
156
+ }
157
+
158
+ private registerVaultEvents() {
159
+ this.vaultEventRefs.push(
160
+ this.plugin.app.vault.on("create", (file) => {
161
+ if (file instanceof TFile && file.extension === "md") {
162
+ this.rebuildIndex();
163
+ }
164
+ }),
165
+ );
166
+ this.vaultEventRefs.push(
167
+ this.plugin.app.vault.on("delete", () => this.rebuildIndex()),
168
+ );
169
+ this.vaultEventRefs.push(
170
+ this.plugin.app.vault.on("rename", (file) => {
171
+ if (file instanceof TFile && file.extension === "md") {
172
+ this.rebuildIndex();
173
+ }
174
+ }),
175
+ );
176
+ }
177
+
178
+ getAllFiles(): TFile[] {
179
+ return this.files;
180
+ }
181
+
182
+ getFileByPath(path: string): TFile | null {
183
+ return this.files.find((file) => file.path === path) || null;
184
+ }
185
+
186
+ // ========================================================================
187
+ // IVaultAccess Implementation
188
+ // ========================================================================
189
+
190
+ /**
191
+ * Read the content of a note.
192
+ *
193
+ * @param path - Path to the note within the vault
194
+ * @returns Promise resolving to note content as plain text
195
+ * @throws Error if note doesn't exist or cannot be read
196
+ */
197
+ async readNote(path: string): Promise<string> {
198
+ const file = this.plugin.app.vault.getAbstractFileByPath(path);
199
+ if (!(file instanceof TFile)) {
200
+ throw new Error(`File not found: ${path}`);
201
+ }
202
+ return await this.plugin.app.vault.read(file);
203
+ }
204
+
205
+ /**
206
+ * Search for notes matching a query.
207
+ *
208
+ * Uses fuzzy search against note names, paths, and aliases.
209
+ * Returns up to 20 best matches sorted by relevance.
210
+ * If query is empty, returns recently modified files.
211
+ *
212
+ * @param query - Search query string (can be empty for recent files)
213
+ * @returns Promise resolving to array of matching note metadata
214
+ */
215
+ searchNotes(query: string): Promise<NoteMetadata[]> {
216
+ if (!query.trim()) {
217
+ const recentFiles = this.files
218
+ .slice()
219
+ .sort((a, b) => (b.stat?.mtime || 0) - (a.stat?.mtime || 0))
220
+ .slice(0, 20);
221
+ return Promise.resolve(
222
+ recentFiles.map((file) => this.convertToMetadata(file)),
223
+ );
224
+ }
225
+
226
+ const fuzzySearch = prepareFuzzySearch(query.trim());
227
+
228
+ const scored: Array<{ file: TFile; score: number }> = this.files.map(
229
+ (file) => {
230
+ const basename = file.basename;
231
+ const path = file.path;
232
+ const fileCache =
233
+ this.plugin.app.metadataCache.getFileCache(file);
234
+ const aliases = fileCache?.frontmatter?.aliases as
235
+ | string[]
236
+ | string
237
+ | undefined;
238
+ const aliasArray: string[] = Array.isArray(aliases)
239
+ ? aliases
240
+ : aliases
241
+ ? [aliases]
242
+ : [];
243
+
244
+ const searchFields = [basename, path, ...aliasArray];
245
+ let bestScore = -Infinity;
246
+
247
+ for (const field of searchFields) {
248
+ const match = fuzzySearch(field);
249
+ if (match && match.score > bestScore) {
250
+ bestScore = match.score;
251
+ }
252
+ }
253
+
254
+ return { file, score: bestScore };
255
+ },
256
+ );
257
+
258
+ const results = scored
259
+ .filter((item) => item.score > -Infinity)
260
+ .sort((a, b) => b.score - a.score)
261
+ .slice(0, 20)
262
+ .map((item) => this.convertToMetadata(item.file));
263
+ return Promise.resolve(results);
264
+ }
265
+
266
+ /**
267
+ * Get the currently active note in the editor.
268
+ *
269
+ * Returns the active note with current selection if available.
270
+ *
271
+ * @returns Promise resolving to active note metadata, or null if no note is active
272
+ */
273
+ getActiveNote(): Promise<NoteMetadata | null> {
274
+ const activeFile = this.plugin.app.workspace.getActiveFile();
275
+ if (!activeFile) return Promise.resolve(null);
276
+
277
+ const metadata = this.convertToMetadata(activeFile);
278
+
279
+ // Add selection if we have it stored for this file
280
+ if (
281
+ this.currentSelection &&
282
+ this.currentSelection.filePath === activeFile.path
283
+ ) {
284
+ metadata.selection = this.currentSelection.selection;
285
+ }
286
+
287
+ return Promise.resolve(metadata);
288
+ }
289
+
290
+ /**
291
+ * List all markdown notes in the vault.
292
+ *
293
+ * @returns Promise resolving to array of all note metadata
294
+ */
295
+ listNotes(): Promise<NoteMetadata[]> {
296
+ return Promise.resolve(
297
+ this.files.map((file) => this.convertToMetadata(file)),
298
+ );
299
+ }
300
+
301
+ // ========================================================================
302
+ // Selection Tracking
303
+ // ========================================================================
304
+
305
+ /**
306
+ * Subscribe to selection changes for the active markdown editor.
307
+ *
308
+ * The adapter will monitor the currently active MarkdownView and
309
+ * keep track of its selection, notifying subscribers whenever the
310
+ * selection or active file changes.
311
+ */
312
+ subscribeSelectionChanges(listener: () => void): () => void {
313
+ this.selectionListeners.add(listener);
314
+ this.ensureSelectionTracking();
315
+
316
+ return () => {
317
+ this.selectionListeners.delete(listener);
318
+ if (this.selectionListeners.size === 0) {
319
+ this.teardownSelectionTracking();
320
+ }
321
+ };
322
+ }
323
+
324
+ private ensureSelectionTracking(): void {
325
+ if (this.activeLeafRef) {
326
+ return;
327
+ }
328
+
329
+ const activeView =
330
+ this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
331
+ this.attachToView(activeView ?? null);
332
+
333
+ this.activeLeafRef = this.plugin.app.workspace.on(
334
+ "active-leaf-change",
335
+ (leaf) => {
336
+ const nextView =
337
+ leaf?.view instanceof MarkdownView
338
+ ? leaf.view
339
+ : this.plugin.app.workspace.getActiveViewOfType(
340
+ MarkdownView,
341
+ );
342
+ this.attachToView(nextView ?? null);
343
+ },
344
+ );
345
+ }
346
+
347
+ private teardownSelectionTracking(): void {
348
+ this.detachEditorListener();
349
+ if (this.activeLeafRef) {
350
+ this.plugin.app.workspace.offref(this.activeLeafRef);
351
+ this.activeLeafRef = null;
352
+ }
353
+ this.lastSelectionKey = "";
354
+ }
355
+
356
+ private detachEditorListener(): void {
357
+ if (this.detachEditorListenerFn) {
358
+ this.detachEditorListenerFn();
359
+ this.detachEditorListenerFn = null;
360
+ }
361
+ this.selectionCompartment = null;
362
+ }
363
+
364
+ private attachToView(view: MarkdownView | null): void {
365
+ this.detachEditorListener();
366
+
367
+ if (!view?.file) {
368
+ return;
369
+ }
370
+
371
+ const { editor, file } = view;
372
+ const filePath = file.path;
373
+
374
+ if (
375
+ this.lastSelectionKey &&
376
+ !this.lastSelectionKey.startsWith(`${filePath}:`)
377
+ ) {
378
+ // Clear previous file selection when switching files
379
+ this.handleSelectionChange(filePath, null);
380
+ }
381
+
382
+ const emitSelection = () => {
383
+ if (editor.somethingSelected()) {
384
+ const selections = editor.listSelections();
385
+ if (selections.length > 0) {
386
+ const normalized = this.normalizeSelection(selections[0]);
387
+ this.handleSelectionChange(filePath, {
388
+ from: {
389
+ line: normalized.anchor.line,
390
+ ch: normalized.anchor.ch,
391
+ },
392
+ to: {
393
+ line: normalized.head.line,
394
+ ch: normalized.head.ch,
395
+ },
396
+ });
397
+ return;
398
+ }
399
+ }
400
+
401
+ const editorHasFocus = editor.hasFocus();
402
+ if (editorHasFocus) {
403
+ this.handleSelectionChange(filePath, null);
404
+ }
405
+ };
406
+
407
+ // Access CodeMirror 6 instance from Obsidian's Editor
408
+ // WARNING: This uses Obsidian's internal API (editor.cm) which is not documented
409
+ // and may change or be removed in future versions.
410
+ // This is required for real-time selection change tracking via EditorView.updateListener.
411
+ // If this API becomes unavailable, selection tracking will silently fail.
412
+ const cm = (editor as unknown as { cm?: EditorView }).cm;
413
+ emitSelection();
414
+
415
+ if (!cm) {
416
+ // Fallback: CodeMirror 6 API not available
417
+ // This may happen if:
418
+ // 1. Obsidian changes its internal implementation
419
+ // 2. A future Obsidian version removes the 'cm' property
420
+ // 3. The editor is in a different mode (e.g., legacy editor)
421
+ console.warn(
422
+ "[VaultService] CodeMirror 6 API not available. " +
423
+ "Selection change tracking will not work. " +
424
+ "This may be due to an Obsidian version change.",
425
+ );
426
+ return;
427
+ }
428
+
429
+ // Only proceed if cm is available
430
+ {
431
+ const compartment = new Compartment();
432
+ this.selectionCompartment = compartment;
433
+ cm.dispatch({
434
+ effects: StateEffect.appendConfig.of(
435
+ compartment.of(
436
+ EditorView.updateListener.of((update) => {
437
+ if (update.selectionSet) {
438
+ emitSelection();
439
+ }
440
+ }),
441
+ ),
442
+ ),
443
+ });
444
+ this.detachEditorListenerFn = () => {
445
+ if (this.selectionCompartment) {
446
+ cm.dispatch({
447
+ effects: this.selectionCompartment.reconfigure([]),
448
+ });
449
+ }
450
+ this.selectionCompartment = null;
451
+ };
452
+ }
453
+ }
454
+
455
+ private normalizeSelection(selection: EditorSelection) {
456
+ const anchor = selection.anchor;
457
+ const head = selection.head ?? selection.anchor;
458
+ const anchorFirst =
459
+ anchor.line < head.line ||
460
+ (anchor.line === head.line && anchor.ch <= head.ch);
461
+
462
+ return anchorFirst ? { anchor, head } : { anchor: head, head: anchor };
463
+ }
464
+
465
+ private handleSelectionChange(
466
+ filePath: string | null,
467
+ selection: { from: EditorPosition; to: EditorPosition } | null,
468
+ ): void {
469
+ const selectionKey = filePath
470
+ ? selection
471
+ ? `${filePath}:${selection.from.line}:${selection.from.ch}-${selection.to.line}:${selection.to.ch}`
472
+ : `${filePath}:none`
473
+ : "none";
474
+
475
+ if (selectionKey === this.lastSelectionKey) {
476
+ return;
477
+ }
478
+
479
+ this.lastSelectionKey = selectionKey;
480
+
481
+ if (filePath && selection) {
482
+ this.currentSelection = {
483
+ filePath,
484
+ selection,
485
+ };
486
+ } else if (
487
+ this.currentSelection &&
488
+ (filePath === null || this.currentSelection.filePath === filePath)
489
+ ) {
490
+ this.currentSelection = null;
491
+ }
492
+
493
+ this.notifySelectionListeners();
494
+ }
495
+
496
+ private notifySelectionListeners(): void {
497
+ for (const listener of this.selectionListeners) {
498
+ try {
499
+ listener();
500
+ } catch (error) {
501
+ console.error("[VaultService] Selection listener error", error);
502
+ }
503
+ }
504
+ }
505
+
506
+ // ========================================================================
507
+ // Lifecycle
508
+ // ========================================================================
509
+
510
+ /**
511
+ * Clean up all event listeners and tracking.
512
+ * Call this when the service is no longer needed.
513
+ */
514
+ destroy(): void {
515
+ // Clean up vault event listeners (from file index)
516
+ for (const ref of this.vaultEventRefs) {
517
+ this.plugin.app.vault.offref(ref);
518
+ }
519
+ this.vaultEventRefs = [];
520
+
521
+ // Clean up selection tracking
522
+ this.teardownSelectionTracking();
523
+ }
524
+
525
+ // ========================================================================
526
+ // Private Helpers
527
+ // ========================================================================
528
+
529
+ /**
530
+ * Convert Obsidian TFile to domain NoteMetadata.
531
+ *
532
+ * Extracts relevant properties from TFile and metadata cache,
533
+ * including frontmatter aliases.
534
+ *
535
+ * @param file - Obsidian TFile object
536
+ * @returns NoteMetadata object
537
+ */
538
+ private convertToMetadata(file: TFile): NoteMetadata {
539
+ const cache = this.plugin.app.metadataCache.getFileCache(file);
540
+ const aliases = cache?.frontmatter?.aliases as
541
+ | string[]
542
+ | string
543
+ | undefined;
544
+
545
+ return {
546
+ path: file.path,
547
+ name: file.basename,
548
+ extension: file.extension,
549
+ created: file.stat.ctime,
550
+ modified: file.stat.mtime,
551
+ aliases: Array.isArray(aliases)
552
+ ? aliases
553
+ : aliases
554
+ ? [aliases]
555
+ : undefined,
556
+ };
557
+ }
558
+ }