Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/tab-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
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();
Expand Down
6 changes: 6 additions & 0 deletions src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -148,6 +149,7 @@ export function createTab(options: TabCreateOptions): TabData {
ui: {
fileContextManager: null,
imageContextManager: null,
vaultDropController: null,
modelSelector: null,
externalContextSelector: null,
mcpServerSelector: null,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
168 changes: 168 additions & 0 deletions src/features/chat/ui/vault-drop.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null;
}

private getDraggedReferences(): VaultDropReference[] {
const references: VaultDropReference[] = [];
const seenPaths = new Set<string>();
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;
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"expand": "キューを展開",
"paused": "現在の応答を中断したため、キューを一時停止しました",
"resume": "再開"
},
"drop": {
"context": "ノートやフォルダをここにドロップしてコンテキストに追加"
}
},
"settings": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"expand": "큐 펼치기",
"paused": "현재 응답을 중단했기 때문에 대기열이 일시 중지되었습니다",
"resume": "계속"
},
"drop": {
"context": "노트나 폴더를 여기에 끌어다 놓아 컨텍스트로 추가"
}
},
"settings": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"expand": "Развернуть очередь",
"paused": "Очередь на паузе: вы прервали текущий ответ",
"resume": "Продолжить"
},
"drop": {
"context": "Перетащите заметки или папки сюда, чтобы добавить их как контекст"
}
},
"settings": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"expand": "展开队列",
"paused": "由于你中断了当前响应,队列已暂停",
"resume": "继续"
},
"drop": {
"context": "拖拽笔记或文件夹到此处,添加为上下文"
}
},
"settings": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"expand": "展開佇列",
"paused": "由於你中斷了當前回應,佇列已暫停",
"resume": "繼續"
},
"drop": {
"context": "拖曳筆記或資料夾到此處,新增為上下文"
}
},
"settings": {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
29 changes: 29 additions & 0 deletions src/style/features/image-context.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading