diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3d0cd..7627c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. composer (aligned with the Qoder IDE send queue). Each entry shows a drag handle for reordering plus edit (withdraw into the composer) and delete actions, and entries drain one per turn end in FIFO order. +- Drag notes or folders from the Obsidian file explorer into the chat + composer: they are inserted as `@note` / `@folder/` context mentions at + the caret, with duplicates skipped and a drop overlay while dragging. ### Changed diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index eb443ab..c522c39 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -38,6 +38,8 @@ export async function destroyTab(tab: TabData): Promise { tab.controllers.inputController?.destroyResumeDropdown(); tab.ui.fileContextManager?.destroy(); + tab.ui.vaultDropController?.destroy(); + tab.ui.vaultDropController = null; tab.ui.modelSelector?.destroy(); tab.ui.modelSelector = null; tab.ui.slashCommandDropdown?.destroy(); diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 0dc2f1e..e9a2309 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -36,6 +36,7 @@ import { InstructionModeManager as InstructionModeManagerClass } from '../ui/ins import { NavigationSidebar } from '../ui/navigation-sidebar'; import { StatusPanel } from '../ui/status-panel'; import { autoResizeTextarea } from '../ui/textarea-resize'; +import { VaultDropController } from '../ui/vault-drop'; import { findRewindContext } from '../utils/rewind'; import { recalculateUsageForModel } from '../utils/usage-info'; import { generateMessageId } from './message-id'; @@ -148,6 +149,7 @@ export function createTab(options: TabCreateOptions): TabData { ui: { fileContextManager: null, imageContextManager: null, + vaultDropController: null, modelSelector: null, externalContextSelector: null, mcpServerSelector: null, @@ -273,6 +275,10 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { ); tab.ui.fileContextManager.setMcpManager(getQoderMcpManager(plugin)); + // Vault file/folder drop - must attach before ImageContextManager so vault + // drags are claimed before the image drop handlers run. + tab.ui.vaultDropController = new VaultDropController(app, dom.inputWrapper, dom.inputEl); + // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl tab.ui.imageContextManager = new ImageContextManager( dom.inputContainerEl, diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index 360dfd2..297e6be 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -28,6 +28,7 @@ import type { import type { InstructionModeManager } from '../ui/instruction-mode-manager'; import type { NavigationSidebar } from '../ui/navigation-sidebar'; import type { StatusPanel } from '../ui/status-panel'; +import type { VaultDropController } from '../ui/vault-drop'; /** * Default number of tabs allowed. @@ -116,6 +117,7 @@ export interface TabServices { export interface TabUIComponents { fileContextManager: FileContextManager | null; imageContextManager: ImageContextManager | null; + vaultDropController: VaultDropController | null; modelSelector: ModelSelector | null; externalContextSelector: ExternalContextSelector | null; mcpServerSelector: McpServerSelector | null; diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts new file mode 100644 index 0000000..3a17400 --- /dev/null +++ b/src/features/chat/ui/vault-drop.ts @@ -0,0 +1,168 @@ +import type { App } from 'obsidian'; +import { TFile, TFolder } from 'obsidian'; + +import { t } from '@/i18n/i18n'; + +/** A vault file or folder reference extracted from an Obsidian drag payload. */ +export interface VaultDropReference { + path: string; + kind: 'file' | 'folder'; +} + +interface DragManagerHost { + dragManager?: unknown; +} + +/** + * Accepts Obsidian file-explorer drags on the composer and inserts them as + * `@path` / `@path/ ` mention tokens at the caret position. + * + * Must be attached before ImageContextManager so vault drags can be claimed + * via stopImmediatePropagation before the image drop handlers run. + */ +export class VaultDropController { + private readonly dropOverlayEl: HTMLElement; + + constructor( + private readonly app: App, + private readonly inputWrapperEl: HTMLElement, + private readonly inputEl: HTMLTextAreaElement, + ) { + 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); + } + + 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); + this.dropOverlayEl.remove(); + } + + private readonly handleDragEnter = (event: DragEvent): void => { + if (this.getDraggedReferences().length === 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + this.dropOverlayEl.addClass('visible'); + }; + + private readonly handleDragOver = (event: DragEvent): void => { + if (this.getDraggedReferences().length === 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + private readonly handleDragLeave = (event: DragEvent): void => { + if (this.getDraggedReferences().length === 0) return; + event.stopImmediatePropagation(); + + const rect = this.inputWrapperEl.getBoundingClientRect(); + if ( + event.clientX <= rect.left || + event.clientX >= rect.right || + event.clientY <= rect.top || + event.clientY >= rect.bottom + ) { + this.dropOverlayEl.removeClass('visible'); + } + }; + + private readonly handleDrop = (event: DragEvent): void => { + const references = this.getDraggedReferences(); + if (references.length === 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + this.dropOverlayEl.removeClass('visible'); + + const newReferences = references.filter((reference) => !this.inputContainsReference(reference)); + if (newReferences.length > 0) { + this.insertReferences(newReferences); + this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); + } + this.inputEl.focus(); + }; + + private getDraggedItems(): unknown[] { + const host = this.app as unknown as DragManagerHost; + const dragManager = host.dragManager; + if (!this.isRecord(dragManager)) return []; + + const draggable = dragManager.draggable; + if (!this.isRecord(draggable)) return []; + + return draggable.type === 'files' && Array.isArray(draggable.files) + ? draggable.files + : draggable.file + ? [draggable.file] + : []; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + + private getDraggedReferences(): VaultDropReference[] { + const references: VaultDropReference[] = []; + const seenPaths = new Set(); + for (const item of this.getDraggedItems()) { + const reference = + item instanceof TFolder && item.path !== '/' && item.path !== '' + ? { path: item.path, kind: 'folder' as const } + : item instanceof TFile && item.extension.toLowerCase() === 'md' + ? { path: item.path, kind: 'file' as const } + : null; + if (!reference || seenPaths.has(reference.path)) continue; + seenPaths.add(reference.path); + references.push(reference); + } + return references; + } + + private mentionToken(reference: VaultDropReference): string { + return `@${reference.path}${reference.kind === 'folder' ? '/' : ''}`; + } + + private inputContainsReference(reference: VaultDropReference): boolean { + const escapedToken = this.mentionToken(reference).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`).test(this.inputEl.value); + } + + private insertReferences(references: readonly VaultDropReference[]): void { + const caret = this.inputEl.selectionStart ?? this.inputEl.value.length; + const before = this.inputEl.value.slice(0, caret); + const after = this.inputEl.value.slice(caret); + const tokens = references.map((reference) => this.mentionToken(reference)).join(' '); + const prefix = before.length > 0 && !/\s$/.test(before) ? ' ' : ''; + const suffix = after.length > 0 && !/^\s/.test(after) ? ' ' : ''; + this.inputEl.value = `${before}${prefix}${tokens} ${suffix}${after}`; + const newCaret = (before + prefix + tokens + ' ').length; + this.inputEl.setSelectionRange(newCaret, newCaret); + } + + private createDropOverlay(): HTMLElement { + const overlayEl = this.inputWrapperEl.createDiv({ cls: 'qoderian-vault-drop-overlay' }); + const contentEl = overlayEl.createDiv({ cls: 'qoderian-vault-drop-content' }); + const svg = contentEl.createSvg('svg', { + attr: { + viewBox: '0 0 24 24', + width: '32', + height: '32', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + }, + }); + // paperclip icon + svg.createSvg('path', { + attr: { + d: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48', + }, + }); + contentEl.createSpan({ text: t('chat.drop.context') }); + return overlayEl; + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3007f15..05bc72e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -109,6 +109,9 @@ "expand": "Warteschlange ausklappen", "paused": "Warteschlange pausiert, weil du die aktuelle Antwort unterbrochen hast", "resume": "Fortsetzen" + }, + "drop": { + "context": "Notizen oder Ordner hier ablegen, um sie als Kontext hinzuzufügen" } }, "settings": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7b24730..b6a6e66 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -109,6 +109,9 @@ "expand": "Expand queue", "paused": "Queue paused because you interrupted the current response", "resume": "Resume" + }, + "drop": { + "context": "Drop notes or folders here to add as context" } }, "settings": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e2d81d0..1f614d7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -109,6 +109,9 @@ "expand": "Expandir cola", "paused": "Cola en pausa porque interrumpiste la respuesta actual", "resume": "Continuar" + }, + "drop": { + "context": "Arrastra notas o carpetas aquí para añadirlas como contexto" } }, "settings": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ace8794..8e6bb91 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -109,6 +109,9 @@ "expand": "Déplier la file", "paused": "File en pause car vous avez interrompu la réponse en cours", "resume": "Reprendre" + }, + "drop": { + "context": "Déposez des notes ou des dossiers ici pour les ajouter comme contexte" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2c8bde6..759800d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -109,6 +109,9 @@ "expand": "キューを展開", "paused": "現在の応答を中断したため、キューを一時停止しました", "resume": "再開" + }, + "drop": { + "context": "ノートやフォルダをここにドロップしてコンテキストに追加" } }, "settings": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index bdb9593..a85a65a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -109,6 +109,9 @@ "expand": "큐 펼치기", "paused": "현재 응답을 중단했기 때문에 대기열이 일시 중지되었습니다", "resume": "계속" + }, + "drop": { + "context": "노트나 폴더를 여기에 끌어다 놓아 컨텍스트로 추가" } }, "settings": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4c0ef2e..d021c48 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -109,6 +109,9 @@ "expand": "Expandir fila", "paused": "Fila pausada porque você interrompeu a resposta atual", "resume": "Continuar" + }, + "drop": { + "context": "Solte notas ou pastas aqui para adicioná-las como contexto" } }, "settings": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 52aac3d..7cc11a2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -109,6 +109,9 @@ "expand": "Развернуть очередь", "paused": "Очередь на паузе: вы прервали текущий ответ", "resume": "Продолжить" + }, + "drop": { + "context": "Перетащите заметки или папки сюда, чтобы добавить их как контекст" } }, "settings": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5167bd5..38a5bee 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -109,6 +109,9 @@ "expand": "展开队列", "paused": "由于你中断了当前响应,队列已暂停", "resume": "继续" + }, + "drop": { + "context": "拖拽笔记或文件夹到此处,添加为上下文" } }, "settings": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 74f57f2..690beeb 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -109,6 +109,9 @@ "expand": "展開佇列", "paused": "由於你中斷了當前回應,佇列已暫停", "resume": "繼續" + }, + "drop": { + "context": "拖曳筆記或資料夾到此處,新增為上下文" } }, "settings": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 60c7dff..34de50d 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -128,6 +128,9 @@ export type TranslationKey = | 'chat.queue.paused' | 'chat.queue.resume' + // Vault drag & drop into composer + | 'chat.drop.context' + // Settings - Section Headings | 'settings.title' | 'settings.display' diff --git a/src/style/features/image-context.css b/src/style/features/image-context.css index 26c2ec8..4ddc53f 100644 --- a/src/style/features/image-context.css +++ b/src/style/features/image-context.css @@ -125,6 +125,35 @@ display: flex; } +/* Vault file/folder drop overlay - same visual language as image drop */ +.qoderian-vault-drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--qoderian-brand-rgb), 0.08); + border: 2px dashed var(--qoderian-brand); + border-radius: 6px; + display: none; + align-items: center; + justify-content: center; + z-index: 100; + pointer-events: none; +} + +.qoderian-vault-drop-overlay.visible { + display: flex; +} + +.qoderian-vault-drop-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: var(--qoderian-brand); +} + .qoderian-drop-content { display: flex; flex-direction: column; diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts new file mode 100644 index 0000000..40f048e --- /dev/null +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -0,0 +1,233 @@ +/** + * @jest-environment jsdom + */ +import { createMockEl } from '@test/helpers/mock-element'; +import { TFile, TFolder } from 'obsidian'; + +import { VaultDropController } from '@/features/chat/ui/vault-drop'; + +function makeFile(path: string): any { + const file = new (TFile as unknown as new () => Record)(); + file.path = path; + file.extension = path.split('.').pop() ?? ''; + return file; +} + +function makeFolder(path: string): any { + const folder = new (TFolder as unknown as new () => Record)(); + folder.path = path; + return folder; +} + +function createDropEvent(overrides: Record = {}) { + return { + type: 'drop', + preventDefault: jest.fn(), + stopImmediatePropagation: jest.fn(), + clientX: 10, + clientY: 10, + ...overrides, + }; +} + +function createDragEvent(type: string, overrides: Record = {}) { + return { + type, + preventDefault: jest.fn(), + stopImmediatePropagation: jest.fn(), + clientX: 10, + clientY: 10, + ...overrides, + }; +} + +function createApp(draggable: unknown): any { + return { dragManager: { draggable } }; +} + +function createInputEl(value = ''): any { + const el = createMockEl('textarea'); + el.value = value; + el.selectionStart = value.length; + el.selectionEnd = value.length; + el.setSelectionRange = jest.fn((start: number, end: number) => { + el.selectionStart = start; + el.selectionEnd = end; + }); + return el; +} + +function findOverlay(wrapper: any): any { + const overlays = (wrapper.children as any[]).filter( + (child) => child.className && child.className.includes('qoderian-vault-drop-overlay'), + ); + return overlays[0]; +} + +describe('VaultDropController', () => { + let wrapper: any; + let inputEl: any; + + beforeEach(() => { + wrapper = createMockEl(); + wrapper.getBoundingClientRect = () => ({ + top: 0, + left: 0, + width: 200, + height: 100, + right: 200, + bottom: 100, + x: 0, + y: 0, + toJSON: () => {}, + }); + inputEl = createInputEl(); + }); + + describe('drop handling', () => { + it('inserts a markdown file mention at the caret', () => { + const app = createApp({ file: makeFile('notes/idea.md') }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@notes/idea.md '); + }); + + it('inserts a folder mention with trailing slash', () => { + const app = createApp({ type: 'files', files: [makeFolder('projects/alpha')] }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@projects/alpha/ '); + }); + + it('inserts multiple references separated by spaces', () => { + const app = createApp({ + type: 'files', + files: [makeFile('a.md'), makeFolder('dir'), makeFile('b.md')], + }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@a.md @dir/ @b.md '); + }); + + it('skips non-markdown files, root folder, and duplicates', () => { + const app = createApp({ + type: 'files', + files: [ + makeFile('image.png'), + makeFolder('/'), + makeFile('a.md'), + makeFile('a.md'), + ], + }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@a.md '); + }); + + it('does not insert a mention that already exists in the input', () => { + inputEl.value = 'see @notes/idea.md for details'; + const app = createApp({ file: makeFile('notes/idea.md') }); + new VaultDropController(app, wrapper, inputEl); + + const event = createDropEvent(); + wrapper.dispatchEvent('drop', event); + + expect(inputEl.value).toBe('see @notes/idea.md for details'); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('preserves surrounding text and adds spacing when needed', () => { + inputEl.value = 'review this:'; + inputEl.selectionStart = inputEl.value.length; + const app = createApp({ file: makeFile('notes/idea.md') }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('review this: @notes/idea.md '); + }); + + it('ignores drops without a vault drag payload', () => { + const app = createApp(null); + new VaultDropController(app, wrapper, inputEl); + + const event = createDropEvent(); + wrapper.dispatchEvent('drop', event); + + expect(inputEl.value).toBe(''); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('dispatches an input event after inserting mentions', () => { + const app = createApp({ file: makeFile('a.md') }); + new VaultDropController(app, wrapper, inputEl); + + const seen: string[] = []; + inputEl.addEventListener('input', () => seen.push('input')); + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(seen).toEqual(['input']); + }); + }); + + describe('overlay visibility', () => { + it('shows the overlay on vault dragenter and claims the event', () => { + const app = createApp({ file: makeFile('a.md') }); + new VaultDropController(app, wrapper, inputEl); + + const event = createDragEvent('dragenter'); + wrapper.dispatchEvent('dragenter', event); + + const overlay = findOverlay(wrapper); + expect(overlay.className).toContain('visible'); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + }); + + it('hides the overlay when the drag leaves the wrapper bounds', () => { + const app = createApp({ file: makeFile('a.md') }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('dragenter', createDragEvent('dragenter')); + wrapper.dispatchEvent('dragleave', createDragEvent('dragleave', { clientX: 500, clientY: 500 })); + + const overlay = findOverlay(wrapper); + expect(overlay.className).not.toContain('visible'); + }); + + it('does not show the overlay for non-vault drags', () => { + const app = createApp(null); + new VaultDropController(app, wrapper, inputEl); + + const event = createDragEvent('dragenter'); + wrapper.dispatchEvent('dragenter', event); + + const overlay = findOverlay(wrapper); + expect(overlay.className).not.toContain('visible'); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('removes listeners and the overlay', () => { + const app = createApp({ file: makeFile('a.md') }); + const controller = new VaultDropController(app, wrapper, inputEl); + const overlay = findOverlay(wrapper); + overlay.remove = jest.fn(); + controller.destroy(); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe(''); + expect(overlay.remove).toHaveBeenCalled(); + }); + }); +});