Skip to content

Commit 53422d3

Browse files
committed
feat: Replace image-only attachment with general file attachment support
- Add comprehensive file processing system with MIME type detection - Support multiple file categories (images, documents, code, data, archives, config) - Implement 10MB file size limit and intelligent text/binary handling - Replace camera icon with paperclip icon in chat interface - Add selectFiles message types and handlers - Update translations for attachFiles across all languages - Maintain backward compatibility with existing image functionality Resolves #5532
1 parent a6e16e8 commit 53422d3

File tree

24 files changed

+286
-5
lines changed

24 files changed

+286
-5
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Terminal } from "../../integrations/terminal/Terminal"
3030
import { openFile } from "../../integrations/misc/open-file"
3131
import { openImage, saveImage } from "../../integrations/misc/image-handler"
3232
import { selectImages } from "../../integrations/misc/process-images"
33+
import { selectFiles } from "../../integrations/misc/process-files"
3334
import { getTheme } from "../../integrations/theme/getTheme"
3435
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
3536
import { searchWorkspaceFiles } from "../../services/search/file-search"
@@ -381,6 +382,15 @@ export const webviewMessageHandler = async (
381382
messageTs: message.messageTs,
382383
})
383384
break
385+
case "selectFiles":
386+
const files = await selectFiles()
387+
await provider.postMessageToWebview({
388+
type: "selectedFiles",
389+
files,
390+
context: message.context,
391+
messageTs: message.messageTs,
392+
})
393+
break
384394
case "exportCurrentTask":
385395
const currentTaskId = provider.getCurrentCline()?.taskId
386396
if (currentTaskId) {
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import * as vscode from "vscode"
2+
import fs from "fs/promises"
3+
import * as path from "path"
4+
5+
// File size limit: 10MB
6+
const MAX_FILE_SIZE = 10 * 1024 * 1024
7+
8+
// Supported file types with their MIME types
9+
const FILE_TYPE_CATEGORIES = {
10+
images: ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"],
11+
documents: ["pdf", "doc", "docx", "txt", "rtf", "odt", "md"],
12+
code: ["js", "ts", "py", "java", "cpp", "c", "h", "hpp", "html", "css", "json", "xml", "yaml", "yml", "php", "rb", "go", "rs", "swift", "kt", "scala", "sh", "bat", "ps1"],
13+
data: ["csv", "xls", "xlsx", "sql", "db", "sqlite"],
14+
archives: ["zip", "rar", "tar", "gz", "7z"],
15+
config: ["ini", "conf", "config", "env", "properties"],
16+
}
17+
18+
// Text file extensions that should be read as content
19+
const TEXT_FILE_EXTENSIONS = [
20+
...FILE_TYPE_CATEGORIES.code,
21+
...FILE_TYPE_CATEGORIES.documents.filter(ext => ["txt", "md"].includes(ext)),
22+
...FILE_TYPE_CATEGORIES.config,
23+
...FILE_TYPE_CATEGORIES.data.filter(ext => ["csv", "sql"].includes(ext)),
24+
]
25+
26+
export interface ProcessedFile {
27+
name: string
28+
path: string
29+
size: number
30+
type: string
31+
category: string
32+
content?: string // For text files
33+
dataUrl?: string // For images and binary files
34+
error?: string
35+
}
36+
37+
export async function selectFiles(): Promise<ProcessedFile[]> {
38+
const options: vscode.OpenDialogOptions = {
39+
canSelectMany: true,
40+
openLabel: "Select",
41+
filters: {
42+
"All Files": ["*"],
43+
"Images": FILE_TYPE_CATEGORIES.images,
44+
"Documents": FILE_TYPE_CATEGORIES.documents,
45+
"Code Files": FILE_TYPE_CATEGORIES.code,
46+
"Data Files": FILE_TYPE_CATEGORIES.data,
47+
"Archives": FILE_TYPE_CATEGORIES.archives,
48+
"Config Files": FILE_TYPE_CATEGORIES.config,
49+
},
50+
}
51+
52+
const fileUris = await vscode.window.showOpenDialog(options)
53+
54+
if (!fileUris || fileUris.length === 0) {
55+
return []
56+
}
57+
58+
return await Promise.all(
59+
fileUris.map(async (uri: vscode.Uri) => {
60+
try {
61+
return await processFile(uri.fsPath)
62+
} catch (error) {
63+
const fileName = path.basename(uri.fsPath)
64+
return {
65+
name: fileName,
66+
path: uri.fsPath,
67+
size: 0,
68+
type: "unknown",
69+
category: "unknown",
70+
error: error instanceof Error ? error.message : String(error),
71+
}
72+
}
73+
}),
74+
)
75+
}
76+
77+
export async function processFile(filePath: string): Promise<ProcessedFile> {
78+
const fileName = path.basename(filePath)
79+
const fileExt = path.extname(filePath).toLowerCase().slice(1)
80+
81+
// Check file size
82+
const stats = await fs.stat(filePath)
83+
if (stats.size > MAX_FILE_SIZE) {
84+
throw new Error(`File size (${formatFileSize(stats.size)}) exceeds the 10MB limit`)
85+
}
86+
87+
const mimeType = getMimeType(filePath)
88+
const category = getFileCategory(fileExt)
89+
90+
const processedFile: ProcessedFile = {
91+
name: fileName,
92+
path: filePath,
93+
size: stats.size,
94+
type: mimeType,
95+
category,
96+
}
97+
98+
// Handle different file types
99+
if (category === "images") {
100+
// Process images as data URLs (existing behavior)
101+
const buffer = await fs.readFile(filePath)
102+
const base64 = buffer.toString("base64")
103+
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
104+
} else if (isTextFile(fileExt)) {
105+
// Read text files as content
106+
try {
107+
processedFile.content = await fs.readFile(filePath, "utf-8")
108+
} catch (error) {
109+
// If UTF-8 reading fails, treat as binary
110+
const buffer = await fs.readFile(filePath)
111+
const base64 = buffer.toString("base64")
112+
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
113+
}
114+
} else {
115+
// Handle binary files as base64
116+
const buffer = await fs.readFile(filePath)
117+
const base64 = buffer.toString("base64")
118+
processedFile.dataUrl = `data:${mimeType};base64,${base64}`
119+
}
120+
121+
return processedFile
122+
}
123+
124+
// Backward compatibility function for existing image functionality
125+
export async function selectImages(): Promise<string[]> {
126+
const options: vscode.OpenDialogOptions = {
127+
canSelectMany: true,
128+
openLabel: "Select",
129+
filters: {
130+
Images: FILE_TYPE_CATEGORIES.images,
131+
},
132+
}
133+
134+
const fileUris = await vscode.window.showOpenDialog(options)
135+
136+
if (!fileUris || fileUris.length === 0) {
137+
return []
138+
}
139+
140+
return await Promise.all(
141+
fileUris.map(async (uri: vscode.Uri) => {
142+
const filePath = uri.fsPath
143+
const buffer = await fs.readFile(filePath)
144+
const base64 = buffer.toString("base64")
145+
const mimeType = getMimeType(filePath)
146+
return `data:${mimeType};base64,${base64}`
147+
}),
148+
)
149+
}
150+
151+
function getMimeType(filePath: string): string {
152+
const ext = path.extname(filePath).toLowerCase()
153+
154+
// Image types
155+
switch (ext) {
156+
case ".png": return "image/png"
157+
case ".jpeg":
158+
case ".jpg": return "image/jpeg"
159+
case ".gif": return "image/gif"
160+
case ".bmp": return "image/bmp"
161+
case ".webp": return "image/webp"
162+
case ".svg": return "image/svg+xml"
163+
164+
// Document types
165+
case ".pdf": return "application/pdf"
166+
case ".doc": return "application/msword"
167+
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
168+
case ".txt": return "text/plain"
169+
case ".rtf": return "application/rtf"
170+
case ".odt": return "application/vnd.oasis.opendocument.text"
171+
case ".md": return "text/markdown"
172+
173+
// Code types
174+
case ".js": return "text/javascript"
175+
case ".ts": return "text/typescript"
176+
case ".py": return "text/x-python"
177+
case ".java": return "text/x-java-source"
178+
case ".cpp":
179+
case ".c": return "text/x-c"
180+
case ".h":
181+
case ".hpp": return "text/x-c"
182+
case ".html": return "text/html"
183+
case ".css": return "text/css"
184+
case ".json": return "application/json"
185+
case ".xml": return "application/xml"
186+
case ".yaml":
187+
case ".yml": return "application/x-yaml"
188+
case ".php": return "text/x-php"
189+
case ".rb": return "text/x-ruby"
190+
case ".go": return "text/x-go"
191+
case ".rs": return "text/x-rust"
192+
case ".swift": return "text/x-swift"
193+
case ".kt": return "text/x-kotlin"
194+
case ".scala": return "text/x-scala"
195+
case ".sh": return "text/x-shellscript"
196+
case ".bat": return "text/x-msdos-batch"
197+
case ".ps1": return "text/x-powershell"
198+
199+
// Data types
200+
case ".csv": return "text/csv"
201+
case ".xls": return "application/vnd.ms-excel"
202+
case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
203+
case ".sql": return "text/x-sql"
204+
case ".db":
205+
case ".sqlite": return "application/x-sqlite3"
206+
207+
// Archive types
208+
case ".zip": return "application/zip"
209+
case ".rar": return "application/x-rar-compressed"
210+
case ".tar": return "application/x-tar"
211+
case ".gz": return "application/gzip"
212+
case ".7z": return "application/x-7z-compressed"
213+
214+
// Config types
215+
case ".ini": return "text/plain"
216+
case ".conf":
217+
case ".config": return "text/plain"
218+
case ".env": return "text/plain"
219+
case ".properties": return "text/plain"
220+
221+
default: return "application/octet-stream"
222+
}
223+
}
224+
225+
function getFileCategory(extension: string): string {
226+
for (const [category, extensions] of Object.entries(FILE_TYPE_CATEGORIES)) {
227+
if (extensions.includes(extension)) {
228+
return category
229+
}
230+
}
231+
return "other"
232+
}
233+
234+
function isTextFile(extension: string): boolean {
235+
return TEXT_FILE_EXTENSIONS.includes(extension)
236+
}
237+
238+
function formatFileSize(bytes: number): string {
239+
if (bytes === 0) return "0 Bytes"
240+
241+
const k = 1024
242+
const sizes = ["Bytes", "KB", "MB", "GB"]
243+
const i = Math.floor(Math.log(bytes) / Math.log(k))
244+
245+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
246+
}

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ExtensionMessage {
5454
| "action"
5555
| "state"
5656
| "selectedImages"
57+
| "selectedFiles"
5758
| "theme"
5859
| "workspaceUpdated"
5960
| "invoke"
@@ -123,6 +124,7 @@ export interface ExtensionMessage {
123124
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
124125
state?: ExtensionState
125126
images?: string[]
127+
files?: any[] // ProcessedFile array from process-files.ts
126128
filePaths?: string[]
127129
openedTabs?: Array<{
128130
label: string

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface WebviewMessage {
5353
| "clearTask"
5454
| "didShowAnnouncement"
5555
| "selectImages"
56+
| "selectFiles"
5657
| "exportCurrentTask"
5758
| "shareCurrentTask"
5859
| "showTaskWithId"

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import Thumbnails from "../common/Thumbnails"
2525
import ModeSelector from "./ModeSelector"
2626
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
2727
import ContextMenu from "./ContextMenu"
28-
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal } from "lucide-react"
28+
import { VolumeX, Pin, Check, Image, WandSparkles, SendHorizontal, Paperclip } from "lucide-react"
2929
import { IndexingStatusBadge } from "./IndexingStatusBadge"
3030
import { cn } from "@/lib/utils"
3131
import { usePromptHistory } from "./hooks/usePromptHistory"
@@ -41,6 +41,7 @@ interface ChatTextAreaProps {
4141
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
4242
onSend: () => void
4343
onSelectImages: () => void
44+
onSelectFiles?: () => void
4445
shouldDisableImages: boolean
4546
onHeightChange?: (height: number) => void
4647
mode: Mode
@@ -63,6 +64,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
6364
setSelectedImages,
6465
onSend,
6566
onSelectImages,
67+
onSelectFiles,
6668
shouldDisableImages,
6769
onHeightChange,
6870
mode,
@@ -984,11 +986,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
984986
</StandardTooltip>
985987
)}
986988
<IndexingStatusBadge />
987-
<StandardTooltip content={t("chat:addImages")}>
989+
<StandardTooltip content={t("chat:attachFiles")}>
988990
<button
989-
aria-label={t("chat:addImages")}
991+
aria-label={t("chat:attachFiles")}
990992
disabled={shouldDisableImages}
991-
onClick={!shouldDisableImages ? onSelectImages : undefined}
993+
onClick={!shouldDisableImages ? (onSelectFiles || onSelectImages) : undefined}
992994
className={cn(
993995
"relative inline-flex items-center justify-center",
994996
"bg-transparent border-none p-1.5",
@@ -1003,7 +1005,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10031005
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
10041006
"mr-1",
10051007
)}>
1006-
<Image className="w-4 h-4" />
1008+
<Paperclip className="w-4 h-4" />
10071009
</button>
10081010
</StandardTooltip>
10091011
</div>

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
701701
const { info: model } = useSelectedModel(apiConfiguration)
702702

703703
const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
704+
const selectFiles = useCallback(() => vscode.postMessage({ type: "selectFiles" }), [])
704705

705706
const shouldDisableImages =
706707
!model?.supportsImages || sendingDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
@@ -1858,6 +1859,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
18581859
setSelectedImages={setSelectedImages}
18591860
onSend={() => handleSendMessage(inputValue, selectedImages)}
18601861
onSelectImages={selectImages}
1862+
onSelectFiles={selectFiles}
18611863
shouldDisableImages={shouldDisableImages}
18621864
onHeightChange={() => {
18631865
if (isAtBottom) {

webview-ui/src/i18n/locales/ca/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"selectApiConfig": "Seleccioneu la configuració de l'API",
105105
"enhancePrompt": "Millora la sol·licitud amb context addicional",
106106
"addImages": "Afegeix imatges al missatge",
107+
"attachFiles": "Attach files to message",
107108
"sendMessage": "Envia el missatge",
108109
"stopTts": "Atura la síntesi de veu",
109110
"typeMessage": "Escriu un missatge...",

webview-ui/src/i18n/locales/de/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"selectApiConfig": "API-Konfiguration auswählen",
105105
"enhancePrompt": "Prompt mit zusätzlichem Kontext verbessern",
106106
"addImages": "Bilder zur Nachricht hinzufügen",
107+
"attachFiles": "Attach files to message",
107108
"sendMessage": "Nachricht senden",
108109
"stopTts": "Text-in-Sprache beenden",
109110
"typeMessage": "Nachricht eingeben...",

webview-ui/src/i18n/locales/en/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
},
123123
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
124124
"addImages": "Add images to message",
125+
"attachFiles": "Attach files to message",
125126
"sendMessage": "Send message",
126127
"stopTts": "Stop text-to-speech",
127128
"typeMessage": "Type a message...",

webview-ui/src/i18n/locales/es/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"selectApiConfig": "Seleccionar configuración de API",
105105
"enhancePrompt": "Mejorar el mensaje con contexto adicional",
106106
"addImages": "Agregar imágenes al mensaje",
107+
"attachFiles": "Attach files to message",
107108
"sendMessage": "Enviar mensaje",
108109
"stopTts": "Detener texto a voz",
109110
"typeMessage": "Escribe un mensaje...",

0 commit comments

Comments
 (0)