Skip to content

Commit 25363e9

Browse files
luoxuanzaoQoder-AI
andcommitted
feat(chat): drop vault notes and folders into the composer
Drag files/folders from the Obsidian file explorer onto the chat input: markdown notes become @path mentions, folders become @path/ mentions, inserted at the caret with duplicates skipped and a drop overlay while dragging. The controller attaches before ImageContextManager so vault drags are claimed without disturbing OS image drops. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent c951fde commit 25363e9

18 files changed

Lines changed: 476 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1818
composer (aligned with the Qoder IDE send queue). Each entry shows a
1919
drag handle for reordering plus edit (withdraw into the composer) and
2020
delete actions, and entries drain one per turn end in FIFO order.
21+
- Drag notes or folders from the Obsidian file explorer into the chat
22+
composer: they are inserted as `@note` / `@folder/` context mentions at
23+
the caret, with duplicates skipped and a drop overlay while dragging.
2124

2225
### Changed
2326

‎src/features/chat/tabs/tab-lifecycle.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
3838
tab.controllers.inputController?.destroyResumeDropdown();
3939

4040
tab.ui.fileContextManager?.destroy();
41+
tab.ui.vaultDropController?.destroy();
42+
tab.ui.vaultDropController = null;
4143
tab.ui.modelSelector?.destroy();
4244
tab.ui.modelSelector = null;
4345
tab.ui.slashCommandDropdown?.destroy();

