From 09e6c5a83f6d1b0057ff77b71ed0522178ec322e Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 18:05:35 +0800 Subject: [PATCH 01/11] feat(chat): accept OS file-manager drags as external context Finder/Explorer drags previously fell through to the browser's default drop and pasted the file's content into the composer. Claim these drags in the composer drop zone: directories and non-image files become external context roots (the scanner now accepts a single file as a context root), images keep attaching via the image manager, and the drop is always preventDefaulted so no text is inserted. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 7 + src/core/context/external-context-scanner.ts | 21 ++- src/core/context/external-context.ts | 26 ++++ src/features/chat/tabs/tab.ts | 3 + src/features/chat/ui/input-toolbar.ts | 13 +- src/features/chat/ui/vault-drop.ts | 137 ++++++++++++++++-- src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- .../context/external-context-scanner.test.ts | 12 ++ .../ui/input-toolbar.external-context.test.ts | 12 ++ .../unit/features/chat/ui/vault-drop.test.ts | 67 +++++++++ 19 files changed, 297 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 514ca72..a384cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- Drag files and folders from your operating system (e.g. macOS Finder) onto + the composer to add them as external context. Dropped folders join the + external context as before, dropped images still attach to the message, and + a dropped file's content is no longer pasted into the input as plain text. + ### Fixed - Hover dropdowns no longer lose their leading characters when their icon diff --git a/src/core/context/external-context-scanner.ts b/src/core/context/external-context-scanner.ts index 972d068..87a69f5 100644 --- a/src/core/context/external-context-scanner.ts +++ b/src/core/context/external-context-scanner.ts @@ -73,12 +73,7 @@ class ExternalContextScanner { const invalidationVersion = this.invalidationVersion; const pathVersion = this.pathVersions.get(expandedPath) ?? 0; - const scan = this.scanDirectory( - expandedPath, - expandedPath, - 0, - { remaining: MAX_FILES_PER_PATH }, - ).then(files => { + const scan = this.scanPathRoot(expandedPath).then(files => { if ( invalidationVersion === this.invalidationVersion && pathVersion === (this.pathVersions.get(expandedPath) ?? 0) @@ -106,6 +101,20 @@ class ExternalContextScanner { return files.map(file => ({ ...file, contextRoot })); } + private async scanPathRoot(root: string): Promise { + const stat = await fs.promises.stat(root).catch(() => null); + if (stat?.isFile()) { + return [{ + path: root, + name: path.basename(root), + relativePath: path.basename(root), + contextRoot: root, + mtime: stat.mtimeMs, + }]; + } + return this.scanDirectory(root, root, 0, { remaining: MAX_FILES_PER_PATH }); + } + private async scanDirectory( dir: string, contextRoot: string, diff --git a/src/core/context/external-context.ts b/src/core/context/external-context.ts index e903b54..f192279 100644 --- a/src/core/context/external-context.ts +++ b/src/core/context/external-context.ts @@ -146,6 +146,32 @@ export function isValidDirectoryPath(p: string): boolean { return validateDirectoryPath(p).valid; } +export interface ContextPathValidationResult { + valid: boolean; + error?: string; + isDirectory: boolean; +} + +/** + * Like validateDirectoryPath but also accepts a single file, so an OS-level + * drag of an individual file can become an external context root. + */ +export function validateContextPath(p: string): ContextPathValidationResult { + try { + const stats = fs.statSync(p); + return { valid: true, isDirectory: stats.isDirectory() }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + return { valid: false, error: 'Path does not exist', isDirectory: false }; + } + if (error.code === 'EACCES') { + return { valid: false, error: 'Permission denied', isDirectory: false }; + } + return { valid: false, error: `Cannot access path: ${error.message}`, isDirectory: false }; + } +} + export function filterValidPaths(paths: string[]): string[] { return paths.filter(isValidDirectoryPath); } diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index fb78bc9..ce8cd47 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -298,6 +298,9 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { onInsertReference: (reference) => { tab.ui.fileContextManager?.registerComposerReference(reference); }, + onAddExternalContext: (path, options) => + tab.ui.externalContextSelector?.addExternalContext(path, options) + ?? { success: false, error: 'External context is not available in this tab' }, }); // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index bc302d0..8168674 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -2,7 +2,7 @@ import { Notice, setIcon } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; -import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateDirectoryPath } from '../../../core/context/external-context'; +import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateContextPath, validateDirectoryPath } from '../../../core/context/external-context'; import { expandHomePath, normalizePathForFilesystem } from '../../../core/fs/path'; import type { ManagedMcpServer, @@ -202,7 +202,10 @@ export class ExternalContextSelector { * @param pathInput - Path string (supports ~/ expansion) * @returns Result with success status and normalized path, or error message on failure */ - addExternalContext(pathInput: string): AddExternalContextResult { + addExternalContext( + pathInput: string, + options: { allowFile?: boolean } = {}, + ): AddExternalContextResult { const trimmed = pathInput?.trim(); if (!trimmed) { return { success: false, error: 'No path provided. Usage: /add-dir /absolute/path' }; @@ -223,8 +226,10 @@ export class ExternalContextSelector { return { success: false, error: 'Path must be absolute. Usage: /add-dir /absolute/path' }; } - // Validate path exists and is a directory with specific error messages - const validation = validateDirectoryPath(normalizedPath); + // Validate path exists; a single file is only accepted when explicitly allowed + const validation: { valid: boolean; error?: string } = options.allowFile + ? validateContextPath(normalizedPath) + : validateDirectoryPath(normalizedPath); if (!validation.valid) { return { success: false, error: `${validation.error}: ${pathInput}` }; } diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index f24d63c..a07f715 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -1,5 +1,7 @@ +import { statSync } from 'fs'; import type { App } from 'obsidian'; import { Notice, TFile, TFolder } from 'obsidian'; +import { fileURLToPath } from 'url'; import { t } from '@/i18n/i18n'; import type { MentionInsertReference } from '@/shared/mention/types'; @@ -15,6 +17,11 @@ export interface VaultDropReference { export interface VaultDropOptions { /** Called for every inserted reference so consumers can chipify it. */ onInsertReference?: (reference: MentionInsertReference) => void; + /** Called for OS-level (e.g. Finder) directories and files dropped on the composer. */ + onAddExternalContext?: ( + path: string, + options?: { allowFile?: boolean }, + ) => { success: boolean; error?: string }; } interface DragManagerHost { @@ -26,6 +33,11 @@ interface DragManagerHost { * `@path` / `@path/ ` mention tokens at the caret position. Notes, folders, * and images are accepted; anything else is ignored. * + * Also claims OS-level (e.g. Finder) drags: directories and non-image files + * are routed to external context, while image files are left to + * ImageContextManager. Claiming these drags preventDefaults them so the + * dropped file's content is never pasted into the input as plain text. + * * Must be attached before ImageContextManager so vault drags can be claimed * via stopImmediatePropagation before the image drop handlers run. The drop * listener runs in the capture phase so inner editors (CodeMirror) never see @@ -34,6 +46,8 @@ interface DragManagerHost { export class VaultDropController { private readonly dropOverlayEl: HTMLElement; private readonly onInsertReference?: (reference: MentionInsertReference) => void; + private readonly onAddExternalContext?: VaultDropOptions['onAddExternalContext']; + private readonly viewWindow: Window | null; constructor( private readonly app: App, @@ -42,11 +56,20 @@ export class VaultDropController { options: VaultDropOptions = {}, ) { this.onInsertReference = options.onInsertReference; + this.onAddExternalContext = options.onAddExternalContext; this.dropOverlayEl = this.createDropOverlay(); + const viewWindow = this.inputWrapperEl.ownerDocument?.defaultView ?? null; + this.viewWindow = viewWindow && typeof viewWindow.addEventListener === 'function' + ? viewWindow + : null; this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter); this.inputWrapperEl.addEventListener('dragover', this.handleDragOver); this.inputWrapperEl.addEventListener('dragleave', this.handleDragLeave); this.inputWrapperEl.addEventListener('drop', this.handleDrop, true); + // Real OS drops can be swallowed by host-level drop interceptors before the + // wrapper's capture listener runs; the window capture phase is the earliest + // point in the propagation path, so claim them here too. + this.viewWindow?.addEventListener('drop', this.handleDrop, true); } destroy(): void { @@ -54,24 +77,25 @@ export class VaultDropController { this.inputWrapperEl.removeEventListener('dragover', this.handleDragOver); this.inputWrapperEl.removeEventListener('dragleave', this.handleDragLeave); this.inputWrapperEl.removeEventListener('drop', this.handleDrop, true); + this.viewWindow?.removeEventListener('drop', this.handleDrop, true); this.dropOverlayEl.remove(); } private readonly handleDragEnter = (event: DragEvent): void => { - if (!this.hasClaimableDrag()) return; + if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.addClass('visible'); }; private readonly handleDragOver = (event: DragEvent): void => { - if (!this.hasClaimableDrag()) return; + if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { - if (!this.hasClaimableDrag()) return; + if (!this.hasClaimableDrag(event)) return; event.stopImmediatePropagation(); const rect = this.inputWrapperEl.getBoundingClientRect(); @@ -86,10 +110,21 @@ export class VaultDropController { }; private readonly handleDrop = (event: DragEvent): void => { + if (!this.isDropWithinInput(event)) return; const { references, ignoredCount } = this.collectDragged(); - if (references.length === 0) return; + const osDrag = this.collectOsDrag(event); + const claimsAnything = + references.length > 0 || + osDrag.dirs.length > 0 || + osDrag.files.length > 0 || + osDrag.images > 0; + if (!claimsAnything) return; + event.preventDefault(); - event.stopImmediatePropagation(); + // Image-only OS drags stay owned by ImageContextManager, which attaches them. + if (osDrag.images === 0) { + event.stopImmediatePropagation(); + } this.dropOverlayEl.removeClass('visible'); const newReferences = references.filter((reference) => !this.inputContainsReference(reference)); @@ -104,17 +139,97 @@ export class VaultDropController { } this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); } + + for (const dir of osDrag.dirs) { + this.onAddExternalContext?.(dir); + } + for (const file of osDrag.files) { + this.onAddExternalContext?.(file, { allowFile: true }); + } + // Mixed drags are claimed wholesale, so surface the items we dropped. - if (ignoredCount > 0) { - new Notice(t('chat.drop.ignored', { count: ignoredCount })); + const unsupported = ignoredCount + osDrag.unreadable; + if (unsupported > 0) { + new Notice(t('chat.drop.ignored', { count: unsupported })); } this.inputEl.focus(); }; - private hasClaimableDrag(): boolean { - const { references } = this.collectDragged(); - return references.length > 0; - }; + private hasClaimableDrag(event: DragEvent): boolean { + if (this.collectDragged().references.length > 0) return true; + return event.dataTransfer?.types.includes('Files') === true; + } + + private isDropWithinInput(event: DragEvent): boolean { + const path = typeof event.composedPath === 'function' ? event.composedPath() : null; + if (path) return path.includes(this.inputWrapperEl); + // Synthetic events (unit tests) without a propagation path are assumed local. + return event.target == null; + } + + /** OS-level (Finder) drag payload, split by what each subsystem owns. */ + private collectOsDrag(event: DragEvent): { + dirs: string[]; + files: string[]; + images: number; + unreadable: number; + } { + const result = { dirs: [] as string[], files: [] as string[], images: 0, unreadable: 0 }; + const dataTransfer = event.dataTransfer; + if (!dataTransfer || !dataTransfer.types.includes('Files')) return result; + const uriPaths = this.uriListPaths(dataTransfer); + + const droppedFiles = Array.from(dataTransfer.files); + droppedFiles.forEach((file, index) => { + // Some Electron builds expose no File.path on drops; the OS also ships + // the dragged paths as file:// URLs in text/uri-list, aligned by index. + const filePath = (file as File & { path?: string }).path || uriPaths[index]; + if (!filePath) { + result.unreadable += 1; + return; + } + let stats: ReturnType; + try { + stats = statSync(filePath); + } catch { + result.unreadable += 1; + return; + } + if (stats.isDirectory()) { + result.dirs.push(filePath); + } else if (stats.isFile()) { + if (file.type.startsWith('image/') && imageMediaTypeForFilename(file.name) !== null) { + result.images += 1; + } else { + result.files.push(filePath); + } + } else { + result.unreadable += 1; + } + }); + return result; + } + + private uriListPaths(dataTransfer: DataTransfer): string[] { + let raw: string; + try { + raw = dataTransfer.getData('text/uri-list') || ''; + } catch { + return []; + } + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('file://')) + .map((line) => { + try { + return fileURLToPath(line); + } catch { + return null; + } + }) + .filter((value): value is string => value !== null); + } private getDraggedItems(): unknown[] { const host = this.app as unknown as DragManagerHost; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 593b181..ab3d69f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -165,8 +165,8 @@ "moreFiles": "{count} weitere Dateien anzeigen" }, "drop": { - "context": "Notizen, Ordner oder Bilder hier ablegen, um sie als Kontext hinzuzufügen", - "ignored": "{count} nicht unterstützte Dateien ignoriert; es können nur Notizen, Ordner und Bilder abgelegt werden" + "context": "Notizen, Ordner, Bilder oder Dateien hier ablegen, um sie als Kontext hinzuzufügen", + "ignored": "{count} nicht unterstützte Elemente ignoriert; es können nur Notizen, Ordner, Bilder und Dateien abgelegt werden" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4818be4..8faad01 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -165,8 +165,8 @@ "moreFiles": "Show {count} more files" }, "drop": { - "context": "Drop notes, folders, or images here to add as context", - "ignored": "Ignored {count} unsupported file(s); only notes, folders, and images can be dropped" + "context": "Drop notes, folders, images, or files here to add as context", + "ignored": "Ignored {count} unsupported item(s); only notes, folders, images, and files can be dropped" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 24e52af..c17e3da 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -165,8 +165,8 @@ "moreFiles": "Mostrar {count} archivos más" }, "drop": { - "context": "Arrastra notas, carpetas o imágenes aquí para añadirlas como contexto", - "ignored": "Se ignoraron {count} archivos no compatibles; solo se pueden soltar notas, carpetas e imágenes" + "context": "Arrastra notas, carpetas, imágenes o archivos aquí para añadirlos como contexto", + "ignored": "Se ignoraron {count} elementos no compatibles; solo se pueden soltar notas, carpetas, imágenes y archivos" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8dff43c..1aa6bf7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -165,8 +165,8 @@ "moreFiles": "Afficher {count} fichiers de plus" }, "drop": { - "context": "Déposez des notes, des dossiers ou des images ici pour les ajouter comme contexte", - "ignored": "{count} fichiers non pris en charge ignorés ; seules les notes, dossiers et images peuvent être déposés" + "context": "Déposez des notes, des dossiers, des images ou des fichiers ici pour les ajouter comme contexte", + "ignored": "{count} éléments non pris en charge ignorés ; seules les notes, dossiers, images et fichiers peuvent être déposés" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7dc0137..0538e7b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -165,8 +165,8 @@ "moreFiles": "さらに {count} ファイルを表示" }, "drop": { - "context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加", - "ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます" + "context": "ノート、フォルダ、画像、ファイルをここにドロップしてコンテキストに追加", + "ignored": "サポートされていない {count} 件を無視しました。ノート、フォルダ、画像、ファイルのみドロップできます" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 0829151..f513ba8 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -165,8 +165,8 @@ "moreFiles": "파일 {count}개 더 보기" }, "drop": { - "context": "노트, 폴더, 이미지를 여기에 끌어다 놓아 컨텍스트로 추가", - "ignored": "지원되지 않는 파일 {count}개를 무시했습니다. 노트, 폴더, 이미지만 끌어다 놓을 수 있습니다" + "context": "노트, 폴더, 이미지, 파일을 여기에 끌어다 놓아 컨텍스트로 추가", + "ignored": "지원되지 않는 {count}개를 무시했습니다. 노트, 폴더, 이미지, 파일만 끌어다 놓을 수 있습니다" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 071a65d..549f775 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -165,8 +165,8 @@ "moreFiles": "Mostrar mais {count} arquivos" }, "drop": { - "context": "Solte notas, pastas ou imagens aqui para adicioná-las como contexto", - "ignored": "{count} arquivos não suportados foram ignorados; apenas notas, pastas e imagens podem ser soltas" + "context": "Solte notas, pastas, imagens ou arquivos aqui para adicioná-los como contexto", + "ignored": "{count} itens não suportados foram ignorados; apenas notas, pastas, imagens e arquivos podem ser soltos" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6cf3f94..7794264 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -165,8 +165,8 @@ "moreFiles": "Показать ещё файлов: {count}" }, "drop": { - "context": "Перетащите заметки, папки или изображения сюда, чтобы добавить их как контекст", - "ignored": "Пропущено {count} неподдерживаемых файлов; перетаскивать можно только заметки, папки и изображения" + "context": "Перетащите заметки, папки, изображения или файлы сюда, чтобы добавить их как контекст", + "ignored": "Пропущено {count} неподдерживаемых элементов; перетаскивать можно только заметки, папки, изображения и файлы" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 73130ca..3d397f5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -165,8 +165,8 @@ "moreFiles": "再显示 {count} 个文件" }, "drop": { - "context": "拖拽笔记、文件夹或图片到此处,添加为上下文", - "ignored": "已忽略 {count} 个不支持的文件,仅支持拖入笔记、文件夹和图片" + "context": "拖拽笔记、文件夹、图片或文件到此处,添加为上下文", + "ignored": "已忽略 {count} 个不支持的项,仅支持拖入笔记、文件夹、图片和文件" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e820c4c..4e73f12 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -165,8 +165,8 @@ "moreFiles": "再顯示 {count} 個檔案" }, "drop": { - "context": "拖曳筆記、資料夾或圖片到此處,新增為上下文", - "ignored": "已忽略 {count} 個不支援的檔案,僅支援拖入筆記、資料夾與圖片" + "context": "拖曳筆記、資料夾、圖片或檔案到此處,新增為上下文", + "ignored": "已忽略 {count} 個不支援的項目,僅支援拖入筆記、資料夾、圖片與檔案" }, "permissionMode": { "default": { diff --git a/tests/unit/core/context/external-context-scanner.test.ts b/tests/unit/core/context/external-context-scanner.test.ts index 720b26f..93f2843 100644 --- a/tests/unit/core/context/external-context-scanner.test.ts +++ b/tests/unit/core/context/external-context-scanner.test.ts @@ -37,6 +37,18 @@ describe('externalContextScanner', () => { }); describe('scanPaths', () => { + it('should return the file itself when the context root is a single file', async () => { + const filePath = path.join(tempDir, 'file1.txt'); + + const files = await externalContextScanner.scanPaths([filePath]); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe(filePath); + expect(files[0].name).toBe('file1.txt'); + expect(files[0].relativePath).toBe('file1.txt'); + expect(files[0].contextRoot).toBe(filePath); + }); + it('should scan directory and return files', async () => { const files = await externalContextScanner.scanPaths([tempDir]); diff --git a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts index ab98c56..f981e3a 100644 --- a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts @@ -179,6 +179,18 @@ describe('ExternalContextSelector', () => { }); describe('addExternalContext', () => { + it('should reject a file path unless allowFile is set', () => { + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + const rejected = selector.addExternalContext('/tmp/some-file.txt'); + expect(rejected.success).toBe(false); + expect(selector.getExternalContexts()).toEqual([]); + + const accepted = selector.addExternalContext('/tmp/some-file.txt', { allowFile: true }); + expect(accepted.success).toBe(true); + expect(selector.getExternalContexts()).toContain('/tmp/some-file.txt'); + }); + it('should reject empty input', () => { const onChange = jest.fn(); selector.setOnChange(onChange); diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index a5dc195..65574e1 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -2,10 +2,16 @@ * @jest-environment jsdom */ import { createMockEl } from '@test/helpers/mock-element'; +import { statSync } from 'fs'; import { Notice, TFile, TFolder } from 'obsidian'; import { VaultDropController } from '@/features/chat/ui/vault-drop'; +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { ...actual, statSync: jest.fn(actual.statSync) }; +}); + function makeFile(path: string): any { const file = new (TFile as unknown as new () => Record)(); file.path = path; @@ -86,6 +92,67 @@ describe('VaultDropController', () => { inputEl = createInputEl(); }); + describe('OS-level (Finder) drags', () => { + const osFile = (name: string, type: string, filePath: string) => ({ name, type, path: filePath }); + + beforeEach(() => { + (statSync as unknown as jest.Mock).mockReset(); + }); + + it('routes a dropped non-image file to external context', () => { + (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => false, isFile: () => true }); + const onAddExternalContext = jest.fn(); + new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + + const event = createDropEvent({ + dataTransfer: { types: ['Files'], files: [osFile('a.txt', 'text/plain', '/tmp/a.txt')] }, + }); + wrapper.dispatchEvent('drop', event); + + expect(onAddExternalContext).toHaveBeenCalledWith('/tmp/a.txt', { allowFile: true }); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + }); + + it('routes a dropped directory to external context without allowFile', () => { + (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => true, isFile: () => false }); + const onAddExternalContext = jest.fn(); + new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + + wrapper.dispatchEvent('drop', createDropEvent({ + dataTransfer: { types: ['Files'], files: [osFile('dir', '', '/tmp/dir')] }, + })); + + expect(onAddExternalContext).toHaveBeenCalledWith('/tmp/dir'); + }); + + it('leaves image drops to the image manager', () => { + (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => false, isFile: () => true }); + const onAddExternalContext = jest.fn(); + new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + + const event = createDropEvent({ + dataTransfer: { types: ['Files'], files: [osFile('i.png', 'image/png', '/tmp/i.png')] }, + }); + wrapper.dispatchEvent('drop', event); + + expect(onAddExternalContext).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('shows the drop overlay for OS file drags', () => { + (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => false, isFile: () => true }); + new VaultDropController(createApp(undefined), wrapper, inputEl); + + wrapper.dispatchEvent('dragenter', createDragEvent('dragenter', { + dataTransfer: { types: ['Files'], files: [osFile('a.txt', 'text/plain', '/tmp/a.txt')] }, + })); + + expect(findOverlay(wrapper).className).toContain('visible'); + }); + }); + describe('drop handling', () => { it('inserts a markdown file mention at the caret', () => { const app = createApp({ file: makeFile('notes/idea.md') }); From 4a67208d1d5e2aeb69452a3a2bdf6a0ebad2839b Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 20:17:56 +0800 Subject: [PATCH 02/11] feat(chat): polish external-context drops and Electron 32 file paths Round out OS drag-and-drop support: resolve dropped files through Electron's webUtils.getPathForFile (File.path was removed in Electron 32), claim the drag lifecycle on the window capture phase so host-level interceptors cannot swallow real Finder/Explorer drops, and scope claiming to drags that end inside the composer. Drops now report the added path or the rejection reason, and the external-context badge shows the count even for a single path. Add a protocol-driven e2e check (npm run test:e2e:file-drop) that drives a folder drop through Chromium's input protocol against a running Obsidian. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 2 + CONTRIBUTING.md | 19 +- package.json | 1 + scripts/e2e-external-file-drop.mjs | 304 ++++++++++++++++++ src/features/chat/ui/input-toolbar.ts | 9 +- src/features/chat/ui/vault-drop.ts | 91 +++++- src/i18n/locales/de.json | 2 + src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/i18n/types.ts | 2 + .../unit/features/chat/ui/vault-drop.test.ts | 43 ++- 18 files changed, 457 insertions(+), 34 deletions(-) create mode 100644 scripts/e2e-external-file-drop.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a384cd2..a0655cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added +- Show the added external context path after dropping a folder or file, surface + rejected additions, and show the context count even for a single path. - Drag files and folders from your operating system (e.g. macOS Finder) onto the composer to add them as external context. Dropped folders join the external context as before, dropped images still attach to the message, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c103848..8387b7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,10 +42,27 @@ stream events should also be exercised manually against a real qodercli: npm run smoke:qoder ``` -This initialization-only check follows the official TypeScript model-selection +The initialization-only SDK check follows the official TypeScript model-selection sample and does not send a model turn. Set `QODER_CLI_PATH` when qodercli is not available on PATH. +Folder-drop changes have a protocol-driven test in a running Obsidian/Electron app. Set +`OBSIDIAN_VAULT` in `.env.local`, launch Obsidian with remote debugging enabled, +open that vault, and run: + +```bash +/Applications/Obsidian.app/Contents/MacOS/Obsidian --remote-debugging-port=9222 +npm run test:e2e:file-drop +``` + +The test builds and deploys the plugin into the configured test vault, reloads +it, sends a folder drag through Chromium's input protocol, and verifies that +the path becomes external context with visible feedback and no composer text +change. It also checks dragover acceptance with a simulated host interceptor. +This does not automate the Finder/Explorer mouse gesture; verify that separately. +Set `QODERIAN_E2E_DROP_KIND=file` for the file case, or `OBSIDIAN_DEBUG_PORT` when +using a port other than `9222`. + ## Naming conventions - TypeScript, TSX, and script file names use `kebab-case`, including diff --git a/package.json b/package.json index 8c7c293..739523a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint:names": "node scripts/check-file-names.mjs", "lint:fix": "npm run lint -- --fix", "test": "node scripts/run-jest.js", + "test:e2e:file-drop": "node scripts/e2e-external-file-drop.mjs", "test:watch": "node scripts/run-jest.js --watch", "test:coverage": "node scripts/run-jest.js --coverage", "smoke:qoder": "node scripts/smoke-qoder-sdk.mjs", diff --git a/scripts/e2e-external-file-drop.mjs b/scripts/e2e-external-file-drop.mjs new file mode 100644 index 0000000..b009b84 --- /dev/null +++ b/scripts/e2e-external-file-drop.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; +import process from 'process'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEBUG_PORT = Number(process.env.OBSIDIAN_DEBUG_PORT || 9222); +const DEBUG_ORIGIN = `http://127.0.0.1:${DEBUG_PORT}`; +const PLUGIN_ID = 'qoderian'; +const DROP_MARKER = 'QODERIAN_E2E_FILE_CONTENT_MUST_NOT_BE_PASTED'; + +function loadLocalEnvironment() { + const envPath = path.join(ROOT, '.env.local'); + if (!existsSync(envPath)) return; + + for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([^#=]+)=["']?(.+?)["']?$/); + if (match && !process.env[match[1].trim()]) { + process.env[match[1].trim()] = match[2].trim(); + } + } +} + +function deployPlugin(vaultPath) { + execFileSync(process.execPath, ['scripts/build.mjs', 'production'], { + cwd: ROOT, + stdio: 'inherit', + }); + + const pluginDir = path.join(vaultPath, '.obsidian', 'plugins', PLUGIN_ID); + if (!existsSync(pluginDir)) { + throw new Error(`Qoderian plugin directory does not exist: ${pluginDir}`); + } + for (const name of ['main.js', 'manifest.json', 'styles.css']) { + copyFileSync(path.join(ROOT, name), path.join(pluginDir, name)); + } +} + +class CdpSession { + constructor(url) { + this.url = url; + this.nextId = 1; + this.pending = new Map(); + } + + async connect() { + this.socket = new WebSocket(this.url); + await new Promise((resolve, reject) => { + this.socket.addEventListener('open', resolve, { once: true }); + this.socket.addEventListener('error', reject, { once: true }); + }); + this.socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(`${pending.method}: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + }); + } + + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { method, resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + async evaluate(expression, awaitPromise = false) { + const result = await this.send('Runtime.evaluate', { + expression, + awaitPromise, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description || 'Renderer evaluation failed'); + } + return result.result.value; + } + + close() { + this.socket.close(); + } +} + +async function retry(description, action, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const value = await action(); + if (value) return value; + } catch (error) { + lastError = error; + } + await new Promise(resolve => setTimeout(resolve, 150)); + } + throw new Error(`${description} timed out${lastError ? `: ${lastError.message}` : ''}`); +} + +async function findVaultTarget(vaultName) { + const targets = await retry('Obsidian DevTools endpoint', async () => { + const response = await fetch(`${DEBUG_ORIGIN}/json/list`); + if (!response.ok) return null; + return response.json(); + }); + const target = targets.find(candidate => + candidate.type === 'page' + && candidate.url === 'app://obsidian.md/index.html' + && candidate.title.includes(vaultName) + ); + if (!target) { + throw new Error(`No Obsidian window for vault "${vaultName}" was found on ${DEBUG_ORIGIN}`); + } + return target; +} + +function visibleComposerStateExpression() { + return `(() => { + const wrapper = [...document.querySelectorAll('.qoderian-input-wrapper')] + .find(element => element.getBoundingClientRect().width > 0); + if (!wrapper) return null; + const rect = wrapper.getBoundingClientRect(); + return { + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2), + sourceValue: wrapper.querySelector('textarea.qoderian-input')?.value ?? '', + editorText: wrapper.querySelector('.cm-content')?.textContent ?? '', + }; + })()`; +} + +async function run() { + loadLocalEnvironment(); + const vaultPath = process.env.OBSIDIAN_VAULT; + if (!vaultPath || !existsSync(vaultPath)) { + throw new Error('Set OBSIDIAN_VAULT in .env.local to an existing test vault.'); + } + + deployPlugin(vaultPath); + + const target = await findVaultTarget(path.basename(vaultPath)); + const cdp = new CdpSession(target.webSocketDebuggerUrl); + await cdp.connect(); + + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'qoderian-file-drop-e2e-')); + const isFolder = process.env.QODERIAN_E2E_DROP_KIND !== 'file'; + const droppedPath = path.join(tempDir, isFolder ? '外部资料 folder' : 'external-context.txt'); + if (isFolder) mkdirSync(droppedPath); + else writeFileSync(droppedPath, DROP_MARKER, 'utf8'); + + try { + await cdp.evaluate(`(async () => { + const plugin = app.plugins.getPlugin(${JSON.stringify(PLUGIN_ID)}); + const contexts = new Map([...(plugin?.getView()?.getTabManager()?.tabs ?? [])] + .map(([id, tab]) => [id, tab.ui.externalContextSelector?.getExternalContexts() ?? []])); + await app.plugins.disablePlugin(${JSON.stringify(PLUGIN_ID)}); + if (window.__qoderianE2EDragOverObserver) { + window.removeEventListener('dragover', window.__qoderianE2EDragOverObserver, true); + } + if (window.__qoderianE2EHostBlocker) { + document.removeEventListener('dragover', window.__qoderianE2EHostBlocker, true); + } + window.__qoderianE2EDragOverResults = []; + window.__qoderianE2EDragOverObserver = event => { + const reachesComposer = event.composedPath() + .some(element => element?.classList?.contains('qoderian-input-wrapper')); + const result = { + accepted: false, + reachesComposer, + types: [...(event.dataTransfer?.types ?? [])], + hostBlocked: false, + }; + window.__qoderianE2EDragOverResults.push(result); + const preventDefault = event.preventDefault.bind(event); + event.preventDefault = () => { + result.accepted = true; + preventDefault(); + }; + }; + window.__qoderianE2EHostBlocker = event => { + if (event.composedPath().some(element => element?.classList?.contains('qoderian-input-wrapper'))) { + window.__qoderianE2EDragOverResults.push({ + accepted: event.defaultPrevented, + reachesComposer: true, + types: [...(event.dataTransfer?.types ?? [])], + hostBlocked: true, + }); + event.stopImmediatePropagation(); + } + }; + window.addEventListener('dragover', window.__qoderianE2EDragOverObserver, true); + await app.plugins.enablePlugin(${JSON.stringify(PLUGIN_ID)}); + await app.commands.executeCommandById(${JSON.stringify(`${PLUGIN_ID}:open-view`)}); + for (const [id, tab] of app.plugins.getPlugin(${JSON.stringify(PLUGIN_ID)}).getView().getTabManager().tabs) { + if (contexts.has(id)) tab.ui.externalContextSelector?.setExternalContexts(contexts.get(id)); + } + document.addEventListener('dragover', window.__qoderianE2EHostBlocker, true); + return true; + })()`, true); + + const before = await retry('visible Qoderian composer', () => + cdp.evaluate(visibleComposerStateExpression())); + + const dragData = { + items: [{ mimeType: 'text/plain', data: DROP_MARKER }], + files: [droppedPath], + dragOperationsMask: 1, + }; + for (const type of ['dragEnter', 'dragOver', 'drop']) { + await cdp.send('Input.dispatchDragEvent', { + type, + x: before.x, + y: before.y, + data: dragData, + }); + } + + const after = await retry('dropped file to appear as external context', async () => { + const state = await cdp.evaluate(`(() => { + const path = ${JSON.stringify(droppedPath)}; + const context = [...document.querySelectorAll('.qoderian-external-context-text')] + .find(element => element.getAttribute('title') === path); + const composer = (${visibleComposerStateExpression()}); + const notice = [...document.querySelectorAll('.notice')] + .some(element => element.textContent.includes(path)); + const badge = context?.closest('.qoderian-external-context-selector') + ?.querySelector('.qoderian-external-context-badge.visible'); + return context && composer && notice && badge + ? { composer, contextTitle: context.getAttribute('title') } : null; + })()`); + return state; + }); + + const dragOverResults = await cdp.evaluate( + `window.__qoderianE2EDragOverResults ?? []`, + ); + const dragOverWasAccepted = dragOverResults.some(result => result.accepted); + if (!dragOverWasAccepted) { + throw new Error( + `The window capture handler did not accept native dragover before the host intercepted it: ${JSON.stringify(dragOverResults)}`, + ); + } + + if (after.composer.sourceValue !== before.sourceValue + || after.composer.editorText !== before.editorText + || after.composer.sourceValue.includes(DROP_MARKER) + || after.composer.editorText.includes(DROP_MARKER)) { + throw new Error('The dropped file content was inserted into the composer.'); + } + + console.log(`PASS protocol-injected ${isFolder ? 'folder' : 'file'} drop -> external context: ${after.contextTitle}`); + console.log('PASS added path shown in a notice and context count badge'); + console.log('PASS dragover accepted before simulated host interception'); + console.log('PASS dropped file content was not pasted into the composer'); + } finally { + try { + await cdp.evaluate(`(() => { + const path = ${JSON.stringify(droppedPath)}; + const text = [...document.querySelectorAll('.qoderian-external-context-text')] + .find(element => element.getAttribute('title') === path); + text?.closest('.qoderian-external-context-item') + ?.querySelector('.qoderian-external-context-remove')?.click(); + if (window.__qoderianE2EDragOverObserver) { + window.removeEventListener('dragover', window.__qoderianE2EDragOverObserver, true); + } + if (window.__qoderianE2EHostBlocker) { + document.removeEventListener('dragover', window.__qoderianE2EHostBlocker, true); + } + delete window.__qoderianE2EDragOverObserver; + delete window.__qoderianE2EHostBlocker; + delete window.__qoderianE2EDragOverResults; + return true; + })()`); + } catch { + // Best-effort UI cleanup; the temporary file is always removed below. + } + cdp.close(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +run().catch((error) => { + console.error(`E2E FAILED: ${error.message}`); + console.error(`Launch Obsidian with --remote-debugging-port=${DEBUG_PORT} and open the OBSIDIAN_VAULT test vault.`); + process.exit(1); +}); diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index 8168674..bbaa287 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -438,13 +438,8 @@ export class ExternalContextSelector { this.iconEl.addClass('active'); this.iconEl.setAttribute('title', `${count} external context${count > 1 ? 's' : ''} (click to add more)`); - // Show badge only when more than 1 path - if (count > 1) { - this.badgeEl.setText(String(count)); - this.badgeEl.addClass('visible'); - } else { - this.badgeEl.removeClass('visible'); - } + this.badgeEl.setText(String(count)); + this.badgeEl.addClass('visible'); } else { this.iconEl.removeClass('active'); this.iconEl.setAttribute('title', 'Add external contexts (click)'); diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index a07f715..9df67e6 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -22,6 +22,33 @@ export interface VaultDropOptions { path: string, options?: { allowFile?: boolean }, ) => { success: boolean; error?: string }; + /** Overrides native File -> filesystem path resolution in tests. */ + resolveNativeFilePath?: (file: File) => string | null; +} + +interface ElectronWebUtils { + getPathForFile(file: File): string; +} + +/** + * Resolve a browser File back to its native path in Electron. + * + * Electron 32 removed the non-standard `File.path` property. Obsidian now + * exposes the supported replacement through `webUtils.getPathForFile()`. + * Keep the legacy property as a fallback for older Obsidian/Electron builds. + */ +export function resolveNativeFilePath(file: File): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Available only in Obsidian's Electron renderer. + const { webUtils } = require('electron') as { webUtils?: ElectronWebUtils }; + const nativePath = webUtils?.getPathForFile(file); + if (nativePath) return nativePath; + } catch { + // Unit tests and non-Electron renderers do not provide the electron module. + } + + const legacyPath = (file as File & { path?: unknown }).path; + return typeof legacyPath === 'string' && legacyPath.length > 0 ? legacyPath : null; } interface DragManagerHost { @@ -47,6 +74,7 @@ export class VaultDropController { private readonly dropOverlayEl: HTMLElement; private readonly onInsertReference?: (reference: MentionInsertReference) => void; private readonly onAddExternalContext?: VaultDropOptions['onAddExternalContext']; + private readonly resolveNativeFilePath: (file: File) => string | null; private readonly viewWindow: Window | null; constructor( @@ -57,31 +85,46 @@ export class VaultDropController { ) { this.onInsertReference = options.onInsertReference; this.onAddExternalContext = options.onAddExternalContext; + this.resolveNativeFilePath = options.resolveNativeFilePath ?? resolveNativeFilePath; this.dropOverlayEl = this.createDropOverlay(); const viewWindow = this.inputWrapperEl.ownerDocument?.defaultView ?? null; this.viewWindow = viewWindow && typeof viewWindow.addEventListener === 'function' ? viewWindow : null; - this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter); - this.inputWrapperEl.addEventListener('dragover', this.handleDragOver); - this.inputWrapperEl.addEventListener('dragleave', this.handleDragLeave); - this.inputWrapperEl.addEventListener('drop', this.handleDrop, true); - // Real OS drops can be swallowed by host-level drop interceptors before the - // wrapper's capture listener runs; the window capture phase is the earliest - // point in the propagation path, so claim them here too. - this.viewWindow?.addEventListener('drop', this.handleDrop, true); + if (this.viewWindow) { + // Real OS drags can be swallowed by Obsidian before wrapper listeners run. + // Claim the complete drag lifecycle at the earliest capture point. A real + // native drop is only emitted when dragover has first been preventDefaulted. + this.viewWindow.addEventListener('dragenter', this.handleDragEnter, true); + this.viewWindow.addEventListener('dragover', this.handleDragOver, true); + this.viewWindow.addEventListener('dragleave', this.handleDragLeave, true); + this.viewWindow.addEventListener('drop', this.handleDrop, true); + } else { + // Lightweight DOM shims used outside a browser do not expose a Window. + this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter); + this.inputWrapperEl.addEventListener('dragover', this.handleDragOver); + this.inputWrapperEl.addEventListener('dragleave', this.handleDragLeave); + this.inputWrapperEl.addEventListener('drop', this.handleDrop, true); + } } destroy(): void { - this.inputWrapperEl.removeEventListener('dragenter', this.handleDragEnter); - this.inputWrapperEl.removeEventListener('dragover', this.handleDragOver); - this.inputWrapperEl.removeEventListener('dragleave', this.handleDragLeave); - this.inputWrapperEl.removeEventListener('drop', this.handleDrop, true); - this.viewWindow?.removeEventListener('drop', this.handleDrop, true); + if (this.viewWindow) { + this.viewWindow.removeEventListener('dragenter', this.handleDragEnter, true); + this.viewWindow.removeEventListener('dragover', this.handleDragOver, true); + this.viewWindow.removeEventListener('dragleave', this.handleDragLeave, true); + this.viewWindow.removeEventListener('drop', this.handleDrop, true); + } else { + this.inputWrapperEl.removeEventListener('dragenter', this.handleDragEnter); + this.inputWrapperEl.removeEventListener('dragover', this.handleDragOver); + this.inputWrapperEl.removeEventListener('dragleave', this.handleDragLeave); + this.inputWrapperEl.removeEventListener('drop', this.handleDrop, true); + } this.dropOverlayEl.remove(); } private readonly handleDragEnter = (event: DragEvent): void => { + if (!this.isDropWithinInput(event)) return; if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); @@ -89,12 +132,14 @@ export class VaultDropController { }; private readonly handleDragOver = (event: DragEvent): void => { + if (!this.isDropWithinInput(event)) return; if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { + if (!this.isDropWithinInput(event)) return; if (!this.hasClaimableDrag(event)) return; event.stopImmediatePropagation(); @@ -117,7 +162,8 @@ export class VaultDropController { references.length > 0 || osDrag.dirs.length > 0 || osDrag.files.length > 0 || - osDrag.images > 0; + osDrag.images > 0 || + osDrag.unreadable > 0; if (!claimsAnything) return; event.preventDefault(); @@ -141,10 +187,10 @@ export class VaultDropController { } for (const dir of osDrag.dirs) { - this.onAddExternalContext?.(dir); + this.addExternalContextWithFeedback(dir); } for (const file of osDrag.files) { - this.onAddExternalContext?.(file, { allowFile: true }); + this.addExternalContextWithFeedback(file, { allowFile: true }); } // Mixed drags are claimed wholesale, so surface the items we dropped. @@ -155,6 +201,17 @@ export class VaultDropController { this.inputEl.focus(); }; + private addExternalContextWithFeedback(path: string, options?: { allowFile?: boolean }): void { + const result = options + ? this.onAddExternalContext?.(path, options) + : this.onAddExternalContext?.(path); + if (result?.success) { + new Notice(t('chat.drop.added', { path })); + } else if (result?.error) { + new Notice(t('chat.drop.failed', { error: result.error })); + } + } + private hasClaimableDrag(event: DragEvent): boolean { if (this.collectDragged().references.length > 0) return true; return event.dataTransfer?.types.includes('Files') === true; @@ -183,7 +240,7 @@ export class VaultDropController { droppedFiles.forEach((file, index) => { // Some Electron builds expose no File.path on drops; the OS also ships // the dragged paths as file:// URLs in text/uri-list, aligned by index. - const filePath = (file as File & { path?: string }).path || uriPaths[index]; + const filePath = this.resolveNativeFilePath(file) || uriPaths[index]; if (!filePath) { result.unreadable += 1; return; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ab3d69f..9bd4df8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -165,6 +165,8 @@ "moreFiles": "{count} weitere Dateien anzeigen" }, "drop": { + "added": "Externer Kontext hinzugefügt: {path}", + "failed": "Externer Kontext konnte nicht hinzugefügt werden: {error}", "context": "Notizen, Ordner, Bilder oder Dateien hier ablegen, um sie als Kontext hinzuzufügen", "ignored": "{count} nicht unterstützte Elemente ignoriert; es können nur Notizen, Ordner, Bilder und Dateien abgelegt werden" }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8faad01..7ad8910 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -165,6 +165,8 @@ "moreFiles": "Show {count} more files" }, "drop": { + "added": "Added external context: {path}", + "failed": "Could not add external context: {error}", "context": "Drop notes, folders, images, or files here to add as context", "ignored": "Ignored {count} unsupported item(s); only notes, folders, images, and files can be dropped" }, diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c17e3da..3cfbb6b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -165,6 +165,8 @@ "moreFiles": "Mostrar {count} archivos más" }, "drop": { + "added": "Contexto externo añadido: {path}", + "failed": "No se pudo añadir el contexto externo: {error}", "context": "Arrastra notas, carpetas, imágenes o archivos aquí para añadirlos como contexto", "ignored": "Se ignoraron {count} elementos no compatibles; solo se pueden soltar notas, carpetas, imágenes y archivos" }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1aa6bf7..db35c7a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -165,6 +165,8 @@ "moreFiles": "Afficher {count} fichiers de plus" }, "drop": { + "added": "Contexte externe ajouté : {path}", + "failed": "Impossible d’ajouter le contexte externe : {error}", "context": "Déposez des notes, des dossiers, des images ou des fichiers ici pour les ajouter comme contexte", "ignored": "{count} éléments non pris en charge ignorés ; seules les notes, dossiers, images et fichiers peuvent être déposés" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0538e7b..f805751 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -165,6 +165,8 @@ "moreFiles": "さらに {count} ファイルを表示" }, "drop": { + "added": "外部コンテキストを追加しました:{path}", + "failed": "外部コンテキストを追加できませんでした:{error}", "context": "ノート、フォルダ、画像、ファイルをここにドロップしてコンテキストに追加", "ignored": "サポートされていない {count} 件を無視しました。ノート、フォルダ、画像、ファイルのみドロップできます" }, diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f513ba8..d91904c 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -165,6 +165,8 @@ "moreFiles": "파일 {count}개 더 보기" }, "drop": { + "added": "외부 컨텍스트 추가됨: {path}", + "failed": "외부 컨텍스트를 추가하지 못했습니다: {error}", "context": "노트, 폴더, 이미지, 파일을 여기에 끌어다 놓아 컨텍스트로 추가", "ignored": "지원되지 않는 {count}개를 무시했습니다. 노트, 폴더, 이미지, 파일만 끌어다 놓을 수 있습니다" }, diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 549f775..3a1a4cd 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -165,6 +165,8 @@ "moreFiles": "Mostrar mais {count} arquivos" }, "drop": { + "added": "Contexto externo adicionado: {path}", + "failed": "Não foi possível adicionar o contexto externo: {error}", "context": "Solte notas, pastas, imagens ou arquivos aqui para adicioná-los como contexto", "ignored": "{count} itens não suportados foram ignorados; apenas notas, pastas, imagens e arquivos podem ser soltos" }, diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7794264..49f95d1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -165,6 +165,8 @@ "moreFiles": "Показать ещё файлов: {count}" }, "drop": { + "added": "Добавлен внешний контекст: {path}", + "failed": "Не удалось добавить внешний контекст: {error}", "context": "Перетащите заметки, папки, изображения или файлы сюда, чтобы добавить их как контекст", "ignored": "Пропущено {count} неподдерживаемых элементов; перетаскивать можно только заметки, папки, изображения и файлы" }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3d397f5..fcad0eb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -165,6 +165,8 @@ "moreFiles": "再显示 {count} 个文件" }, "drop": { + "added": "已添加外部上下文:{path}", + "failed": "添加外部上下文失败:{error}", "context": "拖拽笔记、文件夹、图片或文件到此处,添加为上下文", "ignored": "已忽略 {count} 个不支持的项,仅支持拖入笔记、文件夹、图片和文件" }, diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 4e73f12..a810c7e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -165,6 +165,8 @@ "moreFiles": "再顯示 {count} 個檔案" }, "drop": { + "added": "已新增外部上下文:{path}", + "failed": "新增外部上下文失敗:{error}", "context": "拖曳筆記、資料夾、圖片或檔案到此處,新增為上下文", "ignored": "已忽略 {count} 個不支援的項目,僅支援拖入筆記、資料夾、圖片與檔案" }, diff --git a/src/i18n/types.ts b/src/i18n/types.ts index b0b8f82..be549b1 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,6 +1,8 @@ export type Locale = 'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'ko' | 'de' | 'fr' | 'es' | 'ru' | 'pt'; export type TranslationKey = + | 'chat.drop.added' + | 'chat.drop.failed' // Plugin commands and ribbon | 'commands.openView' | 'commands.inlineEdit' diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index 65574e1..cbff0d0 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -93,7 +93,7 @@ describe('VaultDropController', () => { }); describe('OS-level (Finder) drags', () => { - const osFile = (name: string, type: string, filePath: string) => ({ name, type, path: filePath }); + const osFile = (name: string, type: string) => ({ name, type }); beforeEach(() => { (statSync as unknown as jest.Mock).mockReset(); @@ -102,13 +102,19 @@ describe('VaultDropController', () => { it('routes a dropped non-image file to external context', () => { (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => false, isFile: () => true }); const onAddExternalContext = jest.fn(); - new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + const resolveNativeFilePath = jest.fn(() => '/tmp/a.txt'); + new VaultDropController(createApp(undefined), wrapper, inputEl, { + onAddExternalContext, + resolveNativeFilePath, + }); + const droppedFile = osFile('a.txt', 'text/plain'); const event = createDropEvent({ - dataTransfer: { types: ['Files'], files: [osFile('a.txt', 'text/plain', '/tmp/a.txt')] }, + dataTransfer: { types: ['Files'], files: [droppedFile] }, }); wrapper.dispatchEvent('drop', event); + expect(resolveNativeFilePath).toHaveBeenCalledWith(droppedFile); expect(onAddExternalContext).toHaveBeenCalledWith('/tmp/a.txt', { allowFile: true }); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopImmediatePropagation).toHaveBeenCalled(); @@ -116,23 +122,42 @@ describe('VaultDropController', () => { it('routes a dropped directory to external context without allowFile', () => { (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => true, isFile: () => false }); - const onAddExternalContext = jest.fn(); - new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + const onAddExternalContext = jest.fn(() => ({ success: true })); + new VaultDropController(createApp(undefined), wrapper, inputEl, { + onAddExternalContext, + resolveNativeFilePath: () => '/tmp/dir', + }); wrapper.dispatchEvent('drop', createDropEvent({ - dataTransfer: { types: ['Files'], files: [osFile('dir', '', '/tmp/dir')] }, + dataTransfer: { types: ['Files'], files: [osFile('dir', '')] }, })); expect(onAddExternalContext).toHaveBeenCalledWith('/tmp/dir'); + expect(Notice).toHaveBeenCalledWith(expect.stringContaining('/tmp/dir')); + }); + + it('surfaces rejected folder additions instead of silently ignoring them', () => { + (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => true }); + new VaultDropController(createApp(undefined), wrapper, inputEl, { + resolveNativeFilePath: () => '/tmp/dir', + onAddExternalContext: () => ({ success: false, error: 'Folder already added' }), + }); + wrapper.dispatchEvent('drop', createDropEvent({ + dataTransfer: { types: ['Files'], files: [osFile('dir', '')] }, + })); + expect(Notice).toHaveBeenCalledWith(expect.stringContaining('Folder already added')); }); it('leaves image drops to the image manager', () => { (statSync as unknown as jest.Mock).mockReturnValue({ isDirectory: () => false, isFile: () => true }); const onAddExternalContext = jest.fn(); - new VaultDropController(createApp(undefined), wrapper, inputEl, { onAddExternalContext }); + new VaultDropController(createApp(undefined), wrapper, inputEl, { + onAddExternalContext, + resolveNativeFilePath: () => '/tmp/i.png', + }); const event = createDropEvent({ - dataTransfer: { types: ['Files'], files: [osFile('i.png', 'image/png', '/tmp/i.png')] }, + dataTransfer: { types: ['Files'], files: [osFile('i.png', 'image/png')] }, }); wrapper.dispatchEvent('drop', event); @@ -146,7 +171,7 @@ describe('VaultDropController', () => { new VaultDropController(createApp(undefined), wrapper, inputEl); wrapper.dispatchEvent('dragenter', createDragEvent('dragenter', { - dataTransfer: { types: ['Files'], files: [osFile('a.txt', 'text/plain', '/tmp/a.txt')] }, + dataTransfer: { types: ['Files'], files: [osFile('a.txt', 'text/plain')] }, })); expect(findOverlay(wrapper).className).toContain('visible'); From 2d662067ff66ad0b3f23bd99e5793bd31e02a361 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 20:53:52 +0800 Subject: [PATCH 03/11] fix(chat): resolve a bare external-context root mention to its path `@root/` previously stayed unresolved in outgoing prompts because the external mention resolver required a file path after the root. Resolve the bare root to the context root's absolute path so external folders behave like vault folder mentions. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/core/context/context-mention-resolver.ts | 17 +++++- .../context/context-mention-resolver.test.ts | 53 +++++++++++++++++++ .../file-context/file-context-manager.test.ts | 16 ++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/core/context/context-mention-resolver.ts b/src/core/context/context-mention-resolver.ts index 722d3c7..8063fa9 100644 --- a/src/core/context/context-mention-resolver.ts +++ b/src/core/context/context-mention-resolver.ts @@ -85,10 +85,25 @@ export function resolveExternalMentionAtIndex( const separator = text[displayNameEnd]; if (separator !== '/' && separator !== '\\') continue; + const pathStart = displayNameEnd + 1; + // A bare `@root/` targets the external context root itself, mirroring how + // a vault folder mention references the whole folder. + if (pathStart >= text.length || isWhitespace(text[pathStart])) { + const folderMatch: MentionLookupMatch = { + resolvedPath: entry.contextRoot, + endIndex: pathStart, + trailingPunctuation: '', + }; + if (!bestMatch || folderMatch.endIndex > bestMatch.endIndex) { + bestMatch = folderMatch; + } + continue; + } + const lookup = getContextLookup(entry.contextRoot); const match = findBestMentionLookupMatch( text, - displayNameEnd + 1, + pathStart, lookup, normalizeMentionPath, normalizeForPlatformLookup diff --git a/tests/unit/core/context/context-mention-resolver.test.ts b/tests/unit/core/context/context-mention-resolver.test.ts index 952e1fb..008d642 100644 --- a/tests/unit/core/context/context-mention-resolver.test.ts +++ b/tests/unit/core/context/context-mention-resolver.test.ts @@ -243,6 +243,59 @@ describe('contextMentionResolver', () => { expect(getContextLookup).toHaveBeenCalledWith('/external'); }); + it('resolves a bare root mention at the end of the text to the context root path', () => { + const text = 'See @external/'; + const mentionStart = text.indexOf('@'); + const contextEntries: ExternalContextDisplayEntry[] = [ + { + contextRoot: '/external', + displayName: 'external', + displayNameLower: 'external', + }, + ]; + const getContextLookup = jest.fn().mockReturnValue(new Map()); + + const match = resolveExternalMentionAtIndex( + text, + mentionStart, + contextEntries, + getContextLookup + ); + + expect(match).toEqual({ + resolvedPath: '/external', + trailingPunctuation: '', + endIndex: text.length, + }); + expect(getContextLookup).not.toHaveBeenCalled(); + }); + + it('resolves a bare root mention followed by whitespace', () => { + const text = 'See @external/ then continue'; + const mentionStart = text.indexOf('@'); + const contextEntries: ExternalContextDisplayEntry[] = [ + { + contextRoot: '/external', + displayName: 'external', + displayNameLower: 'external', + }, + ]; + const getContextLookup = jest.fn().mockReturnValue(new Map()); + + const match = resolveExternalMentionAtIndex( + text, + mentionStart, + contextEntries, + getContextLookup + ); + + expect(match).toEqual({ + resolvedPath: '/external', + trailingPunctuation: '', + endIndex: text.indexOf('/') + 1, + }); + }); + it('returns null when mention does not include a path separator after display name', () => { const text = 'Use @external and continue'; const mentionStart = text.indexOf('@'); diff --git a/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts b/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts index fb8f4cc..5ff56c5 100644 --- a/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts +++ b/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts @@ -344,6 +344,22 @@ describe('FileContextManager', () => { manager.destroy(); }); + it('transforms a bare external root mention to its absolute path', async () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ externalContexts: ['/external'] }) + ); + mockScanPaths.mockReturnValue([]); + + const transformed = await manager.transformContextMentions('Explain @external/ then continue.'); + expect(transformed).toBe('Explain /external then continue.'); + + manager.destroy(); + }); + it('transforms pasted external context mention to absolute path without dropdown selection', async () => { const app = createMockApp(); const manager = new FileContextManager( From 6165bb31b6991dfeb07ff3b26df8ba337f977d18 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 21:00:17 +0800 Subject: [PATCH 04/11] fix(chat): select external context roots like vault folders Selecting an external context root inserted `@root/` without a chip and then immediately reopened the dropdown drilled into that root's files, so the whole-folder reference never settled. Register a folder chip for the root and insert `@root/ ` with a trailing space; typing `/` still drills into the root's files. Co-authored-by: QoderAI (Qwen 3.8 Max) --- .../mention/mention-dropdown-controller.ts | 13 +++++--- .../mention-dropdown-controller.test.ts | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/shared/mention/mention-dropdown-controller.ts b/src/shared/mention/mention-dropdown-controller.ts index ff94837..bc7f544 100644 --- a/src/shared/mention/mention-dropdown-controller.ts +++ b/src/shared/mention/mention-dropdown-controller.ts @@ -525,11 +525,16 @@ export class MentionDropdownController { break; } case 'context-folder': { + // Mirrors the vault folder case: chip the whole-root token and settle. + // Typing `/` afterwards still drills into the root's file list. const replacement = `@${selectedItem.name}/`; - this.insertReplacement(beforeAt, replacement, afterCursor); - this.inputEl.focus(); - this.handleInputChange(); - return; + this.callbacks.onInsertReference?.({ + token: replacement, + path: selectedItem.contextRoot, + kind: 'folder', + }); + this.insertReplacement(beforeAt, `${replacement} `, afterCursor); + break; } case 'context-file': { // Display friendly name in input; absolute path resolution happens at send time. diff --git a/tests/unit/shared/mention/mention-dropdown-controller.test.ts b/tests/unit/shared/mention/mention-dropdown-controller.test.ts index 2c75f18..74a2013 100644 --- a/tests/unit/shared/mention/mention-dropdown-controller.test.ts +++ b/tests/unit/shared/mention/mention-dropdown-controller.test.ts @@ -566,6 +566,38 @@ describe('MentionDropdownController', () => { localController.destroy(); }); + it('chips an external context root and settles instead of drilling in', () => { + const onAttachFile = jest.fn(); + const onInsertReference = jest.fn(); + const localCallbacks = createMockCallbacks({ + onAttachFile, + onInsertReference, + getExternalContexts: jest.fn().mockReturnValue(['/tmp/external']), + }); + const localInput = createMockInput(); + const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); + + localInput.value = '@external'; + localInput.selectionStart = 9; + localInput.selectionEnd = 9; + localController.handleInputChange(); + jest.advanceTimersByTime(200); + + const enterEvent = { key: 'Enter', preventDefault: jest.fn(), isComposing: false } as any; + localController.handleKeydown(enterEvent); + + expect(localInput.value).toBe('@external/ '); + expect(onInsertReference).toHaveBeenCalledWith({ + token: '@external/', + path: '/tmp/external', + kind: 'folder', + }); + expect(onAttachFile).not.toHaveBeenCalled(); + expect(localController.isVisible()).toBe(false); + + localController.destroy(); + }); + it('renders vault folder text in @path/ format', () => { const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ From f995de125551f8a1185cff7a531d9692ff8ce1f5 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 21:07:58 +0800 Subject: [PATCH 05/11] feat(chat): chip external-context mentions in sent user messages User-message bubbles only chipped `@path` tokens that resolved inside the vault, so an external context root or file mention stayed raw text after sending. Recognize external context roots by name (`@root/` chips as a folder, `@root/relative/path` as a file), which needs no scan on the render path, and pass the tab's external contexts into the renderer. Co-authored-by: QoderAI (Qwen 3.8 Max) --- .../chat/rendering/message-renderer.ts | 9 +++- src/features/chat/tabs/tab.ts | 1 + src/shared/markdown/mention-chip.ts | 42 ++++++++++++++++--- .../unit/shared/markdown/mention-chip.test.ts | 40 ++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts index 8dc2259..52c05f8 100644 --- a/src/features/chat/rendering/message-renderer.ts +++ b/src/features/chat/rendering/message-renderer.ts @@ -65,6 +65,7 @@ export class MessageRenderer { private messagesEl: HTMLElement; private rewindCallback?: (messageId: string, mode?: ChatRewindMode) => Promise; private forkCallback?: (messageId: string) => Promise; + private getExternalContexts?: () => readonly string[]; private liveMessageEls = new Map(); constructor( @@ -73,6 +74,7 @@ export class MessageRenderer { messagesEl: HTMLElement, rewindCallback?: (messageId: string, mode?: ChatRewindMode) => Promise, forkCallback?: (messageId: string) => Promise, + getExternalContexts?: () => readonly string[], ) { this.app = plugin.app; this.plugin = plugin; @@ -80,6 +82,7 @@ export class MessageRenderer { this.messagesEl = messagesEl; this.rewindCallback = rewindCallback; this.forkCallback = forkCallback; + this.getExternalContexts = getExternalContexts; // Register delegated click handler for file links registerFileLinkHandler(this.app, this.messagesEl, this.component); @@ -823,7 +826,11 @@ export class MessageRenderer { { mediaFolder: this.plugin.settings.mediaFolder } ); if (options?.userReferenceChips) { - processedMarkdown = replaceMentionTokensWithHtml(processedMarkdown, this.app); + processedMarkdown = replaceMentionTokensWithHtml( + processedMarkdown, + this.app, + this.getExternalContexts?.() ?? [], + ); } await MarkdownRenderer.render( this.app, diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index ce8cd47..8b61de8 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -835,6 +835,7 @@ export function initializeTabControllers( forkRequestCallback ? (id) => handleForkRequest(tab, plugin, id, forkRequestCallback) : undefined, + () => ui.externalContextSelector?.getExternalContexts() ?? [], ); // Selection controller diff --git a/src/shared/markdown/mention-chip.ts b/src/shared/markdown/mention-chip.ts index 39079e2..8984b27 100644 --- a/src/shared/markdown/mention-chip.ts +++ b/src/shared/markdown/mention-chip.ts @@ -177,17 +177,45 @@ function createChipHtml(path: string, kind: ReferenceChipKind): string { * an existing vault file or folder becomes a chip; unknown tokens and code * spans pass through unchanged. */ -export function replaceMentionTokensWithHtml(markdown: string, app: App): string { +export function replaceMentionTokensWithHtml( + markdown: string, + app: App, + externalContexts: readonly string[] = [], +): string { if (!app?.vault || !markdown.includes('@')) { return markdown; } + // External context mentions use the root's folder name as a namespace + // (`@root/` or `@root/relative/path`), so a name match is enough and no + // filesystem scan is needed on the render path. + const externalRootNames = new Set(); + for (const contextPath of externalContexts) { + const segments = contextPath.replace(/\\/g, '/').split('/').filter(Boolean); + const name = segments[segments.length - 1]; + if (name) externalRootNames.add(name.toLowerCase()); + } + + const matchesExternalContext = (path: string): boolean => { + if (externalRootNames.size === 0) return false; + const segments = path.replace(/\\/g, '/').replace(/^\/+/, '').split('/'); + const [rootName, ...rest] = segments; + if (!rootName || !externalRootNames.has(rootName.toLowerCase())) return false; + if (rest.length === 0) return true; + if (rest.some(segment => segment.length === 0)) return false; + if (rest.every(segment => !/\s/.test(segment))) return true; + // Spaced paths are only accepted when the final segment looks like a file + // name, so trailing words from the sentence are not swallowed. + return /\.[A-Za-z0-9]{1,8}$/.test(rest[rest.length - 1].trim()); + }; + const resolvePath = (path: string): boolean => { try { - return app.vault.getAbstractFileByPath(path) !== null; + if (app.vault.getAbstractFileByPath(path) !== null) return true; } catch { - return false; + // Vault lookup failures fall through to the external context check. } + return matchesExternalContext(path); }; const candidates = findMentionCandidates(markdown, resolvePath); if (candidates.length === 0) { @@ -203,14 +231,16 @@ export function replaceMentionTokensWithHtml(markdown: string, app: App): string try { resolved = app.vault.getAbstractFileByPath(candidate.path); } catch { - // Vault lookup failures leave the token untouched. + // Vault lookup failures fall through to the external context check. } const kind: ReferenceChipKind | null = resolved instanceof TFolder ? 'folder' : resolved instanceof TFile ? 'file' - : null; - if (!kind || !resolved) { + : matchesExternalContext(candidate.path) + ? (candidate.hasTrailingSlash ? 'folder' : 'file') + : null; + if (!kind) { continue; } diff --git a/tests/unit/shared/markdown/mention-chip.test.ts b/tests/unit/shared/markdown/mention-chip.test.ts index f8e3a22..433dc96 100644 --- a/tests/unit/shared/markdown/mention-chip.test.ts +++ b/tests/unit/shared/markdown/mention-chip.test.ts @@ -98,6 +98,46 @@ describe('replaceMentionTokensWithHtml', () => { expect(result).toBe('see @missing/note.md now'); }); + it('chips an external context root as a folder when contexts are provided', () => { + const app = createMockApp(); + const result = replaceMentionTokensWithHtml('scan @qoderian-verify/ now', app, [ + '/Users/me/Desktop/qoderian-verify', + ]); + + expect(result).toContain('data-kind="folder"'); + expect(result).toContain('data-path="qoderian-verify"'); + expect(result).toContain('title="@qoderian-verify/"'); + expect(result).toContain(' now'); + }); + + it('chips a spaced file name inside an external context root', () => { + const app = createMockApp(); + const result = replaceMentionTokensWithHtml('see @qoderian-verify/my file.md now', app, [ + '/Users/me/Desktop/qoderian-verify', + ]); + + expect(result).toContain('data-kind="file"'); + expect(result).toContain('data-path="qoderian-verify/my file.md"'); + expect(result).toContain(' now'); + }); + + it('chips a file inside an external context root', () => { + const app = createMockApp(); + const result = replaceMentionTokensWithHtml('see @qoderian-verify/src/a.md now', app, [ + '/Users/me/Desktop/qoderian-verify', + ]); + + expect(result).toContain('data-kind="file"'); + expect(result).toContain('data-path="qoderian-verify/src/a.md"'); + }); + + it('leaves external-looking tokens untouched when no contexts are provided', () => { + const app = createMockApp(); + const result = replaceMentionTokensWithHtml('scan @qoderian-verify/ now', app); + + expect(result).toBe('scan @qoderian-verify/ now'); + }); + it('skips tokens inside code fences and inline code', () => { const app = createMockApp(['notes/idea.md']); const markdown = [ From af2ba478f70cd0663f59eb19d5505dcdb6c1e7fa Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 21:14:15 +0800 Subject: [PATCH 06/11] feat(chat): reveal external-context chips in the OS file manager External chips now carry their absolute path, and clicking one opens it in Finder/Explorer through Electron's shell instead of silently failing the vault lookup. Vault chips keep Obsidian's reveal behavior. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/shared/markdown/mention-chip.ts | 44 ++++++++++++------- src/shared/obsidian/compat.ts | 33 ++++++++++++++ .../unit/shared/markdown/mention-chip.test.ts | 7 +-- tests/unit/shared/obsidian/compat.test.ts | 17 +++++++ 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/shared/markdown/mention-chip.ts b/src/shared/markdown/mention-chip.ts index 8984b27..645ae39 100644 --- a/src/shared/markdown/mention-chip.ts +++ b/src/shared/markdown/mention-chip.ts @@ -159,13 +159,18 @@ function resolveLongestPath( return null; } -function createChipHtml(path: string, kind: ReferenceChipKind): string { +function createChipHtml( + path: string, + kind: ReferenceChipKind, + externalPath?: string, +): string { const label = escapeHtml(formatReferenceLabel(path)); - const escapedPath = escapeHtml(path); + const chipPath = escapeHtml(externalPath ?? path); const title = escapeHtml(`@${path}${kind === 'folder' ? '/' : ''}`); + const externalAttr = externalPath ? ' data-external="true"' : ''; return ( `` + + ` data-kind="${kind}" data-path="${chipPath}"${externalAttr} title="${title}">` + `` + `${label}` + `` @@ -189,24 +194,28 @@ export function replaceMentionTokensWithHtml( // External context mentions use the root's folder name as a namespace // (`@root/` or `@root/relative/path`), so a name match is enough and no // filesystem scan is needed on the render path. - const externalRootNames = new Set(); + const externalRootsByName = new Map(); for (const contextPath of externalContexts) { const segments = contextPath.replace(/\\/g, '/').split('/').filter(Boolean); const name = segments[segments.length - 1]; - if (name) externalRootNames.add(name.toLowerCase()); + if (name) externalRootsByName.set(name.toLowerCase(), contextPath); } - const matchesExternalContext = (path: string): boolean => { - if (externalRootNames.size === 0) return false; + const externalAbsolutePath = (path: string): string | null => { + if (externalRootsByName.size === 0) return null; const segments = path.replace(/\\/g, '/').replace(/^\/+/, '').split('/'); const [rootName, ...rest] = segments; - if (!rootName || !externalRootNames.has(rootName.toLowerCase())) return false; - if (rest.length === 0) return true; - if (rest.some(segment => segment.length === 0)) return false; - if (rest.every(segment => !/\s/.test(segment))) return true; - // Spaced paths are only accepted when the final segment looks like a file - // name, so trailing words from the sentence are not swallowed. - return /\.[A-Za-z0-9]{1,8}$/.test(rest[rest.length - 1].trim()); + const rootPath = rootName ? externalRootsByName.get(rootName.toLowerCase()) : undefined; + if (!rootPath) return null; + if (rest.length === 0) return rootPath; + if (rest.some(segment => segment.length === 0)) return null; + if (rest.some(segment => /\s/.test(segment))) { + // Spaced paths are only accepted when the final segment looks like a file + // name, so trailing words from the sentence are not swallowed. + const last = rest[rest.length - 1].trim(); + if (!/\.[A-Za-z0-9]{1,8}$/.test(last)) return null; + } + return `${rootPath.replace(/[\\/]+$/, '')}/${rest.join('/')}`; }; const resolvePath = (path: string): boolean => { @@ -215,7 +224,7 @@ export function replaceMentionTokensWithHtml( } catch { // Vault lookup failures fall through to the external context check. } - return matchesExternalContext(path); + return externalAbsolutePath(path) !== null; }; const candidates = findMentionCandidates(markdown, resolvePath); if (candidates.length === 0) { @@ -233,11 +242,12 @@ export function replaceMentionTokensWithHtml( } catch { // Vault lookup failures fall through to the external context check. } + const externalPath = resolved ? null : externalAbsolutePath(candidate.path); const kind: ReferenceChipKind | null = resolved instanceof TFolder ? 'folder' : resolved instanceof TFile ? 'file' - : matchesExternalContext(candidate.path) + : externalPath ? (candidate.hasTrailingSlash ? 'folder' : 'file') : null; if (!kind) { @@ -245,7 +255,7 @@ export function replaceMentionTokensWithHtml( } chunks.push(markdown.slice(cursor, candidate.start)); - chunks.push(createChipHtml(candidate.path, kind)); + chunks.push(createChipHtml(candidate.path, kind, externalPath ?? undefined)); cursor = candidate.end; } if (chunks.length === 0) { diff --git a/src/shared/obsidian/compat.ts b/src/shared/obsidian/compat.ts index 768c6e0..8b41495 100644 --- a/src/shared/obsidian/compat.ts +++ b/src/shared/obsidian/compat.ts @@ -1,5 +1,6 @@ import type { App, TAbstractFile, TFile, TFolder, Workspace, WorkspaceLeaf } from 'obsidian'; import { Notice } from 'obsidian'; +import { isAbsolute } from 'path'; import type { ReferenceChipKind } from '../mention/types'; @@ -34,9 +35,41 @@ const referenceChipActions: Record = { * notes and offers to create a file. */ export function openReferenceChip(app: App, kind: ReferenceChipKind, path: string): void { + if (isAbsolute(path)) { + revealExternalPath(path); + return; + } referenceChipActions[kind]?.(app, path); } +interface ElectronRemoteShellApi { + remote?: { + shell?: { + /** Reveals the item (file or folder) in the OS file manager. */ + showItemInFolder?: (fullPath: string) => void; + }; + }; +} + +/** + * Reveals an absolute path — an external context root or one of its files — + * in the OS file manager. Vault chips keep using Obsidian's own reveal. + */ +function revealExternalPath(path: string): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Electron remote is exposed only at runtime in Obsidian's renderer. + const { remote } = require('electron') as ElectronRemoteShellApi; + const showItemInFolder = remote?.shell?.showItemInFolder; + if (typeof showItemInFolder === 'function') { + showItemInFolder.call(remote?.shell, path); + return; + } + } catch { + // Electron remote is unavailable outside the desktop app; fall through. + } + new Notice(`Cannot reveal path: ${path}`); +} + function openReferenceFile(app: App, path: string): void { const entry = app.vault.getAbstractFileByPath(path); if (!entry) return; diff --git a/tests/unit/shared/markdown/mention-chip.test.ts b/tests/unit/shared/markdown/mention-chip.test.ts index 433dc96..de54e57 100644 --- a/tests/unit/shared/markdown/mention-chip.test.ts +++ b/tests/unit/shared/markdown/mention-chip.test.ts @@ -105,7 +105,8 @@ describe('replaceMentionTokensWithHtml', () => { ]); expect(result).toContain('data-kind="folder"'); - expect(result).toContain('data-path="qoderian-verify"'); + expect(result).toContain('data-path="/Users/me/Desktop/qoderian-verify"'); + expect(result).toContain('data-external="true"'); expect(result).toContain('title="@qoderian-verify/"'); expect(result).toContain(' now'); }); @@ -117,7 +118,7 @@ describe('replaceMentionTokensWithHtml', () => { ]); expect(result).toContain('data-kind="file"'); - expect(result).toContain('data-path="qoderian-verify/my file.md"'); + expect(result).toContain('data-path="/Users/me/Desktop/qoderian-verify/my file.md"'); expect(result).toContain(' now'); }); @@ -128,7 +129,7 @@ describe('replaceMentionTokensWithHtml', () => { ]); expect(result).toContain('data-kind="file"'); - expect(result).toContain('data-path="qoderian-verify/src/a.md"'); + expect(result).toContain('data-path="/Users/me/Desktop/qoderian-verify/src/a.md"'); }); it('leaves external-looking tokens untouched when no contexts are provided', () => { diff --git a/tests/unit/shared/obsidian/compat.test.ts b/tests/unit/shared/obsidian/compat.test.ts index f351261..b8260c7 100644 --- a/tests/unit/shared/obsidian/compat.test.ts +++ b/tests/unit/shared/obsidian/compat.test.ts @@ -2,6 +2,12 @@ import type { App, TAbstractFile, Workspace, WorkspaceLeaf } from 'obsidian'; import { openReferenceChip, revealWorkspaceLeaf } from '@/shared/obsidian/compat'; +const mockShowItemInFolder = jest.fn(); + +jest.mock('electron', () => ({ + remote: { shell: { showItemInFolder: mockShowItemInFolder } }, +}), { virtual: true }); + describe('obsidianCompat', () => { describe('revealWorkspaceLeaf', () => { it('reveals the workspace leaf', async () => { @@ -99,5 +105,16 @@ describe('obsidianCompat', () => { expect(openFile).not.toHaveBeenCalled(); expect(revealInFolder).not.toHaveBeenCalled(); }); + + it('reveals an external absolute path in the OS file manager', () => { + const { app, openFile, revealInFolder } = createMockApp({}); + mockShowItemInFolder.mockClear(); + + openReferenceChip(app, 'folder', '/Users/me/Desktop/qoderian-verify'); + + expect(mockShowItemInFolder).toHaveBeenCalledWith('/Users/me/Desktop/qoderian-verify'); + expect(openFile).not.toHaveBeenCalled(); + expect(revealInFolder).not.toHaveBeenCalled(); + }); }); }); From e8ea7fc14a73fe7dc26edc83f645c8300974616c Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 21:23:41 +0800 Subject: [PATCH 07/11] chore(chat): log the underlying error when message rendering fails The render catch replaced content with a generic message and swallowed the cause, so a render regression looked like a plain text failure with no diagnostic trail. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/chat/rendering/message-renderer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts index 52c05f8..1eb8076 100644 --- a/src/features/chat/rendering/message-renderer.ts +++ b/src/features/chat/rendering/message-renderer.ts @@ -893,7 +893,8 @@ export class MessageRenderer { if (processedMarkdown.includes('[[')) { processFileLinks(this.app, el); } - } catch { + } catch (error) { + console.error('[qoderian] Failed to render message content', error); el.createDiv({ cls: 'qoderian-render-error', text: 'Failed to render message content.', From d147bcfc3c142ed999d7a6ee4fefdb63bd32c9fb Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 21:42:28 +0800 Subject: [PATCH 08/11] fix(chat): allow persisting single-file external contexts Persistence validated paths with the directory-only check, so a file root added by an OS drop could not be kept across sessions and the rejection message was misleading. Validate with the file-tolerant check and filter persisted settings paths the same way. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/core/context/external-context.ts | 5 ++++ src/features/chat/ui/input-toolbar.ts | 14 +++++----- .../core/context/external-context.test.ts | 27 +++++++++++++++++++ .../ui/input-toolbar.external-context.test.ts | 20 ++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/core/context/external-context.ts b/src/core/context/external-context.ts index f192279..2b7e18e 100644 --- a/src/core/context/external-context.ts +++ b/src/core/context/external-context.ts @@ -176,6 +176,11 @@ export function filterValidPaths(paths: string[]): string[] { return paths.filter(isValidDirectoryPath); } +/** Keeps directories and single-file roots alike (external contexts may be either). */ +export function filterValidContextPaths(paths: string[]): string[] { + return paths.filter((p) => validateContextPath(p).valid); +} + export function isDuplicatePath(newPath: string, existingPaths: string[]): boolean { const normalizedNew = normalizePathForComparison(newPath); return existingPaths.some(existing => normalizePathForComparison(existing) === normalizedNew); diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index bbaa287..abd51e4 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -2,7 +2,7 @@ import { Notice, setIcon } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; -import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateContextPath, validateDirectoryPath } from '../../../core/context/external-context'; +import { filterValidContextPaths, findConflictingPath, isDuplicatePath, validateContextPath, validateDirectoryPath } from '../../../core/context/external-context'; import { expandHomePath, normalizePathForFilesystem } from '../../../core/fs/path'; import type { ManagedMcpServer, @@ -128,8 +128,8 @@ export class ExternalContextSelector { } setPersistentPaths(paths: string[]): void { - // Validate paths - remove non-existent directories - const validPaths = filterValidPaths(paths); + // Validate paths - remove non-existent entries (directories or files) + const validPaths = filterValidContextPaths(paths); const invalidPaths = paths.filter(p => !validPaths.includes(p)); this.persistentPaths = new Set(validPaths); @@ -150,9 +150,9 @@ export class ExternalContextSelector { if (this.persistentPaths.has(path)) { this.persistentPaths.delete(path); } else { - // Validate path still exists before persisting - if (!isValidDirectoryPath(path)) { - new Notice(`Cannot persist "${this.shortenPath(path)}" - directory no longer exists`, 4000); + // Validate the path still exists before persisting (file or directory). + if (!validateContextPath(path).valid) { + new Notice(`Cannot persist "${this.shortenPath(path)}" - path no longer exists`, 4000); return; } this.persistentPaths.add(path); @@ -263,7 +263,7 @@ export class ExternalContextSelector { // Use settings value if provided (most up-to-date), otherwise use local cache if (persistentPathsFromSettings) { // Validate paths - silently filter during session initialization (not user action) - const validPaths = filterValidPaths(persistentPathsFromSettings); + const validPaths = filterValidContextPaths(persistentPathsFromSettings); this.persistentPaths = new Set(validPaths); } this.externalContextPaths = [...this.persistentPaths]; diff --git a/tests/unit/core/context/external-context.test.ts b/tests/unit/core/context/external-context.test.ts index 2c82569..11e38ba 100644 --- a/tests/unit/core/context/external-context.test.ts +++ b/tests/unit/core/context/external-context.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import { buildExternalContextDisplayEntries, + filterValidContextPaths, filterValidPaths, findConflictingPath, getFolderName, @@ -324,6 +325,32 @@ describe('externalContext utilities', () => { }); }); + describe('filterValidContextPaths', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps directories and single files, dropping missing paths', () => { + (fs.statSync as jest.Mock).mockImplementation((p: string) => { + if (p === '/valid/dir') return { isDirectory: () => true }; + if (p === '/valid/file.txt') return { isDirectory: () => false }; + throw new Error('ENOENT'); + }); + + const result = filterValidContextPaths(['/valid/dir', '/valid/file.txt', '/missing']); + + expect(result).toEqual(['/valid/dir', '/valid/file.txt']); + }); + + it('returns an empty array when no path exists', () => { + (fs.statSync as jest.Mock).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(filterValidContextPaths(['/gone-a', '/gone-b'])).toEqual([]); + }); + }); + describe('isDuplicatePath', () => { const originalPlatform = process.platform; diff --git a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts index f981e3a..af64eea 100644 --- a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts @@ -117,6 +117,26 @@ describe('ExternalContextSelector', () => { expect.arrayContaining(['/path/a', '/path/b']) ); }); + + it('persists a single-file external context', () => { + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + selector.setExternalContexts(['/tmp/some-file.txt']); + + selector.togglePersistence('/tmp/some-file.txt'); + + expect(selector.getPersistentPaths()).toContain('/tmp/some-file.txt'); + expect(onPersistenceChange).toHaveBeenCalledWith(['/tmp/some-file.txt']); + }); + + it('keeps persisted file paths when loading from settings', () => { + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + selector.setPersistentPaths(['/tmp/some-file.txt']); + + expect(selector.getPersistentPaths()).toContain('/tmp/some-file.txt'); + }); }); describe('clearExternalContexts', () => { From a94bdd94bac7704d30b999a9c3b2bc168401d2a1 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 22:01:45 +0800 Subject: [PATCH 09/11] chore(chat): drop the directory-only external context helpers filterValidPaths and isValidDirectoryPath lost their last production caller when persistence switched to the file-tolerant validators. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/core/context/external-context.ts | 8 --- .../core/context/external-context.test.ts | 66 ------------------- 2 files changed, 74 deletions(-) diff --git a/src/core/context/external-context.ts b/src/core/context/external-context.ts index 2b7e18e..18ac0e3 100644 --- a/src/core/context/external-context.ts +++ b/src/core/context/external-context.ts @@ -142,10 +142,6 @@ export function validateDirectoryPath(p: string): DirectoryValidationResult { } } -export function isValidDirectoryPath(p: string): boolean { - return validateDirectoryPath(p).valid; -} - export interface ContextPathValidationResult { valid: boolean; error?: string; @@ -172,10 +168,6 @@ export function validateContextPath(p: string): ContextPathValidationResult { } } -export function filterValidPaths(paths: string[]): string[] { - return paths.filter(isValidDirectoryPath); -} - /** Keeps directories and single-file roots alike (external contexts may be either). */ export function filterValidContextPaths(paths: string[]): string[] { return paths.filter((p) => validateContextPath(p).valid); diff --git a/tests/unit/core/context/external-context.test.ts b/tests/unit/core/context/external-context.test.ts index 11e38ba..f362df7 100644 --- a/tests/unit/core/context/external-context.test.ts +++ b/tests/unit/core/context/external-context.test.ts @@ -3,11 +3,9 @@ import * as fs from 'fs'; import { buildExternalContextDisplayEntries, filterValidContextPaths, - filterValidPaths, findConflictingPath, getFolderName, isDuplicatePath, - isValidDirectoryPath, normalizePathForComparison, validateDirectoryPath, } from '@/core/context/external-context'; @@ -261,70 +259,6 @@ describe('externalContext utilities', () => { }); }); - describe('isValidDirectoryPath', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return true for existing directory', () => { - (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); - expect(isValidDirectoryPath('/existing/dir')).toBe(true); - expect(fs.statSync).toHaveBeenCalledWith('/existing/dir'); - }); - - it('should return false for non-existent path', () => { - (fs.statSync as jest.Mock).mockImplementation(() => { - throw new Error('ENOENT'); - }); - expect(isValidDirectoryPath('/non/existent')).toBe(false); - }); - - it('should return false for file path (not directory)', () => { - (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); - expect(isValidDirectoryPath('/path/to/file.txt')).toBe(false); - }); - }); - - describe('filterValidPaths', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should filter out non-existent paths', () => { - (fs.statSync as jest.Mock).mockImplementation((p: string) => { - if (p === '/valid/path') { - return { isDirectory: () => true }; - } - throw new Error('ENOENT'); - }); - - const result = filterValidPaths(['/valid/path', '/invalid/path', '/another/invalid']); - expect(result).toEqual(['/valid/path']); - }); - - it('should return empty array when all paths are invalid', () => { - (fs.statSync as jest.Mock).mockImplementation(() => { - throw new Error('ENOENT'); - }); - - const result = filterValidPaths(['/invalid1', '/invalid2']); - expect(result).toEqual([]); - }); - - it('should return all paths when all are valid', () => { - (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); - - const paths = ['/path1', '/path2', '/path3']; - const result = filterValidPaths(paths); - expect(result).toEqual(paths); - }); - - it('should handle empty array', () => { - const result = filterValidPaths([]); - expect(result).toEqual([]); - }); - }); - describe('filterValidContextPaths', () => { beforeEach(() => { jest.clearAllMocks(); From d09bfe22367a294c7e3fd5ccb6549c5a714fcddd Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 22:17:32 +0800 Subject: [PATCH 10/11] perf(chat): skip the redundant drop-overlay class write dragenter fires for every child element under the pointer, and Obsidian's class helpers rewrite the attribute even when the value is unchanged. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/chat/ui/vault-drop.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index 9df67e6..d46ee98 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -128,7 +128,10 @@ export class VaultDropController { if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); - this.dropOverlayEl.addClass('visible'); + // dragenter fires again for every child element; skip the redundant write. + if (!this.dropOverlayEl.hasClass('visible')) { + this.dropOverlayEl.addClass('visible'); + } }; private readonly handleDragOver = (event: DragEvent): void => { From 71f7e2588fd4e291d55f5fc09012f5e4b7751ea3 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 15 Sep 2026 22:20:20 +0800 Subject: [PATCH 11/11] test(chat): make the file-drop assertion platform-neutral Windows normalizes the path separators, so asserting the exact POSIX literal failed there. Co-authored-by: QoderAI (Qwen 3.8 Max) --- .../features/chat/ui/input-toolbar.external-context.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts index af64eea..7391d18 100644 --- a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts @@ -208,7 +208,9 @@ describe('ExternalContextSelector', () => { const accepted = selector.addExternalContext('/tmp/some-file.txt', { allowFile: true }); expect(accepted.success).toBe(true); - expect(selector.getExternalContexts()).toContain('/tmp/some-file.txt'); + const contexts = selector.getExternalContexts(); + expect(contexts).toHaveLength(1); + expect(contexts[0]).toContain('some-file.txt'); }); it('should reject empty input', () => {