From 09495cf191f64d621fc43181549aeab9d59d326e Mon Sep 17 00:00:00 2001 From: Jonas Kunert Date: Tue, 21 Jul 2026 14:33:58 +0200 Subject: [PATCH 1/2] Add drag & drop for images and file references Dropping image files from the OS reuses the existing createImageFile/ imageAttached paste pipeline; dropping files/tabs from the VS Code explorer inserts an @-file reference into the message input instead, since no file content is available for those drops. Adds a dashed drag-over highlight on the input container. Upstream #185, #80, #54, #119 Co-Authored-By: Claude Fable 5 --- src/script.ts | 108 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui-styles.ts | 6 +++ 2 files changed, 114 insertions(+) diff --git a/src/script.ts b/src/script.ts index 4c949e2..81271fd 100644 --- a/src/script.ts +++ b/src/script.ts @@ -72,6 +72,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt const fileSearchInput = document.getElementById('fileSearchInput'); const fileList = document.getElementById('fileList'); const imageBtn = document.getElementById('imageBtn'); + const inputContainer = document.getElementById('inputContainer'); let isProcessRunning = false; let filteredFiles = []; @@ -1334,6 +1335,113 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // but ensure paste will work when selected }); + // Drag & drop support for images (from OS) and files (from VS Code explorer/editor tabs) + let dragCounter = 0; + + function insertFileReference(filePath) { + const cursorPos = messageInput.selectionStart; + const textBefore = messageInput.value.substring(0, cursorPos); + const textAfter = messageInput.value.substring(cursorPos); + const newText = textBefore + '@' + filePath + ' ' + textAfter; + + messageInput.value = newText; + messageInput.focus(); + + const newCursorPos = textBefore.length + filePath.length + 2; + messageInput.setSelectionRange(newCursorPos, newCursorPos); + adjustTextareaHeight(); + } + + function fileUriToPath(uri) { + if (!uri || uri.indexOf('file://') !== 0) { + return null; + } + try { + let filePath = decodeURIComponent(uri.substring('file://'.length)); + // Strip leading slash from Windows drive paths like /c:/Users/... + if (/^\\/[a-zA-Z]:\\//.test(filePath)) { + filePath = filePath.substring(1); + } + return filePath; + } catch (error) { + console.error('Failed to decode dropped file URI:', uri, error); + return null; + } + } + + document.body.addEventListener('dragenter', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter++; + inputContainer.classList.add('drag-over'); + }); + + document.body.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + document.body.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = Math.max(0, dragCounter - 1); + if (dragCounter === 0) { + inputContainer.classList.remove('drag-over'); + } + }); + + document.body.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + inputContainer.classList.remove('drag-over'); + + const dataTransfer = e.dataTransfer; + if (!dataTransfer) { + return; + } + + // Case 1: files dropped from the OS (e.g. Explorer/Finder) - attach images + if (dataTransfer.files && dataTransfer.files.length > 0) { + let hasSkippedFile = false; + for (let i = 0; i < dataTransfer.files.length; i++) { + const file = dataTransfer.files[i]; + if (file.type && file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = function(event) { + vscode.postMessage({ + type: 'createImageFile', + imageData: event.target.result, + imageType: file.type + }); + }; + reader.readAsDataURL(file); + } else { + hasSkippedFile = true; + } + } + if (hasSkippedFile) { + showToast('Only image files can be dropped directly - use @ to reference other files'); + } + return; + } + + // Case 2: items dropped from VS Code (explorer/editor tabs) - insert as @ file references + const uriList = dataTransfer.getData('text/uri-list'); + if (uriList) { + const uris = uriList.split('\\n') + .map(function(line) { return line.trim(); }) + .filter(function(line) { return line && line.indexOf('#') !== 0; }); + + uris.forEach(function(uri) { + const filePath = fileUriToPath(uri); + if (filePath) { + insertFileReference(filePath); + } + }); + } + }); + // Initialize textarea height adjustTextareaHeight(); diff --git a/src/ui-styles.ts b/src/ui-styles.ts index 76bd766..67785a8 100644 --- a/src/ui-styles.ts +++ b/src/ui-styles.ts @@ -1652,6 +1652,12 @@ const styles = ` position: relative; } + .input-container.drag-over { + outline: 2px dashed var(--vscode-focusBorder); + outline-offset: -2px; + background-color: rgba(139, 92, 246, 0.08); + } + .model-selector-row { display: flex; align-items: center; From 362254a9c0f35870a10e39ef697e5ee566069e51 Mon Sep 17 00:00:00 2001 From: Jonas Kunert Date: Tue, 21 Jul 2026 14:44:08 +0200 Subject: [PATCH 2/2] Harden drag & drop: type-gating, stuck-highlight reset, remote-URI feedback Only react to drags that actually carry Files or text/uri-list (checked via dataTransfer.types) so native in-textarea text drag/drop keeps working instead of being swallowed. Add a shared resetDragState(), wired to window 'dragend'/'blur' as a safety net for cases where dragleave doesn't fire across webview/iframe boundaries (dropping outside the panel, aborting with Esc). Show a toast when a dropped uri-list contains no local file:// entries (e.g. untitled:, vscode-remote:// on SSH/WSL workspaces) instead of failing silently. Co-Authored-By: Claude Fable 5 --- src/script.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/script.ts b/src/script.ts index 81271fd..7380925 100644 --- a/src/script.ts +++ b/src/script.ts @@ -1338,6 +1338,18 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Drag & drop support for images (from OS) and files (from VS Code explorer/editor tabs) let dragCounter = 0; + function resetDragState() { + dragCounter = 0; + inputContainer.classList.remove('drag-over'); + } + + function isRelevantDrag(dataTransfer) { + if (!dataTransfer || !dataTransfer.types) { + return false; + } + return dataTransfer.types.indexOf('Files') !== -1 || dataTransfer.types.indexOf('text/uri-list') !== -1; + } + function insertFileReference(filePath) { const cursorPos = messageInput.selectionStart; const textBefore = messageInput.value.substring(0, cursorPos); @@ -1370,6 +1382,9 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt } document.body.addEventListener('dragenter', (e) => { + if (!isRelevantDrag(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); dragCounter++; @@ -1377,11 +1392,17 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt }); document.body.addEventListener('dragover', (e) => { + if (!isRelevantDrag(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); }); document.body.addEventListener('dragleave', (e) => { + if (!isRelevantDrag(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); dragCounter = Math.max(0, dragCounter - 1); @@ -1390,11 +1411,18 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt } }); + // Safety net: iframe boundaries in VS Code webviews often don't fire dragleave + // (e.g. dropping outside the webview, or aborting the drag with Esc) + window.addEventListener('dragend', resetDragState); + window.addEventListener('blur', resetDragState); + document.body.addEventListener('drop', (e) => { + if (!isRelevantDrag(e.dataTransfer)) { + return; + } e.preventDefault(); e.stopPropagation(); - dragCounter = 0; - inputContainer.classList.remove('drag-over'); + resetDragState(); const dataTransfer = e.dataTransfer; if (!dataTransfer) { @@ -1433,12 +1461,19 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt .map(function(line) { return line.trim(); }) .filter(function(line) { return line && line.indexOf('#') !== 0; }); + let insertedCount = 0; uris.forEach(function(uri) { const filePath = fileUriToPath(uri); if (filePath) { + insertedCount++; insertFileReference(filePath); } }); + + // uris were present but none were local file:// paths (e.g. untitled:, vscode-remote://) + if (uris.length > 0 && insertedCount === 0) { + showToast('Only local files can be dropped here'); + } } });