Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { selectImages, selectFiles } from "../../integrations/misc/process-files"
import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
Expand Down Expand Up @@ -418,6 +418,10 @@ export const webviewMessageHandler = async (
const images = await selectImages()
await provider.postMessageToWebview({ type: "selectedImages", images })
break
case "selectFiles":
const { images: fileImages, files } = await selectFiles()
await provider.postMessageToWebview({ type: "selectedFiles", images: fileImages, files })
break
case "exportCurrentTask":
const currentTaskId = provider.getCurrentCline()?.taskId
if (currentTaskId) {
Expand Down
130 changes: 130 additions & 0 deletions src/integrations/misc/__tests__/process-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import { selectFiles, selectImages } from "../process-files"

// Mock vscode
vi.mock("vscode", () => ({
window: {
showOpenDialog: vi.fn(),
showWarningMessage: vi.fn(),
},
Uri: {
file: (path: string) => ({ fsPath: path }),
},
}))

// Mock fs
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
stat: vi.fn(),
},
}))

describe("process-files", () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe("selectFiles", () => {
it("should return empty arrays when no files are selected", async () => {
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue(undefined)

const result = await selectFiles()

expect(result).toEqual({ images: [], files: [] })
})

it("should process image files correctly", async () => {
const mockUri = { fsPath: "/test/image.png" }
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([mockUri] as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("fake-image-data"))

const result = await selectFiles()

expect(result.images).toHaveLength(1)
expect(result.images[0]).toMatch(/^data:image\/png;base64,/)
expect(result.files).toHaveLength(0)
})

it("should process text files correctly", async () => {
const mockUri = { fsPath: "/test/data.json" }
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([mockUri] as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
vi.mocked(fs.readFile).mockResolvedValue('{"test": "data"}')

const result = await selectFiles()

expect(result.files).toHaveLength(1)
expect(result.files[0]).toEqual({
path: "data.json",
content: '{"test": "data"}',
type: "json",
})
expect(result.images).toHaveLength(0)
})

it("should handle multiple files of different types", async () => {
const mockUris = [
{ fsPath: "/test/image.jpg" },
{ fsPath: "/test/config.xml" },
{ fsPath: "/test/readme.md" },
]
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue(mockUris as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(Buffer.from("fake-image"))
.mockResolvedValueOnce("<config>test</config>")
.mockResolvedValueOnce("# README")

const result = await selectFiles()

expect(result.images).toHaveLength(1)
expect(result.files).toHaveLength(2)
expect(result.files[0].type).toBe("xml")
expect(result.files[1].type).toBe("md")
})

it("should reject files that are too large", async () => {
const mockUri = { fsPath: "/test/large.txt" }
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([mockUri] as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 25 * 1024 * 1024 } as any) // 25MB

const result = await selectFiles()

expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("File too large: large.txt (max 20MB)")
expect(result).toEqual({ images: [], files: [] })
})

it("should handle binary files gracefully", async () => {
const mockUri = { fsPath: "/test/binary.exe" }
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([mockUri] as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
vi.mocked(fs.readFile).mockRejectedValue(new Error("Invalid UTF-8"))

const result = await selectFiles()

expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("Cannot attach binary file: binary.exe")
expect(result).toEqual({ images: [], files: [] })
})
})

describe("selectImages", () => {
it("should only return images from selectFiles", async () => {
const mockUris = [{ fsPath: "/test/image.png" }, { fsPath: "/test/data.json" }]
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue(mockUris as any)
vi.mocked(fs.stat).mockResolvedValue({ size: 1000 } as any)
vi.mocked(fs.readFile)
.mockResolvedValueOnce(Buffer.from("fake-image"))
.mockResolvedValueOnce('{"test": "data"}')

const result = await selectImages()

expect(result).toHaveLength(1)
expect(result[0]).toMatch(/^data:image\/png;base64,/)
})
})
})
158 changes: 158 additions & 0 deletions src/integrations/misc/process-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"

const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico"]
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB

// Known binary file extensions to skip upfront
const BINARY_EXTENSIONS = [
// Executables & Libraries
"exe",
"dll",
"so",
"dylib",
"app",
"deb",
"rpm",
"dmg",
"pkg",
"msi",
// Archives
"zip",
"tar",
"gz",
"bz2",
"7z",
"rar",
"jar",
"war",
"ear",
// Media
"mp3",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"ogg",
"wav",
"flac",
// Documents (binary formats)
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"odt",
"ods",
"odp",
// Databases
"db",
"sqlite",
"mdb",
// Other binary formats
"pyc",
"pyo",
"class",
"o",
"a",
"lib",
"node",
"wasm",
]

export async function selectFiles(): Promise<{
images: string[]
files: Array<{ path: string; content: string; type: string }>
}> {
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
openLabel: "Select",
filters: {
"All Files": ["*"],
Images: IMAGE_EXTENSIONS,
},
}

const fileUris = await vscode.window.showOpenDialog(options)

if (!fileUris || fileUris.length === 0) {
return { images: [], files: [] }
}

const images: string[] = []
const files: Array<{ path: string; content: string; type: string }> = []

await Promise.all(
fileUris.map(async (uri) => {
const filePath = uri.fsPath
const ext = path.extname(filePath).toLowerCase().substring(1)
const fileName = path.basename(filePath)

// Check file size
const stats = await fs.stat(filePath)
if (stats.size > MAX_FILE_SIZE) {
vscode.window.showWarningMessage(`File too large: ${fileName} (max 20MB)`)
return
}

if (IMAGE_EXTENSIONS.includes(ext)) {
// Process as image
const buffer = await fs.readFile(filePath)
const base64 = buffer.toString("base64")
const mimeType = getMimeType(filePath)
const dataUrl = `data:${mimeType};base64,${base64}`
images.push(dataUrl)
} else if (BINARY_EXTENSIONS.includes(ext)) {
// Skip known binary files
vscode.window.showWarningMessage(`Cannot attach binary file: ${fileName}`)
} else {
// Try to read as text file
try {
const content = await fs.readFile(filePath, "utf-8")
// Additional check: if the content has null bytes, it's likely binary
if (content.includes("\0")) {
vscode.window.showWarningMessage(`File appears to be binary: ${fileName}`)
} else {
files.push({
path: fileName,
content: content,
type: ext || "txt", // Default to 'txt' if no extension
})
}
} catch (error) {
// File couldn't be read as UTF-8, likely binary
vscode.window.showWarningMessage(`Cannot read file as text: ${fileName}`)
}
}
}),
)

return { images, files }
}

