Skip to content

Commit 147a5b6

Browse files
doscode-krclaude
andcommitted
feat(chat): Add drag & drop file attachment with Shift key guidance
Implement drag-and-drop file attachment functionality for the chat input. Features: - Drag & drop support for images and general files - Image files saved to .claude/claude-code-chat-images/ - General files saved to .claude/claude-code-chat-files/ - Automatic filename sanitization (special chars → underscores) - .gitignore auto-creation for temp directories - Visual feedback with border highlight on dragover - Real-time tooltip guidance for Shift key requirement - Without Shift: "⌨️ Hold Shift key to attach" - With Shift: "📎 Release to attach file" Technical details: - VS Code webview requires Shift key for file path insertion (native limitation) - Cannot override this behavior due to Electron layer interception - Tooltip provides clear UX guidance for the platform constraint Changes: - src/script.ts: Add drag/drop handlers, tooltip functions, filePath handler - src/extension.ts: Add createFile message handler and _createFile method - src/ui-styles.ts: Add .drag-over and .drag-tooltip styles - package.json: Bump version to 1.0.8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent bb983f3 commit 147a5b6

File tree

4 files changed

+213
-1
lines changed

4 files changed

+213
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "claude-code-chat",
33
"displayName": "Chat for Claude Code",
44
"description": "Beautiful Claude Code Chat Interface for VS Code",
5-
"version": "1.0.7",
5+
"version": "1.0.8",
66
"publisher": "AndrePimenta",
77
"author": "Andre Pimenta",
88
"repository": {

src/extension.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ class ClaudeChatProvider {
300300
case 'createImageFile':
301301
this._createImageFile(message.imageData, message.imageType);
302302
return;
303+
case 'createFile':
304+
this._createFile(message.fileData, message.fileName, message.fileType);
305+
return;
303306
case 'permissionResponse':
304307
this._handlePermissionResponse(message.id, message.approved, message.alwaysAllow);
305308
return;
@@ -2387,6 +2390,60 @@ class ClaudeChatProvider {
23872390
}
23882391
}
23892392

2393+
private async _createFile(fileData: string, fileName: string, fileType: string) {
2394+
try {
2395+
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
2396+
if (!workspaceFolder) { return; }
2397+
2398+
// Extract base64 data from data URL
2399+
const base64Data = fileData.split(',')[1];
2400+
const buffer = Buffer.from(base64Data, 'base64');
2401+
2402+
// Sanitize filename: allow only alphanumeric, dots, and hyphens
2403+
const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
2404+
2405+
// Create unique filename with timestamp
2406+
const timestamp = Date.now();
2407+
const uniqueFileName = `file_${timestamp}_${sanitizedName}`;
2408+
2409+
// Create files folder in workspace .claude directory
2410+
const filesDir = vscode.Uri.joinPath(
2411+
workspaceFolder.uri,
2412+
'.claude',
2413+
'claude-code-chat-files'
2414+
);
2415+
await vscode.workspace.fs.createDirectory(filesDir);
2416+
2417+
// Create .gitignore to ignore all files
2418+
const gitignorePath = vscode.Uri.joinPath(filesDir, '.gitignore');
2419+
try {
2420+
await vscode.workspace.fs.stat(gitignorePath);
2421+
} catch {
2422+
// .gitignore doesn't exist, create it
2423+
const gitignoreContent = new TextEncoder().encode('*\n');
2424+
await vscode.workspace.fs.writeFile(gitignorePath, gitignoreContent);
2425+
}
2426+
2427+
// Create the file
2428+
const filePath = vscode.Uri.joinPath(filesDir, uniqueFileName);
2429+
await vscode.workspace.fs.writeFile(filePath, buffer);
2430+
2431+
// Send the file path back to webview
2432+
this._postMessage({
2433+
type: 'filePath',
2434+
data: {
2435+
filePath: filePath.fsPath,
2436+
originalName: fileName
2437+
}
2438+
});
2439+
2440+
console.log(`Created file: ${uniqueFileName}`);
2441+
} catch (error) {
2442+
console.error('Error creating file:', error);
2443+
vscode.window.showErrorMessage('Failed to create file');
2444+
}
2445+
}
2446+
23902447
public dispose() {
23912448
if (this._panel) {
23922449
this._panel.dispose();

src/script.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,110 @@ const getScript = (isTelemetryEnabled: boolean) => `<script>
10261026
// but ensure paste will work when selected
10271027
});
10281028
1029+
// Drag & Drop event handlers for file attachment
1030+
// Add handlers to both messageInput and its parent container for better coverage
1031+
const dropTargets = [messageInput, messageInput.parentElement];
1032+
1033+
dropTargets.forEach(target => {
1034+
if (!target) return;
1035+
1036+
target.addEventListener('dragenter', (e) => {
1037+
e.preventDefault();
1038+
e.stopPropagation();
1039+
messageInput.classList.add('drag-over');
1040+
});
1041+
1042+
target.addEventListener('dragover', (e) => {
1043+
e.preventDefault();
1044+
e.stopPropagation();
1045+
// Force copy effect to allow drop without Shift key
1046+
if (e.dataTransfer) {
1047+
e.dataTransfer.dropEffect = 'copy';
1048+
}
1049+
1050+
// Show shift key guidance tooltip
1051+
const message = e.shiftKey
1052+
? '📎 Release to attach file'
1053+
: '⌨️ Hold Shift key to attach';
1054+
showDragTooltip(message, e.clientX, e.clientY);
1055+
1056+
messageInput.classList.add('drag-over');
1057+
});
1058+
1059+
target.addEventListener('dragleave', (e) => {
1060+
e.preventDefault();
1061+
e.stopPropagation();
1062+
// Only remove class if leaving the entire drop zone
1063+
if (!target.contains(e.relatedTarget)) {
1064+
hideDragTooltip();
1065+
messageInput.classList.remove('drag-over');
1066+
}
1067+
});
1068+
1069+
target.addEventListener('drop', async (e) => {
1070+
e.preventDefault();
1071+
e.stopPropagation();
1072+
hideDragTooltip();
1073+
messageInput.classList.remove('drag-over');
1074+
1075+
const files = e.dataTransfer?.files;
1076+
if (!files || files.length === 0) {
1077+
return;
1078+
}
1079+
1080+
// Process each dropped file
1081+
for (let i = 0; i < files.length; i++) {
1082+
const file = files[i];
1083+
1084+
if (file.type.startsWith('image/')) {
1085+
// Image file: reuse existing createImageFile logic
1086+
const reader = new FileReader();
1087+
reader.onload = (event) => {
1088+
vscode.postMessage({
1089+
type: 'createImageFile',
1090+
imageData: event.target.result,
1091+
imageType: file.type
1092+
});
1093+
};
1094+
reader.readAsDataURL(file);
1095+
} else {
1096+
// General file: use new createFile message
1097+
const reader = new FileReader();
1098+
reader.onload = (event) => {
1099+
vscode.postMessage({
1100+
type: 'createFile',
1101+
fileData: event.target.result,
1102+
fileName: file.name,
1103+
fileType: file.type
1104+
});
1105+
};
1106+
reader.readAsDataURL(file);
1107+
}
1108+
}
1109+
});
1110+
});
1111+
1112+
1113+
// Drag-and-drop tooltip for Shift key guidance
1114+
let dragTooltip = null;
1115+
1116+
function showDragTooltip(message, x, y) {
1117+
if (!dragTooltip) {
1118+
dragTooltip = document.createElement('div');
1119+
dragTooltip.className = 'drag-tooltip';
1120+
document.body.appendChild(dragTooltip);
1121+
}
1122+
dragTooltip.textContent = message;
1123+
dragTooltip.style.left = x + 'px';
1124+
dragTooltip.style.top = (y - 40) + 'px';
1125+
dragTooltip.style.display = 'block';
1126+
}
1127+
1128+
function hideDragTooltip() {
1129+
if (dragTooltip) {
1130+
dragTooltip.style.display = 'none';
1131+
}
1132+
}
10291133
// Initialize textarea height
10301134
adjustTextareaHeight();
10311135
@@ -2005,6 +2109,35 @@ const getScript = (isTelemetryEnabled: boolean) => `<script>
20052109
}
20062110
break;
20072111
2112+
2113+
case 'filePath':
2114+
// Handle general file path response (from drag & drop)
2115+
if (message.data.filePath) {
2116+
// Get current cursor position and content
2117+
const cursorPosition = messageInput.selectionStart || messageInput.value.length;
2118+
const currentValue = messageInput.value || '';
2119+
2120+
// Insert the file path at the current cursor position
2121+
const textBefore = currentValue.substring(0, cursorPosition);
2122+
const textAfter = currentValue.substring(cursorPosition);
2123+
2124+
// Add a space before the path if there's text before and it doesn't end with whitespace
2125+
const separator = (textBefore && !textBefore.endsWith(' ') && !textBefore.endsWith('\\n')) ? ' ' : '';
2126+
2127+
messageInput.value = textBefore + separator + message.data.filePath + textAfter;
2128+
2129+
// Move cursor to end of inserted path
2130+
const newCursorPosition = cursorPosition + separator.length + message.data.filePath.length;
2131+
messageInput.setSelectionRange(newCursorPosition, newCursorPosition);
2132+
2133+
// Focus back on textarea and adjust height
2134+
messageInput.focus();
2135+
adjustTextareaHeight();
2136+
2137+
console.log('Inserted file path:', message.data.filePath);
2138+
console.log('Original file name:', message.data.originalName);
2139+
}
2140+
break;
20082141
case 'updateTokens':
20092142
// Update token totals in real-time
20102143
totalTokensInput = message.data.totalTokensInput || 0;

src/ui-styles.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,6 +1368,28 @@ const styles = `
13681368
outline: none;
13691369
}
13701370
1371+
.input-field.drag-over {
1372+
border: 2px dashed var(--vscode-focusBorder) !important;
1373+
background-color: var(--vscode-inputOption-hoverBackground) !important;
1374+
transition: all 0.2s ease;
1375+
1376+
.drag-tooltip {
1377+
position: fixed;
1378+
background: var(--vscode-editorWidget-background);
1379+
border: 1px solid var(--vscode-editorWidget-border);
1380+
color: var(--vscode-editorWidget-foreground);
1381+
padding: 6px 12px;
1382+
border-radius: 4px;
1383+
font-size: 12px;
1384+
z-index: 10000;
1385+
pointer-events: none;
1386+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
1387+
white-space: nowrap;
1388+
display: none;
1389+
transition: opacity 0.2s ease;
1390+
}
1391+
}
1392+
13711393
.input-field::placeholder {
13721394
color: var(--vscode-input-placeholderForeground);
13731395
border: none;

0 commit comments

Comments
 (0)