From b68a10571c71ed50c7270700955420a19ba805d0 Mon Sep 17 00:00:00 2001 From: Asuta <39234777+Asuta@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:21:12 +0800 Subject: [PATCH] feat(vscode): support drag-and-drop file attachments in chat (#535) * feat(vscode): implement file drop API for handling dropped files in VS Code * refactor(vscode): deduplicate attachment parsing for picked and dropped files --- packages/ui/src/components/chat/ChatInput.tsx | 100 +++++++++++- packages/vscode/src/bridge.ts | 149 +++++++++++++----- packages/vscode/webview/main.tsx | 9 ++ 3 files changed, 214 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index af453032..cab3f1da 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1442,8 +1442,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => { if (!dataTransfer) return false; if (dataTransfer.files && dataTransfer.files.length > 0) return true; - if (!dataTransfer.types) return false; - return Array.from(dataTransfer.types).includes('Files'); + if (dataTransfer.types) { + const types = Array.from(dataTransfer.types); + if (types.includes('Files')) return true; + if (types.includes('text/uri-list')) return true; + } + + const uriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain'); + return typeof uriList === 'string' && uriList.toLowerCase().includes('file://'); }, []); const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => { @@ -1462,6 +1468,87 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo return fromItems; }, []); + const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => { + if (!dataTransfer || typeof dataTransfer.getData !== 'function') return []; + + const rawUriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain'); + if (!rawUriList) return []; + + const candidates = rawUriList + .split(/\r?\n/) + .map((value) => value.trim()) + .filter((value) => value.length > 0 && !value.startsWith('#')) + .filter((value) => value.toLowerCase().startsWith('file://')); + + return Array.from(new Set(candidates)); + }, []); + + const attachVSCodeDroppedUris = React.useCallback(async (uris: string[]) => { + if (uris.length === 0) return; + + try { + const response = await fetch('/api/vscode/drop-files', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ uris }), + }); + + if (!response.ok) { + throw new Error(`Failed to attach dropped files (${response.status})`); + } + + const data = await response.json(); + const picked = Array.isArray(data?.files) ? data.files : []; + const skipped = Array.isArray(data?.skipped) ? data.skipped : []; + + if (skipped.length > 0) { + const summary = skipped + .map((entry: { name?: string; reason?: string }) => `${entry?.name || 'file'}: ${entry?.reason || 'skipped'}`) + .join('\n'); + toast.error(`Some dropped files were skipped:\n${summary}`); + } + + let attachedCount = 0; + for (const file of picked as Array<{ name: string; mimeType?: string; dataUrl?: string }>) { + if (!file?.dataUrl) continue; + + const sizeBefore = useSessionStore.getState().attachedFiles.length; + try { + const [meta, base64] = file.dataUrl.split(','); + const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); + if (!base64) continue; + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: mime }); + const localFile = new File([blob], file.name || 'file', { type: mime }); + await addAttachedFile(localFile); + + const sizeAfter = useSessionStore.getState().attachedFiles.length; + if (sizeAfter > sizeBefore) { + attachedCount += 1; + } + } catch (error) { + console.error('Dropped file attach failed', error); + toast.error(error instanceof Error ? error.message : 'Failed to attach dropped file'); + } + } + + if (attachedCount > 0) { + toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); + } + } catch (error) { + console.error('VS Code dropped file attach failed', error); + toast.error(error instanceof Error ? error.message : 'Failed to attach dropped files'); + } + }, [addAttachedFile]); + const normalizeDroppedPath = React.useCallback((rawPath: string): string => { const input = rawPath.trim(); if (!input.toLowerCase().startsWith('file://')) { @@ -1526,6 +1613,15 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!currentSessionId && !newSessionDraftOpen) return; const files = collectDroppedFiles(e.dataTransfer); + + if (files.length === 0 && isVSCodeRuntime()) { + const droppedUris = collectDroppedFileUris(e.dataTransfer); + if (droppedUris.length > 0) { + await attachVSCodeDroppedUris(droppedUris); + } + return; + } + let attachedCount = 0; if (files.length > 0) { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 8ecb0c9b..0c057dbe 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -107,11 +107,68 @@ export interface BridgeContext { const SETTINGS_KEY = 'openchamber.settings'; const CLIENT_RELOAD_DELAY_MS = 800; +const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); +const guessMimeTypeFromExtension = (ext: string) => { + switch (ext) { + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.bmp': + case '.webp': + return `image/${ext.replace('.', '')}`; + case '.pdf': + return 'application/pdf'; + case '.txt': + case '.log': + return 'text/plain'; + case '.json': + return 'application/json'; + case '.md': + case '.markdown': + return 'text/markdown'; + default: + return 'application/octet-stream'; + } +}; + +const readUriAsAttachment = async ( + uri: vscode.Uri, + fallbackName?: string, +): Promise< + | { file: { name: string; mimeType: string; size: number; dataUrl: string } } + | { skipped: { name: string; reason: string } } +> => { + const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file'); + + try { + const stat = await vscode.workspace.fs.stat(uri); + if ((stat.type & vscode.FileType.Directory) !== 0) { + return { skipped: { name, reason: 'Folders are not supported' } }; + } + + const size = stat.size ?? 0; + if (size > MAX_FILE_ATTACH_SIZE_BYTES) { + return { skipped: { name, reason: 'File exceeds 10MB limit' } }; + } + + const bytes = await vscode.workspace.fs.readFile(uri); + const ext = path.extname(name).toLowerCase(); + const mimeType = guessMimeTypeFromExtension(ext); + const base64 = Buffer.from(bytes).toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { file: { name, mimeType, size, dataUrl } }; + } catch (error) { + return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } }; + } +}; + const isPathInside = (candidatePath: string, parentPath: string): boolean => { const normalizedCandidate = path.resolve(candidatePath); const normalizedParent = path.resolve(parentPath); @@ -1660,7 +1717,6 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:files/pick': { - const MAX_SIZE = 10 * 1024 * 1024; const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; @@ -1679,50 +1735,59 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; const skipped: Array<{ name: string; reason: string }> = []; - const guessMime = (ext: string) => { - switch (ext) { - case '.png': - case '.jpg': - case '.jpeg': - case '.gif': - case '.bmp': - case '.webp': - return `image/${ext.replace('.', '')}`; - case '.pdf': - return 'application/pdf'; - case '.txt': - case '.log': - return 'text/plain'; - case '.json': - return 'application/json'; - case '.md': - case '.markdown': - return 'text/markdown'; - default: - return 'application/octet-stream'; - } - }; - for (const uri of picks) { + const result = await readUriAsAttachment(uri); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:files/drop': { + const uris = Array.isArray((payload as { uris?: unknown[] })?.uris) + ? (payload as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : []; + + if (uris.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; + const skipped: Array<{ name: string; reason: string }> = []; + + const dedupedUris = Array.from(new Set(uris.map((value) => value.trim()))); + + for (const rawUri of dedupedUris) { + let uri: vscode.Uri; try { - const stat = await vscode.workspace.fs.stat(uri); - const size = stat.size ?? 0; - const name = path.basename(uri.fsPath); - - if (size > MAX_SIZE) { - skipped.push({ name, reason: 'File exceeds 10MB limit' }); - continue; - } - - const bytes = await vscode.workspace.fs.readFile(uri); - const ext = path.extname(name).toLowerCase(); - const mimeType = guessMime(ext); - const base64 = Buffer.from(bytes).toString('base64'); - const dataUrl = `data:${mimeType};base64,${base64}`; - files.push({ name, mimeType, size, dataUrl }); + uri = vscode.Uri.parse(rawUri, true); } catch (error) { - const name = path.basename(uri.fsPath); - skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' }); + skipped.push({ + name: rawUri, + reason: error instanceof Error ? error.message : 'Invalid URI', + }); + continue; + } + + if (uri.scheme !== 'file') { + skipped.push({ + name: rawUri, + reason: `Unsupported URI scheme: ${uri.scheme}`, + }); + continue; + } + + const name = path.basename(uri.fsPath || uri.path || rawUri); + + const result = await readUriAsAttachment(uri, name); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); } } diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 6bbd45b0..06ad8721 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -486,6 +486,15 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') { + const body = init?.body ? JSON.parse(init.body as string) : {}; + const uris = Array.isArray((body as { uris?: unknown[] }).uris) + ? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string') + : []; + const data = await sendBridgeMessage('api:files/drop', { uris }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname.startsWith('/api/config/agents/')) { const encodedName = pathname.slice('/api/config/agents/'.length); const name = decodeURIComponent(encodedName);