‎src/features/chat/tabs/tab.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { InstructionModeManager as InstructionModeManagerClass } from '../ui/ins
3636
import { NavigationSidebar } from '../ui/navigation-sidebar';
3737
import { StatusPanel } from '../ui/status-panel';
3838
import { autoResizeTextarea } from '../ui/textarea-resize';
39+
import { VaultDropController } from '../ui/vault-drop';
3940
import { findRewindContext } from '../utils/rewind';
4041
import { recalculateUsageForModel } from '../utils/usage-info';
4142
import { generateMessageId } from './message-id';
@@ -148,6 +149,7 @@ export function createTab(options: TabCreateOptions): TabData {
148149
ui: {
149150
fileContextManager: null,
150151
imageContextManager: null,
152+
vaultDropController: null,
151153
modelSelector: null,
152154
externalContextSelector: null,
153155
mcpServerSelector: null,
@@ -273,6 +275,10 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void {
273275
);
274276
tab.ui.fileContextManager.setMcpManager(getQoderMcpManager(plugin));
275277

278+
// Vault file/folder drop - must attach before ImageContextManager so vault
279+
// drags are claimed before the image drop handlers run.
280+
tab.ui.vaultDropController = new VaultDropController(app, dom.inputWrapper, dom.inputEl);
281+
276282
// Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl
277283
tab.ui.imageContextManager = new ImageContextManager(
278284
dom.inputContainerEl,

‎src/features/chat/tabs/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
import type { InstructionModeManager } from '../ui/instruction-mode-manager';
2929
import type { NavigationSidebar } from '../ui/navigation-sidebar';
3030
import type { StatusPanel } from '../ui/status-panel';
31+
import type { VaultDropController } from '../ui/vault-drop';
3132

3233
/**
3334
* Default number of tabs allowed.
@@ -116,6 +117,7 @@ export interface TabServices {
116117
export interface TabUIComponents {
117118
fileContextManager: FileContextManager | null;
118119
imageContextManager: ImageContextManager | null;
120+
vaultDropController: VaultDropController | null;
119121
modelSelector: ModelSelector | null;
120122
externalContextSelector: ExternalContextSelector | null;
121123
mcpServerSelector: McpServerSelector | null;

‎src/features/chat/ui/vault-drop.ts‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { App } from 'obsidian';
2+
import { TFile, TFolder } from 'obsidian';
3+
4+
import { t } from '@/i18n/i18n';
5+
6+
/** A vault file or folder reference extracted from an Obsidian drag payload. */
7+
export interface VaultDropReference {
8+
path: string;
9+
kind: 'file' | 'folder';
10+
}
11+
12+
interface DragManagerHost {
13+
dragManager?: unknown;
14+
}
15+
16+
/**
17+
* Accepts Obsidian file-explorer drags on the composer and inserts them as
18+
* `@path` / `@path/ ` mention tokens at the caret position.
19+
*
20+
* Must be attached before ImageContextManager so vault drags can be claimed
21+
* via stopImmediatePropagation before the image drop handlers run.
22+
*/
23+
export class VaultDropController {
24+
private readonly dropOverlayEl: HTMLElement;
25+
26+
constructor(
27+
private readonly app: App,
28+
private readonly inputWrapperEl: HTMLElement,
29+
private readonly inputEl: HTMLTextAreaElement,
30+
) {
31+
this.dropOverlayEl = this.createDropOverlay();
32+
this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter);
33+
this.inputWrapperEl.addEventListener('dragover', this.handleDragOver);
34+
this.inputWrapperEl.addEventListener('dragleave', this.handleDragLeave);
35+
this.inputWrapperEl.addEventListener('drop', this.handleDrop);
36+
}
37+
38+
destroy(): void {
39+
this.inputWrapperEl.removeEventListener('dragenter', this.handleDragEnter);
40+
this.inputWrapperEl.removeEventListener('dragover', this.handleDragOver);
41+
this.inputWrapperEl.removeEventListener('dragleave', this.handleDragLeave);
42+
this.inputWrapperEl.removeEventListener('drop', this.handleDrop);
43+
this.dropOverlayEl.remove();
44+
}
45+
46+
private readonly handleDragEnter = (event: DragEvent): void => {
47+
if (this.getDraggedReferences().length === 0) return;
48+
event.preventDefault();
49+
event.stopImmediatePropagation();
50+
this.dropOverlayEl.addClass('visible');
51+
};
52+
53+
private readonly handleDragOver = (event: DragEvent): void => {
54+
if (this.getDraggedReferences().length === 0) return;
55+
event.preventDefault();
56+
event.stopImmediatePropagation();
57+
};
58+
59+
private readonly handleDragLeave = (event: DragEvent): void => {
60+
if (this.getDraggedReferences().length === 0) return;
61+
event.stopImmediatePropagation();
62+
63+
const rect = this.inputWrapperEl.getBoundingClientRect();
64+
if (
65+
event.clientX <= rect.left ||
66+
event.clientX >= rect.right ||
67+
event.clientY <= rect.top ||
68+
event.clientY >= rect.bottom
69+
) {
70+
this.dropOverlayEl.removeClass('visible');
71+
}
72+
};
73+
74+
private readonly handleDrop = (event: DragEvent): void => {
75+
const references = this.getDraggedReferences();
76+
if (references.length === 0) return;
77+
event.preventDefault();
78+
event.stopImmediatePropagation();
79+
this.dropOverlayEl.removeClass('visible');
80+
81+
const newReferences = references.filter((reference) => !this.inputContainsReference(reference));
82+
if (newReferences.length > 0) {
83+
this.insertReferences(newReferences);
84+
this.inputEl.dispatchEvent(new Event('input', { bubbles: true }));
85+
}
86+
this.inputEl.focus();
87+
};
88+
89+
private getDraggedItems(): unknown[] {
90+
const host = this.app as unknown as DragManagerHost;
91+
const dragManager = host.dragManager;
92+
if (!this.isRecord(dragManager)) return [];
93+
94+
const draggable = dragManager.draggable;
95+
if (!this.isRecord(draggable)) return [];
96+
97+
return draggable.type === 'files' && Array.isArray(draggable.files)
98+
? draggable.files
99+
: draggable.file
100+
? [draggable.file]
101+
: [];
102+
}
103+
104+
private isRecord(value: unknown): value is Record<string, unknown> {
105+
return typeof value === 'object' && value !== null;
106+
}
107+
108+
private getDraggedReferences(): VaultDropReference[] {
109+
const references: VaultDropReference[] = [];
110+
const seenPaths = new Set<string>();
111+
for (const item of this.getDraggedItems()) {
112+
const reference =
113+
item instanceof TFolder && item.path !== '/' && item.path !== ''
114+
? { path: item.path, kind: 'folder' as const }
115+
: item instanceof TFile && item.extension.toLowerCase() === 'md'
116+
? { path: item.path, kind: 'file' as const }
117+
: null;
118+
if (!reference || seenPaths.has(reference.path)) continue;
119+
seenPaths.add(reference.path);
120+
references.push(reference);
121+
}
122+
return references;
123+
}
124+
125+
private mentionToken(reference: VaultDropReference): string {
126+
return `@${reference.path}${reference.kind === 'folder' ? '/' : ''}`;
127+
}
128+
129+
private inputContainsReference(reference: VaultDropReference): boolean {
130+
const escapedToken = this.mentionToken(reference).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131+
return new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`).test(this.inputEl.value);
132+
}
133+
134+
private insertReferences(references: readonly VaultDropReference[]): void {
135+
const caret = this.inputEl.selectionStart ?? this.inputEl.value.length;
136+
const before = this.inputEl.value.slice(0, caret);
137+
const after = this.inputEl.value.slice(caret);
138+
const tokens = references.map((reference) => this.mentionToken(reference)).join(' ');
139+
const prefix = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
140+
const suffix = after.length > 0 && !/^\s/.test(after) ? ' ' : '';
141+
this.inputEl.value = `${before}${prefix}${tokens} ${suffix}${after}`;
142+
const newCaret = (before + prefix + tokens + ' ').length;
143+
this.inputEl.setSelectionRange(newCaret, newCaret);
144+
}
145+
146+
private createDropOverlay(): HTMLElement {
147+
const overlayEl = this.inputWrapperEl.createDiv({ cls: 'qoderian-vault-drop-overlay' });
148+
const contentEl = overlayEl.createDiv({ cls: 'qoderian-vault-drop-content' });
149+
const svg = contentEl.createSvg('svg', {
150+
attr: {
151+
viewBox: '0 0 24 24',
152+
width: '32',
153+
height: '32',
154+
fill: 'none',
155+
stroke: 'currentColor',
156+
'stroke-width': '2',
157+
},
158+
});
159+
// paperclip icon
160+
svg.createSvg('path', {
161+
attr: {
162+
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',
163+
},
164+
});
165+
contentEl.createSpan({ text: t('chat.drop.context') });
166+
return overlayEl;
167+
}
168+
}

‎src/i18n/locales/de.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"expand": "Warteschlange ausklappen",
110110
"paused": "Warteschlange pausiert, weil du die aktuelle Antwort unterbrochen hast",
111111
"resume": "Fortsetzen"
112+
},
113+
"drop": {
114+
"context": "Notizen oder Ordner hier ablegen, um sie als Kontext hinzuzufügen"
112115
}
113116
},
114117
"settings": {

‎src/i18n/locales/en.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"expand": "Expand queue",
110110
"paused": "Queue paused because you interrupted the current response",
111111
"resume": "Resume"
112+
},
113+
"drop": {
114+
"context": "Drop notes or folders here to add as context"
112115
}
113116
},
114117
"settings": {

‎src/i18n/locales/es.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"expand": "Expandir cola",
110110
"paused": "Cola en pausa porque interrumpiste la respuesta actual",
111111
"resume": "Continuar"
112+
},
113+
"drop": {
114+
"context": "Arrastra notas o carpetas aquí para añadirlas como contexto"
112115
}
113116
},
114117
"settings": {

‎src/i18n/locales/fr.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"expand": "Déplier la file",
110110
"paused": "File en pause car vous avez interrompu la réponse en cours",
111111
"resume": "Reprendre"
112+
},
113+
"drop": {
114+
"context": "Déposez des notes ou des dossiers ici pour les ajouter comme contexte"
112115
}
113116
},
114117
"settings": {

‎src/i18n/locales/ja.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"expand": "キューを展開",
110110
"paused": "現在の応答を中断したため、キューを一時停止しました",
111111
"resume": "再開"
112+
},
113+
"drop": {
114+
"context": "ノートやフォルダをここにドロップしてコンテキストに追加"
112115
}
113116
},
114117
"settings": {

0 commit comments

Comments
 (0)