// Keep the original selectImages function for backward compatibility
export async function selectImages(): Promise<string[]> {
const result = await selectFiles()
return result.images
}

function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".png":
return "image/png"
case ".jpeg":
case ".jpg":
return "image/jpeg"
case ".webp":
return "image/webp"
default:
throw new Error(`Unsupported image type: ${ext}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IMAGE_EXTENSIONS array (line 5) includes extensions like gif, bmp, svg and ico, but the getMimeType function (lines 145–157) only supports PNG, JPEG, JPG and WEBP. Consider adding cases for the additional image types to prevent runtime errors when a supported extension is selected.

}
}
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ExtensionMessage {
| "action"
| "state"
| "selectedImages"
| "selectedFiles"
| "theme"
| "workspaceUpdated"
| "invoke"
Expand Down Expand Up @@ -121,6 +122,7 @@ export interface ExtensionMessage {
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
state?: ExtensionState
images?: string[]
files?: Array<{ path: string; content?: string; type: string }>
filePaths?: string[]
openedTabs?: Array<{
label: string
Expand Down
2 changes: 2 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface WebviewMessage {
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "selectFiles"
| "exportCurrentTask"
| "shareCurrentTask"
| "showTaskWithId"
Expand Down Expand Up @@ -201,6 +202,7 @@ export interface WebviewMessage {
askResponse?: ClineAskResponse
apiConfiguration?: ProviderSettings
images?: string[]
files?: Array<{ path: string; content?: string; type: string }>
bool?: boolean
value?: number
commands?: string[]
Expand Down
Loading
Loading