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
This commit is contained in:
@@ -1442,8 +1442,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => {
|
const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => {
|
||||||
if (!dataTransfer) return false;
|
if (!dataTransfer) return false;
|
||||||
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
|
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
|
||||||
if (!dataTransfer.types) return false;
|
if (dataTransfer.types) {
|
||||||
return Array.from(dataTransfer.types).includes('Files');
|
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[] => {
|
const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => {
|
||||||
@@ -1462,6 +1468,87 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return fromItems;
|
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 normalizeDroppedPath = React.useCallback((rawPath: string): string => {
|
||||||
const input = rawPath.trim();
|
const input = rawPath.trim();
|
||||||
if (!input.toLowerCase().startsWith('file://')) {
|
if (!input.toLowerCase().startsWith('file://')) {
|
||||||
@@ -1526,6 +1613,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!currentSessionId && !newSessionDraftOpen) return;
|
if (!currentSessionId && !newSessionDraftOpen) return;
|
||||||
|
|
||||||
const files = collectDroppedFiles(e.dataTransfer);
|
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;
|
let attachedCount = 0;
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
|
|||||||
+107
-42
@@ -107,11 +107,68 @@ export interface BridgeContext {
|
|||||||
|
|
||||||
const SETTINGS_KEY = 'openchamber.settings';
|
const SETTINGS_KEY = 'openchamber.settings';
|
||||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||||
|
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
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 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 isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||||
const normalizedCandidate = path.resolve(candidatePath);
|
const normalizedCandidate = path.resolve(candidatePath);
|
||||||
const normalizedParent = path.resolve(parentPath);
|
const normalizedParent = path.resolve(parentPath);
|
||||||
@@ -1660,7 +1717,6 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:files/pick': {
|
case 'api:files/pick': {
|
||||||
const MAX_SIZE = 10 * 1024 * 1024;
|
|
||||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||||
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
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 files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = [];
|
||||||
const skipped: Array<{ name: string; reason: 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) {
|
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 {
|
try {
|
||||||
const stat = await vscode.workspace.fs.stat(uri);
|
uri = vscode.Uri.parse(rawUri, true);
|
||||||
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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const name = path.basename(uri.fsPath);
|
skipped.push({
|
||||||
skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' });
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -486,6 +486,15 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
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/')) {
|
if (pathname.startsWith('/api/config/agents/')) {
|
||||||
const encodedName = pathname.slice('/api/config/agents/'.length);
|
const encodedName = pathname.slice('/api/config/agents/'.length);
|
||||||
const name = decodeURIComponent(encodedName);
|
const name = decodeURIComponent(encodedName);
|
||||||
|
|||||||
Reference in New Issue
Block a user