diff --git a/CHANGELOG.md b/CHANGELOG.md index 514ca72..a0655cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### 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 + 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/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/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/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..18ac0e3 100644 --- a/src/core/context/external-context.ts +++ b/src/core/context/external-context.ts @@ -142,12 +142,35 @@ export function validateDirectoryPath(p: string): DirectoryValidationResult { } } -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); +/** 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 { diff --git a/src/features/chat/rendering/message-renderer.ts b/src/features/chat/rendering/message-renderer.ts index 8dc2259..1eb8076 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, @@ -886,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.', diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index fb78bc9..8b61de8 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 @@ -832,6 +835,7 @@ export function initializeTabControllers( forkRequestCallback ? (id) => handleForkRequest(tab, plugin, id, forkRequestCallback) : undefined, + () => ui.externalContextSelector?.getExternalContexts() ?? [], ); // Selection controller diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index bc302d0..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, 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); @@ -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}` }; } @@ -258,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]; @@ -433,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 f24d63c..d46ee98 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,38 @@ 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 }; + /** 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 { @@ -26,6 +60,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 +73,9 @@ interface DragManagerHost { 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( private readonly app: App, @@ -42,36 +84,66 @@ export class VaultDropController { options: VaultDropOptions = {}, ) { this.onInsertReference = options.onInsertReference; + this.onAddExternalContext = options.onAddExternalContext; + this.resolveNativeFilePath = options.resolveNativeFilePath ?? resolveNativeFilePath; this.dropOverlayEl = this.createDropOverlay(); - 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); + const viewWindow = this.inputWrapperEl.ownerDocument?.defaultView ?? null; + this.viewWindow = viewWindow && typeof viewWindow.addEventListener === 'function' + ? viewWindow + : null; + 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); + 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.hasClaimableDrag()) return; + if (!this.isDropWithinInput(event)) return; + 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 => { - if (!this.hasClaimableDrag()) return; + if (!this.isDropWithinInput(event)) return; + if (!this.hasClaimableDrag(event)) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { - if (!this.hasClaimableDrag()) return; + if (!this.isDropWithinInput(event)) return; + if (!this.hasClaimableDrag(event)) return; event.stopImmediatePropagation(); const rect = this.inputWrapperEl.getBoundingClientRect(); @@ -86,10 +158,22 @@ 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 || + osDrag.unreadable > 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 +188,108 @@ export class VaultDropController { } this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); } + + for (const dir of osDrag.dirs) { + this.addExternalContextWithFeedback(dir); + } + for (const file of osDrag.files) { + this.addExternalContextWithFeedback(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 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; + } + + 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 = this.resolveNativeFilePath(file) || 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..9bd4df8 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -165,8 +165,10 @@ "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" + "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" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4818be4..7ad8910 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -165,8 +165,10 @@ "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" + "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" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 24e52af..3cfbb6b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -165,8 +165,10 @@ "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" + "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" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8dff43c..db35c7a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -165,8 +165,10 @@ "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" + "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" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7dc0137..f805751 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -165,8 +165,10 @@ "moreFiles": "さらに {count} ファイルを表示" }, "drop": { - "context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加", - "ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます" + "added": "外部コンテキストを追加しました:{path}", + "failed": "外部コンテキストを追加できませんでした:{error}", + "context": "ノート、フォルダ、画像、ファイルをここにドロップしてコンテキストに追加", + "ignored": "サポートされていない {count} 件を無視しました。ノート、フォルダ、画像、ファイルのみドロップできます" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 0829151..d91904c 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -165,8 +165,10 @@ "moreFiles": "파일 {count}개 더 보기" }, "drop": { - "context": "노트, 폴더, 이미지를 여기에 끌어다 놓아 컨텍스트로 추가", - "ignored": "지원되지 않는 파일 {count}개를 무시했습니다. 노트, 폴더, 이미지만 끌어다 놓을 수 있습니다" + "added": "외부 컨텍스트 추가됨: {path}", + "failed": "외부 컨텍스트를 추가하지 못했습니다: {error}", + "context": "노트, 폴더, 이미지, 파일을 여기에 끌어다 놓아 컨텍스트로 추가", + "ignored": "지원되지 않는 {count}개를 무시했습니다. 노트, 폴더, 이미지, 파일만 끌어다 놓을 수 있습니다" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 071a65d..3a1a4cd 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -165,8 +165,10 @@ "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" + "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" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6cf3f94..49f95d1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -165,8 +165,10 @@ "moreFiles": "Показать ещё файлов: {count}" }, "drop": { - "context": "Перетащите заметки, папки или изображения сюда, чтобы добавить их как контекст", - "ignored": "Пропущено {count} неподдерживаемых файлов; перетаскивать можно только заметки, папки и изображения" + "added": "Добавлен внешний контекст: {path}", + "failed": "Не удалось добавить внешний контекст: {error}", + "context": "Перетащите заметки, папки, изображения или файлы сюда, чтобы добавить их как контекст", + "ignored": "Пропущено {count} неподдерживаемых элементов; перетаскивать можно только заметки, папки, изображения и файлы" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 73130ca..fcad0eb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -165,8 +165,10 @@ "moreFiles": "再显示 {count} 个文件" }, "drop": { - "context": "拖拽笔记、文件夹或图片到此处,添加为上下文", - "ignored": "已忽略 {count} 个不支持的文件,仅支持拖入笔记、文件夹和图片" + "added": "已添加外部上下文:{path}", + "failed": "添加外部上下文失败:{error}", + "context": "拖拽笔记、文件夹、图片或文件到此处,添加为上下文", + "ignored": "已忽略 {count} 个不支持的项,仅支持拖入笔记、文件夹、图片和文件" }, "permissionMode": { "default": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e820c4c..a810c7e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -165,8 +165,10 @@ "moreFiles": "再顯示 {count} 個檔案" }, "drop": { - "context": "拖曳筆記、資料夾或圖片到此處,新增為上下文", - "ignored": "已忽略 {count} 個不支援的檔案,僅支援拖入筆記、資料夾與圖片" + "added": "已新增外部上下文:{path}", + "failed": "新增外部上下文失敗:{error}", + "context": "拖曳筆記、資料夾、圖片或檔案到此處,新增為上下文", + "ignored": "已忽略 {count} 個不支援的項目,僅支援拖入筆記、資料夾、圖片與檔案" }, "permissionMode": { "default": { 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/src/shared/markdown/mention-chip.ts b/src/shared/markdown/mention-chip.ts index 39079e2..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}` + `` @@ -177,17 +182,49 @@ 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 externalRootsByName = new Map(); + for (const contextPath of externalContexts) { + const segments = contextPath.replace(/\\/g, '/').split('/').filter(Boolean); + const name = segments[segments.length - 1]; + if (name) externalRootsByName.set(name.toLowerCase(), contextPath); + } + + const externalAbsolutePath = (path: string): string | null => { + if (externalRootsByName.size === 0) return null; + const segments = path.replace(/\\/g, '/').replace(/^\/+/, '').split('/'); + const [rootName, ...rest] = segments; + 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 => { 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 externalAbsolutePath(path) !== null; }; const candidates = findMentionCandidates(markdown, resolvePath); if (candidates.length === 0) { @@ -203,19 +240,22 @@ 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 externalPath = resolved ? null : externalAbsolutePath(candidate.path); const kind: ReferenceChipKind | null = resolved instanceof TFolder ? 'folder' : resolved instanceof TFile ? 'file' - : null; - if (!kind || !resolved) { + : externalPath + ? (candidate.hasTrailingSlash ? 'folder' : 'file') + : null; + if (!kind) { continue; } 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/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/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/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/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/core/context/external-context.test.ts b/tests/unit/core/context/external-context.test.ts index 2c82569..f362df7 100644 --- a/tests/unit/core/context/external-context.test.ts +++ b/tests/unit/core/context/external-context.test.ts @@ -2,11 +2,10 @@ import * as fs from 'fs'; import { buildExternalContextDisplayEntries, - filterValidPaths, + filterValidContextPaths, findConflictingPath, getFolderName, isDuplicatePath, - isValidDirectoryPath, normalizePathForComparison, validateDirectoryPath, } from '@/core/context/external-context'; @@ -260,67 +259,29 @@ describe('externalContext utilities', () => { }); }); - describe('isValidDirectoryPath', () => { + describe('filterValidContextPaths', () => { 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', () => { + it('keeps directories and single files, dropping missing paths', () => { (fs.statSync as jest.Mock).mockImplementation((p: string) => { - if (p === '/valid/path') { - return { isDirectory: () => true }; - } + if (p === '/valid/dir') return { isDirectory: () => true }; + if (p === '/valid/file.txt') return { isDirectory: () => false }; throw new Error('ENOENT'); }); - const result = filterValidPaths(['/valid/path', '/invalid/path', '/another/invalid']); - expect(result).toEqual(['/valid/path']); + const result = filterValidContextPaths(['/valid/dir', '/valid/file.txt', '/missing']); + + expect(result).toEqual(['/valid/dir', '/valid/file.txt']); }); - it('should return empty array when all paths are invalid', () => { + it('returns an empty array when no path exists', () => { (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([]); + expect(filterValidContextPaths(['/gone-a', '/gone-b'])).toEqual([]); }); }); 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( 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..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 @@ -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', () => { @@ -179,6 +199,20 @@ 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); + const contexts = selector.getExternalContexts(); + expect(contexts).toHaveLength(1); + expect(contexts[0]).toContain('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..cbff0d0 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,92 @@ describe('VaultDropController', () => { inputEl = createInputEl(); }); + describe('OS-level (Finder) drags', () => { + const osFile = (name: string, type: string) => ({ name, type }); + + 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(); + 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: [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(); + }); + + 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(() => ({ success: true })); + new VaultDropController(createApp(undefined), wrapper, inputEl, { + onAddExternalContext, + resolveNativeFilePath: () => '/tmp/dir', + }); + + wrapper.dispatchEvent('drop', createDropEvent({ + 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, + resolveNativeFilePath: () => '/tmp/i.png', + }); + + const event = createDropEvent({ + dataTransfer: { types: ['Files'], files: [osFile('i.png', 'image/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')] }, + })); + + 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') }); diff --git a/tests/unit/shared/markdown/mention-chip.test.ts b/tests/unit/shared/markdown/mention-chip.test.ts index f8e3a22..de54e57 100644 --- a/tests/unit/shared/markdown/mention-chip.test.ts +++ b/tests/unit/shared/markdown/mention-chip.test.ts @@ -98,6 +98,47 @@ 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="/Users/me/Desktop/qoderian-verify"'); + expect(result).toContain('data-external="true"'); + 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="/Users/me/Desktop/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="/Users/me/Desktop/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 = [ 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([ 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(); + }); }); });