@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.
- package/.claude/hooks/gh-setup.sh +49 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/release-notes/SKILL.md +331 -0
- package/.editorconfig +10 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
- package/.github/copilot-instructions.md +45 -0
- package/.github/pull_request_template.md +32 -0
- package/.github/workflows/ci.yaml +25 -0
- package/.github/workflows/docs.yml +58 -0
- package/.github/workflows/relay_to_openclaw.yml +59 -0
- package/.github/workflows/release.yaml +45 -0
- package/.prettierignore +10 -0
- package/.prettierrc +13 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +37 -0
- package/.zed/settings.json +42 -0
- package/AGENTS.md +330 -0
- package/ARCHITECTURE.md +390 -0
- package/CONTRIBUTING.md +216 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.ja.md +121 -0
- package/README.md +125 -0
- package/docs/.vitepress/config.mts +124 -0
- package/docs/.vitepress/theme/custom.css +111 -0
- package/docs/.vitepress/theme/index.ts +4 -0
- package/docs/agent-setup/claude-code.md +84 -0
- package/docs/agent-setup/codex.md +76 -0
- package/docs/agent-setup/custom-agents.md +67 -0
- package/docs/agent-setup/gemini-cli.md +99 -0
- package/docs/agent-setup/index.md +34 -0
- package/docs/announcements/gemini-cli-deprecation.md +73 -0
- package/docs/getting-started/index.md +78 -0
- package/docs/getting-started/quick-start.md +38 -0
- package/docs/help/faq.md +181 -0
- package/docs/help/troubleshooting.md +221 -0
- package/docs/index.md +63 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/demo.mp4 +0 -0
- package/docs/public/favicon-16x16.png +0 -0
- package/docs/public/favicon-32x32.png +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/images/editing.webp +0 -0
- package/docs/public/images/export.webp +0 -0
- package/docs/public/images/floating-chat-button.webp +0 -0
- package/docs/public/images/floating-chat-instance-menu.webp +0 -0
- package/docs/public/images/floating-chat-view.webp +0 -0
- package/docs/public/images/mode-selection.webp +0 -0
- package/docs/public/images/model-selection.webp +0 -0
- package/docs/public/images/multi-session.webp +0 -0
- package/docs/public/images/remove-image.webp +0 -0
- package/docs/public/images/ribbon-icon.webp +0 -0
- package/docs/public/images/selection-context.gif +0 -0
- package/docs/public/images/sending-images.webp +0 -0
- package/docs/public/images/sending-messages.webp +0 -0
- package/docs/public/images/session-history-button.webp +0 -0
- package/docs/public/images/slash-commands-1.webp +0 -0
- package/docs/public/images/slash-commands-2.webp +0 -0
- package/docs/public/images/switch-agent.webp +0 -0
- package/docs/public/images/switch-default-agent.webp +0 -0
- package/docs/public/images/temporary-disable.gif +0 -0
- package/docs/reference/acp-support.md +110 -0
- package/docs/usage/chat-export.md +80 -0
- package/docs/usage/commands.md +51 -0
- package/docs/usage/context-files.md +57 -0
- package/docs/usage/editing.md +69 -0
- package/docs/usage/floating-chat.md +84 -0
- package/docs/usage/index.md +97 -0
- package/docs/usage/mcp-tools.md +33 -0
- package/docs/usage/mentions.md +70 -0
- package/docs/usage/mode-selection.md +28 -0
- package/docs/usage/model-selection.md +32 -0
- package/docs/usage/multi-session.md +68 -0
- package/docs/usage/sending-images.md +64 -0
- package/docs/usage/session-history.md +91 -0
- package/docs/usage/slash-commands.md +44 -0
- package/esbuild.config.mjs +49 -0
- package/eslint.config.mjs +25 -0
- package/main.js +228 -0
- package/manifest.json +11 -0
- package/package.json +52 -0
- package/src/acp/acp-client.ts +921 -0
- package/src/acp/acp-handler.ts +252 -0
- package/src/acp/permission-handler.ts +282 -0
- package/src/acp/terminal-handler.ts +264 -0
- package/src/acp/type-converter.ts +272 -0
- package/src/hooks/useAgent.ts +250 -0
- package/src/hooks/useAgentMessages.ts +470 -0
- package/src/hooks/useAgentSession.ts +544 -0
- package/src/hooks/useChatActions.ts +400 -0
- package/src/hooks/useHistoryModal.ts +219 -0
- package/src/hooks/useSessionHistory.ts +863 -0
- package/src/hooks/useSettings.ts +19 -0
- package/src/hooks/useSuggestions.ts +342 -0
- package/src/main.ts +9 -0
- package/src/plugin.ts +1126 -0
- package/src/services/chat-exporter.ts +552 -0
- package/src/services/message-sender.ts +755 -0
- package/src/services/message-state.ts +375 -0
- package/src/services/session-helpers.ts +211 -0
- package/src/services/session-state.ts +130 -0
- package/src/services/session-storage.ts +267 -0
- package/src/services/settings-normalizer.ts +255 -0
- package/src/services/settings-service.ts +285 -0
- package/src/services/update-checker.ts +128 -0
- package/src/services/vault-service.ts +558 -0
- package/src/services/view-registry.ts +345 -0
- package/src/types/agent.ts +92 -0
- package/src/types/chat.ts +351 -0
- package/src/types/errors.ts +136 -0
- package/src/types/obsidian-internals.d.ts +14 -0
- package/src/types/session.ts +731 -0
- package/src/ui/ChangeDirectoryModal.ts +137 -0
- package/src/ui/ChatContext.ts +25 -0
- package/src/ui/ChatHeader.tsx +295 -0
- package/src/ui/ChatPanel.tsx +1162 -0
- package/src/ui/ChatView.tsx +348 -0
- package/src/ui/ErrorBanner.tsx +104 -0
- package/src/ui/FloatingButton.tsx +351 -0
- package/src/ui/FloatingChatView.tsx +531 -0
- package/src/ui/InputArea.tsx +1107 -0
- package/src/ui/InputToolbar.tsx +371 -0
- package/src/ui/MessageBubble.tsx +442 -0
- package/src/ui/MessageList.tsx +265 -0
- package/src/ui/PermissionBanner.tsx +61 -0
- package/src/ui/SessionHistoryModal.tsx +821 -0
- package/src/ui/SettingsTab.ts +1337 -0
- package/src/ui/SuggestionPopup.tsx +138 -0
- package/src/ui/TerminalBlock.tsx +107 -0
- package/src/ui/ToolCallBlock.tsx +456 -0
- package/src/ui/shared/AttachmentStrip.tsx +57 -0
- package/src/ui/shared/IconButton.tsx +55 -0
- package/src/ui/shared/MarkdownRenderer.tsx +103 -0
- package/src/ui/view-host.ts +56 -0
- package/src/utils/error-utils.ts +274 -0
- package/src/utils/logger.ts +44 -0
- package/src/utils/mention-parser.ts +129 -0
- package/src/utils/paths.ts +246 -0
- package/src/utils/platform.ts +425 -0
- package/styles.css +2322 -0
- package/tsconfig.json +18 -0
- package/version-bump.mjs +18 -0
- 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
|
+
}
|