diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index f331b419..0b6fe3cd 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 95dcc07b..970a72fa 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1,12 +1,19 @@ import React from 'react'; import { Textarea } from '@/components/ui/textarea'; -import { RiAiAgentLine, RiCloseCircleLine, RiFileUploadLine, RiSendPlane2Line } from '@remixicon/react'; +import { + RiAddCircleLine, + RiAiAgentLine, + RiAttachment2, + RiCloseCircleLine, + RiFileUploadLine, + RiSendPlane2Line, +} from '@remixicon/react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import type { EditPermissionMode } from '@/stores/types/sessionTypes'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; -import { FileAttachmentButton, AttachedFilesList } from './FileAttachment'; +import { AttachedFilesList } from './FileAttachment'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete'; @@ -20,6 +27,12 @@ import { toast } from 'sonner'; import { useFileStore } from '@/stores/fileStore'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; const MAX_VISIBLE_TEXTAREA_LINES = 8; @@ -643,10 +656,96 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [addServerFile]); + const fileInputRef = React.useRef(null); + const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false); + + const attachFiles = React.useCallback(async (files: FileList | File[]) => { + let attachedCount = 0; + const list = Array.isArray(files) ? files : Array.from(files); + + for (const file of list) { + const sizeBefore = useSessionStore.getState().attachedFiles.length; + try { + await addAttachedFile(file); + const sizeAfter = useSessionStore.getState().attachedFiles.length; + if (sizeAfter > sizeBefore) { + attachedCount += 1; + } + } catch (error) { + console.error('File attach failed', error); + toast.error(error instanceof Error ? error.message : 'Failed to attach file'); + } + } + + if (attachedCount > 0) { + toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); + } + }, [addAttachedFile]); + + const handleVSCodePickFiles = React.useCallback(async () => { + try { + const response = await fetch('/api/vscode/pick-files'); + 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((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`) + .join('\n'); + toast.error(`Some files were skipped:\n${summary}`); + } + + const asFiles = picked + .map((file: { name: string; mimeType?: string; dataUrl?: string }) => { + if (!file?.dataUrl) return null; + try { + const [meta, base64] = file.dataUrl.split(','); + const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); + if (!base64) return null; + 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 }); + return new File([blob], file.name || 'file', { type: mime }); + } catch (err) { + console.error('Failed to decode VS Code picked file', err); + return null; + } + }) + .filter(Boolean) as File[]; + + if (asFiles.length > 0) { + await attachFiles(asFiles); + } + } catch (error) { + console.error('VS Code file pick failed', error); + toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code'); + } + }, [attachFiles]); + + const handlePickLocalFiles = React.useCallback(() => { + if (isVSCodeRuntime()) { + void handleVSCodePickFiles(); + return; + } + fileInputRef.current?.click(); + }, [handleVSCodePickFiles]); + + const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files) return; + await attachFiles(files); + event.target.value = ''; + }, [attachFiles]); + const footerGapClass = 'gap-x-1.5 gap-y-0'; - const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : 'px-2.5 py-1.5'; - const footerHeightClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; - const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]'; + const isVSCode = isVSCodeRuntime(); + const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5'); + const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7'); + const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]'); const iconButtonBaseClass = cn( footerHeightClass, @@ -669,17 +768,69 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo ); - const projectFileButton = ( - - - + const attachmentMenu = ( + <> + + +
+ + {isMobile || isVSCode ? null : ( + + + + { + requestAnimationFrame(() => handlePickLocalFiles()); + }} + > + + Attach files + + { + requestAnimationFrame(() => { + setProjectFilePickerOpen(true); + }); + }} + > + + Attach from project + + + +
+ ); const settingsButton = onOpenSettings ? ( @@ -696,8 +847,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const attachmentsControls = ( <> - - {projectFileButton} + {attachmentMenu} {settingsButton} ); @@ -795,7 +945,17 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo {isDragging && (
- +
+ +

Drop files here to attach

diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 9c94ed30..efaf638d 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -37,6 +37,56 @@ export const FileMentionAutocomplete = React.forwardRef([]); const containerRef = React.useRef(null); + const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { + const q = query.trim().toLowerCase(); + if (!q) { + return 0; + } + + const c = candidate.toLowerCase(); + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i += 1) { + const ch = q[i]; + if (!ch || ch === ' ') { + continue; + } + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.max(0, gap); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.round(c.length / 3)); + return score; + }, []); + React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; @@ -61,15 +111,35 @@ export const FileMentionAutocomplete = React.forwardRef { if (cancelled) { return; } - setFiles(hits.slice(0, 15)); + + const ranked = normalizedQueryLower + ? hits + .map((file) => { + const label = file.relativePath || file.name || file.path; + const score = fuzzyScore(normalizedQueryLower, label); + return score === null ? null : { file, score, labelLength: label.length }; + }) + .filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }> + : hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length })); + + ranked.sort((a, b) => ( + b.score - a.score + || a.labelLength - b.labelLength + || a.file.path.localeCompare(b.file.path) + )); + + setFiles(ranked.slice(0, 15).map((entry) => entry.file)); }) .catch(() => { if (!cancelled) { @@ -85,7 +155,7 @@ export const FileMentionAutocomplete = React.forwardRef { cancelled = true; }; - }, [currentDirectory, debouncedQuery, searchFiles]); + }, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles]); React.useEffect(() => { setSelectedIndex(0); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index b22910b5..edecdd68 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -345,11 +345,12 @@ export const ModelControls: React.FC = ({ className }) => { } }, [editToggleDisabled]); - const buttonHeight = isCompact ? 'h-9' : 'h-8'; - const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4'; - const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4'; + const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; + const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; + const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; + const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta'; - const inlineGapClass = isCompact ? 'gap-x-2' : 'gap-x-3'; + const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-2' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3'; const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode]; const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => { diff --git a/packages/ui/src/components/chat/ServerFilePicker.tsx b/packages/ui/src/components/chat/ServerFilePicker.tsx index 4561ad50..e8b77ef7 100644 --- a/packages/ui/src/components/chat/ServerFilePicker.tsx +++ b/packages/ui/src/components/chat/ServerFilePicker.tsx @@ -32,78 +32,140 @@ interface ServerFilePickerProps { onFilesSelected: (files: FileInfo[]) => void; multiSelect?: boolean; children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + presentation?: 'dropdown' | 'modal'; } export const ServerFilePicker: React.FC = ({ onFilesSelected, multiSelect = false, - children + children, + open: controlledOpen, + onOpenChange, + presentation = 'dropdown', }) => { const { isMobile } = useDeviceInfo(); const isVSCodeRuntime = useIsVSCodeRuntime(); const isCompact = isMobile || isVSCodeRuntime; const { currentDirectory } = useDirectoryStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); - const [open, setOpen] = React.useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); const [mobileOpen, setMobileOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); const [selectedFiles, setSelectedFiles] = React.useState>(new Set()); const [expandedDirs, setExpandedDirs] = React.useState>(new Set()); - const [fileTree, setFileTree] = React.useState([]); + const [childrenByDir, setChildrenByDir] = React.useState>({}); + const loadedDirsRef = React.useRef>(new Set()); + const inFlightDirsRef = React.useRef>(new Set()); const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [loading, setLoading] = React.useState(false); const [attaching, setAttaching] = React.useState(false); const [error, setError] = React.useState(null); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; + + const sortDirectoryItems = React.useCallback((items: FileInfo[]) => ( + items.slice().sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + ), []); + + const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => ( + sortDirectoryItems(entries + .filter((item) => !item.name.startsWith('.')) + .map((item) => { + const name = item.name; + const extension = !item.isDirectory && name.includes('.') + ? name.split('.').pop()?.toLowerCase() + : undefined; + return { + name, + path: item.path || `${dirPath}/${name}`, + type: item.isDirectory ? 'directory' : 'file', + size: 0, + extension, + }; + })) + ), [sortDirectoryItems]); + const loadDirectory = React.useCallback(async (dirPath: string) => { setLoading(true); setError(null); try { - const tempClient = opencodeClient.getApiClient(); - const response = await tempClient.file.list({ - query: { - path: '.', - directory: dirPath - } - }); + const entries = await opencodeClient.listLocalDirectory(dirPath); + const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + }))); - if (!response.data) { - setFileTree([]); - return; - } - - const items = response.data - .filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.')) - .map((item: { name: string; type: string; size?: number; absolute?: string }) => { - const extension = item.type === 'file' - ? item.name.split('.').pop()?.toLowerCase() - : undefined; - - return { - name: item.name, - path: item.absolute || `${dirPath}/${item.name}`, - type: item.type as 'file' | 'directory', - size: item.size || 0, - extension - }; - }) - .sort((a: FileInfo, b: FileInfo) => { - if (a.type !== b.type) { - return a.type === 'directory' ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - setFileTree(items); + loadedDirsRef.current = new Set([dirPath]); + inFlightDirsRef.current = new Set(); + setChildrenByDir({ [dirPath]: items }); + setExpandedDirs(new Set()); } catch { setError('Failed to load directory contents'); - setFileTree([]); + loadedDirsRef.current = new Set([dirPath]); + inFlightDirsRef.current = new Set(); + setChildrenByDir({ [dirPath]: [] }); + setExpandedDirs(new Set()); } finally { setLoading(false); } - }, []); + }, [mapFilesystemEntries]); + + const loadDirectoryChildren = React.useCallback(async (dirPath: string) => { + const normalizedDir = dirPath.trim(); + if (!normalizedDir) { + return; + } + if (loadedDirsRef.current.has(normalizedDir)) { + return; + } + if (inFlightDirsRef.current.has(normalizedDir)) { + return; + } + + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.add(normalizedDir); + + try { + const entries = await opencodeClient.listLocalDirectory(normalizedDir); + const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + }))); + + loadedDirsRef.current = new Set(loadedDirsRef.current); + loadedDirsRef.current.add(normalizedDir); + setChildrenByDir((prev) => ({ + ...prev, + [normalizedDir]: items, + })); + } catch { + // Keep it unloadded so the user can retry expanding the directory. + setChildrenByDir((prev) => { + if (prev[normalizedDir]) { + return prev; + } + return { + ...prev, + [normalizedDir]: [], + }; + }); + } finally { + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.delete(normalizedDir); + } + }, [mapFilesystemEntries]); React.useEffect(() => { if ((open || mobileOpen) && currentDirectory) { @@ -214,50 +276,13 @@ export const ServerFilePicker: React.FC = ({ return next; }); } else { - setExpandedDirs(prev => { + setExpandedDirs((prev) => { const next = new Set(prev); next.add(dirPath); return next; }); - try { - const tempClient = opencodeClient.getApiClient(); - const response = await tempClient.file.list({ - query: { - path: '.', - directory: dirPath - } - }); - - if (response.data) { - const subItems = response.data - .filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.')) - .map((item: { name: string; type: string; size?: number; absolute?: string }) => { - const extension = item.type === 'file' - ? item.name.split('.').pop()?.toLowerCase() - : undefined; - - return { - name: item.name, - path: item.absolute || `${dirPath}/${item.name}`, - type: item.type as 'file' | 'directory', - size: 0, - extension - }; - }); - - setFileTree(prev => { - const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/')); - return [...filtered, ...subItems].sort((a, b) => { - const aDepth = a.path.split('/').length; - const bDepth = b.path.split('/').length; - if (aDepth !== bDepth) return aDepth - bDepth; - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - }); - } - } catch { /* ignored */ } + await loadDirectoryChildren(dirPath); } }; @@ -278,11 +303,14 @@ export const ServerFilePicker: React.FC = ({ }; const handleConfirm = async () => { - const treeFileMap = new Map( - fileTree - .filter((file) => file.type === 'file') - .map((file) => [file.path, file]) - ); + const treeFileMap = new Map(); + Object.values(childrenByDir).forEach((items) => { + items.forEach((file) => { + if (file.type === 'file') { + treeFileMap.set(file.path, file); + } + }); + }); const searchFileMap = new Map(searchResults.map((file) => [file.path, file])); const selected = Array.from(selectedFiles) @@ -304,20 +332,13 @@ export const ServerFilePicker: React.FC = ({ if (!currentDirectory) { return []; } - - return fileTree.filter((item) => { - const itemDir = item.path.substring(0, item.path.lastIndexOf('/')); - return itemDir === currentDirectory; - }); - }, [fileTree, currentDirectory]); + return childrenByDir[currentDirectory] ?? []; + }, [childrenByDir, currentDirectory]); const isSearchActive = searchQuery.trim().length > 0; const getChildItems = (parentPath: string) => { - return fileTree.filter(item => { - const itemDir = item.path.substring(0, item.path.lastIndexOf('/')); - return itemDir === parentPath; - }); + return childrenByDir[parentPath] ?? []; }; const getRelativePath = (fullPath: string) => { @@ -384,6 +405,7 @@ export const ServerFilePicker: React.FC = ({ const isDirectory = file.type === 'directory'; const children = isDirectory ? getChildItems(file.path) : []; const isExpanded = expandedDirs.has(file.path); + const isLoadingChildren = isDirectory && isExpanded && inFlightDirsRef.current.has(file.path) && children.length === 0; return (
@@ -398,7 +420,7 @@ export const ServerFilePicker: React.FC = ({ e.preventDefault(); e.stopPropagation(); if (isDirectory) { - toggleDirectory(file.path); + void toggleDirectory(file.path); } else { toggleFileSelection(file.path); } @@ -418,6 +440,15 @@ export const ServerFilePicker: React.FC = ({ {children.map((child) => renderFileTree(child, level + 1))}
)} + + {isDirectory && isExpanded && isLoadingChildren && ( +
+ Loading… +
+ )} ); }; @@ -527,6 +558,34 @@ export const ServerFilePicker: React.FC = ({ ); + if (presentation === 'modal') { + return ( + <> + {children ? ( + { + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + }} + > + {children} + + ) : null} + + setOpen(false)} + title="Select Project Files" + footer={summarySection} + > +
{pickerBody}
+
+ + ); + } + if (isCompact) { return ( <> @@ -549,9 +608,13 @@ export const ServerFilePicker: React.FC = ({ {children} {pickerBody} diff --git a/packages/ui/src/lib/typography.ts b/packages/ui/src/lib/typography.ts index 1e34f7ea..90b052ff 100644 --- a/packages/ui/src/lib/typography.ts +++ b/packages/ui/src/lib/typography.ts @@ -8,12 +8,13 @@ export const SEMANTIC_TYPOGRAPHY = { } as const; export const VSCODE_TYPOGRAPHY = { - markdown: '0.9375rem', - code: '0.9375rem', - uiHeader: '1rem', - uiLabel: '0.9375rem', - meta: '0.9375rem', - micro: '0.875rem', + // Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density. + markdown: '0.9063rem', + code: '0.8750rem', + uiHeader: '0.9063rem', + uiLabel: '0.8438rem', + meta: '0.8438rem', + micro: '0.7813rem', } as const; export const SEMANTIC_TYPOGRAPHY_CSS = { diff --git a/packages/ui/src/stores/fileStore.ts b/packages/ui/src/stores/fileStore.ts index e96b3fa1..4e221d03 100644 --- a/packages/ui/src/stores/fileStore.ts +++ b/packages/ui/src/stores/fileStore.ts @@ -19,6 +19,32 @@ type FileStore = FileState & FileActions; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; +const guessMimeTypeFromName = (filename: string): string => { + const name = (filename || "").toLowerCase(); + const ext = name.includes(".") ? name.split(".").pop() || "" : ""; + switch (ext) { + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "gif": + return "image/gif"; + case "webp": + return "image/webp"; + case "svg": + return "image/svg+xml"; + case "bmp": + return "image/bmp"; + case "ico": + return "image/x-icon"; + case "pdf": + return "application/pdf"; + default: + return "text/plain"; + } +}; + const guessMimeType = (file: File): string => { if (file.type && file.type.trim().length > 0) { return file.type; @@ -65,6 +91,31 @@ const guessMimeType = (file: File): string => { } }; +const base64ByteLength = (base64: string): number => { + const cleaned = base64.replace(/\s+/g, ""); + if (!cleaned) { + return 0; + } + const padding = cleaned.endsWith("==") ? 2 : cleaned.endsWith("=") ? 1 : 0; + return Math.floor((cleaned.length * 3) / 4) - padding; +}; + +const base64EncodeBytes = (bytes: Uint8Array): string => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let output = ""; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i] ?? 0; + const b = bytes[i + 1]; + const c = bytes[i + 2]; + const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0); + output += alphabet[(triple >> 18) & 63]; + output += alphabet[(triple >> 12) & 63]; + output += typeof b === "number" ? alphabet[(triple >> 6) & 63] : "="; + output += typeof c === "number" ? alphabet[triple & 63] : "="; + } + return output; +}; + export const useFileStore = create()( devtools( @@ -153,6 +204,8 @@ export const useFileStore = create()( } let fileContent = content; + let encoding: "base64" | undefined; + let resolvedMimeType: string | undefined; if (!fileContent) { try { @@ -171,6 +224,8 @@ export const useFileStore = create()( if (response.data && "content" in response.data) { fileContent = response.data.content; + encoding = response.data.encoding ?? undefined; + resolvedMimeType = response.data.mimeType ?? undefined; } else { fileContent = ""; } @@ -181,26 +236,36 @@ export const useFileStore = create()( } } - const blob = new Blob([fileContent || ""], { type: "text/plain" }); + const inferredMime = resolvedMimeType || guessMimeTypeFromName(name); + const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream"; - if (blob.size > MAX_ATTACHMENT_SIZE) { + const base64 = (() => { + if (encoding === "base64") { + return fileContent || ""; + } + const encoder = new TextEncoder(); + const data = encoder.encode(fileContent || ""); + return base64EncodeBytes(data); + })(); + + const sizeBytes = encoding === "base64" + ? base64ByteLength(base64) + : new TextEncoder().encode(fileContent || "").length; + + if (sizeBytes > MAX_ATTACHMENT_SIZE) { throw new Error(`File "${name}" is too large. Maximum size is 10MB.`); } - const file = new File([blob], name, { type: "text/plain" }); - - const encoder = new TextEncoder(); - const data = encoder.encode(fileContent || ""); - const base64 = btoa(String.fromCharCode(...data)); - const dataUrl = `data:text/plain;base64,${base64}`; + const file = new File([], name, { type: safeMimeType }); + const dataUrl = `data:${safeMimeType};base64,${base64}`; const attachedFile: AttachedFile = { id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, file, dataUrl, - mimeType: "text/plain", + mimeType: safeMimeType, filename: name, - size: blob.size, + size: sizeBytes, source: "server", serverPath: path, }; diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 3ec76dd7..b1fd7a66 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -74,30 +74,141 @@ const listDirectoryEntries = async (dirPath: string) => { })); }; +const FILE_SEARCH_EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + 'coverage', + 'tmp', + 'logs', +]); + +const shouldSkipSearchDirectory = (name: string) => { + if (!name) { + return false; + } + if (name.startsWith('.')) { + return true; + } + return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); +}; + +const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => { + const normalizedQuery = (query || '').trim().toLowerCase(); + const matchAll = normalizedQuery.length === 0; + + const rootUri = vscode.Uri.file(rootPath); + const queue: vscode.Uri[] = [rootUri]; + const visited = new Set([normalizeFsPath(rootUri.fsPath)]); + const results: Array<{ name: string; path: string; relativePath: string; extension?: string }> = []; + const MAX_CONCURRENCY = 5; + + while (queue.length > 0 && results.length < limit) { + const batch = queue.splice(0, MAX_CONCURRENCY); + const dirLists = await Promise.all( + batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])) + ); + + for (let index = 0; index < batch.length; index += 1) { + const currentDir = batch[index]; + const dirents = dirLists[index]; + + for (const [entryName, entryType] of dirents) { + if (!entryName || entryName.startsWith('.')) { + continue; + } + + const entryUri = vscode.Uri.joinPath(currentDir, entryName); + const absolute = normalizeFsPath(entryUri.fsPath); + + if (entryType === vscode.FileType.Directory) { + if (shouldSkipSearchDirectory(entryName)) { + continue; + } + if (!visited.has(absolute)) { + visited.add(absolute); + queue.push(entryUri); + } + continue; + } + + if (entryType !== vscode.FileType.File) { + continue; + } + + const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); + if (!matchAll) { + const lowercaseName = entryName.toLowerCase(); + const lowercasePath = relativePath.toLowerCase(); + if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) { + continue; + } + } + + results.push({ + name: entryName, + path: absolute, + relativePath, + extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined, + }); + + if (results.length >= limit) { + queue.length = 0; + break; + } + } + + if (results.length >= limit) { + break; + } + } + } + + return results; +}; + const searchDirectory = async (directory: string, query: string, limit = 60) => { const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; if (!rootPath) return []; const sanitizedQuery = query?.trim() || ''; - const pattern = sanitizedQuery ? `**/*${sanitizedQuery}*` : '**/*'; - const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; - const results = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), - exclude, - limit, - ); + if (!sanitizedQuery) { + return searchFilesystemFiles(rootPath, '', limit); + } - return results.map((file) => { - const absolute = normalizeFsPath(file.fsPath); - const relative = normalizeFsPath(path.relative(rootPath, absolute)); - const name = path.basename(absolute); - return { - name, - path: absolute, - relativePath: relative || name, - extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, - }; - }); + // Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace). + try { + const pattern = `**/*${sanitizedQuery}*`; + const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; + const results = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), + exclude, + limit, + ); + + if (Array.isArray(results) && results.length > 0) { + return results.map((file) => { + const absolute = normalizeFsPath(file.fsPath); + const relative = normalizeFsPath(path.relative(rootPath, absolute)); + const name = path.basename(absolute); + return { + name, + path: absolute, + relativePath: relative || name, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; + }); + } + } catch { + // Fall through to filesystem traversal. + } + + // Fallback: deterministic, case-insensitive traversal with early-exit at limit. + return searchFilesystemFiles(rootPath, sanitizedQuery, limit); }; const fetchModelsMetadata = async () => {