Compare commits

...

2 Commits

Author SHA1 Message Date
kvyb f5778d86b3 feat: migrate file selection to host bridge and fix PDF opening
- Add FileService to host bridge with selectFiles RPC for multi-IDE support
- Create proto/host/file.proto and VSCode selectFiles implementation
- Update HostProvider, host-bridge-client-manager, and grpc-client for fileClient
- Simplify process-files.ts to use HostProvider abstraction pattern
- Fix VSCode openFile to route PDFs/binary files to system viewer vs text editor
- Improve ChatView.tsx file selection with defensive array handling
- Regenerate all host bridge client interfaces and configurations
- Preserve all existing functionality while enabling IntelliJ implementation
2025-07-24 14:00:09 +08:00
kvyb 597901ba7a feat: add openFile host bridge for vscode.open command 2025-07-24 07:01:18 +08:00
12 changed files with 311 additions and 124 deletions
+13
View File
@@ -0,0 +1,13 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "common.proto";
// Host-specific file operations
service FileService {
// Select images and other files from the file system and returns as data URLs & paths respectively
rpc selectFiles(cline.BooleanRequest) returns (cline.StringArrays);
}
+10
View File
@@ -14,6 +14,7 @@ service WindowService {
rpc showMessage(ShowMessageRequest) returns (SelectedResponse);
rpc showInputBox(ShowInputBoxRequest) returns (ShowInputBoxResponse);
rpc showSaveDialog(ShowSaveDialogRequest) returns (ShowSaveDialogResponse);
rpc openFile(OpenFileRequest) returns (OpenFileResponse);
}
message ShowTextDocumentRequest {
@@ -101,4 +102,13 @@ message ShowInputBoxRequest {
message ShowInputBoxResponse {
optional string response = 1;
}
message OpenFileRequest {
cline.Metadata metadata = 1;
string file_path = 2;
}
message OpenFileResponse {
bool success = 1;
}
+4
View File
@@ -5,6 +5,7 @@ import {
EnvServiceClientImpl,
WindowServiceClientImpl,
DiffServiceClientImpl,
FileServiceClientImpl,
} from "@generated/hosts/standalone/host-bridge-clients"
import {
WatchServiceClientInterface,
@@ -12,6 +13,7 @@ import {
EnvServiceClientInterface,
WindowServiceClientInterface,
DiffServiceClientInterface,
FileServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
import { HOSTBRIDGE_PORT } from "@/standalone/protobus-service"
@@ -26,6 +28,7 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
envClient: EnvServiceClientInterface
windowClient: WindowServiceClientInterface
diffClient: DiffServiceClientInterface
fileClient: FileServiceClientInterface
constructor() {
const address = process.env.HOST_BRIDGE_ADDRESS || `localhost:${HOSTBRIDGE_PORT}`
@@ -35,5 +38,6 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
this.envClient = new EnvServiceClientImpl(address)
this.windowClient = new WindowServiceClientImpl(address)
this.diffClient = new DiffServiceClientImpl(address)
this.fileClient = new FileServiceClientImpl(address)
}
}
+2
View File
@@ -4,6 +4,7 @@ import {
EnvServiceClientInterface,
WindowServiceClientInterface,
DiffServiceClientInterface,
FileServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
/**
@@ -15,6 +16,7 @@ export interface HostBridgeClientProvider {
envClient: EnvServiceClientInterface
windowClient: WindowServiceClientInterface
diffClient: DiffServiceClientInterface
fileClient: FileServiceClientInterface
}
/**
+4
View File
@@ -88,6 +88,10 @@ export class HostProvider {
public static get diff() {
return HostProvider.get().hostBridge.diffClient
}
public static get file() {
return HostProvider.get().hostBridge.fileClient
}
}
/**
@@ -8,4 +8,5 @@ export const vscodeHostBridgeClient: HostBridgeClientProvider = {
envClient: createGrpcClient(host.EnvServiceDefinition),
windowClient: createGrpcClient(host.WindowServiceDefinition),
diffClient: createGrpcClient(host.DiffServiceDefinition),
fileClient: createGrpcClient(host.FileServiceDefinition),
}
@@ -0,0 +1,93 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import sizeOf from "image-size"
import { BooleanRequest, StringArrays } from "@/shared/proto/common"
export async function selectFiles(request: BooleanRequest): Promise<StringArrays> {
try {
const imagesAllowed = request.value
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"] // supported by anthropic and openrouter
const OTHER_FILE_EXTENSIONS = ["xml", "json", "txt", "log", "md", "docx", "ipynb", "pdf", "xlsx", "csv"]
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
openLabel: "Select",
filters: {
files: imagesAllowed ? [...IMAGE_EXTENSIONS, ...OTHER_FILE_EXTENSIONS] : OTHER_FILE_EXTENSIONS,
},
}
const fileUris = await vscode.window.showOpenDialog(options)
if (!fileUris || fileUris.length === 0) {
return StringArrays.create({ values1: [], values2: [] })
}
const images: string[] = []
const files: string[] = []
for (const uri of fileUris) {
const filePath = uri.fsPath
const extension = path.extname(filePath).slice(1).toLowerCase()
if (IMAGE_EXTENSIONS.includes(extension) && imagesAllowed) {
try {
// Read file once and use for both dimension check and base64 conversion
const buffer = await fs.readFile(filePath)
// Check image dimensions
const dimensions = sizeOf(buffer)
if (!dimensions.width || !dimensions.height) {
console.warn(`Could not get dimensions for image: ${filePath}`)
continue
}
if (dimensions.width > 7500 || dimensions.height > 7500) {
console.warn(`Image dimensions exceed 7500px, skipping: ${filePath}`)
continue
}
// Convert to base64 data URL
const base64 = buffer.toString("base64")
const mimeType = `image/${extension === "jpg" ? "jpeg" : extension}`
const dataUrl = `data:${mimeType};base64,${base64}`
// Images only go in values1 (thumbnails only, no file path)
images.push(dataUrl)
} catch (error) {
console.error(`Error processing image ${filePath}:`, error)
}
} else {
try {
// Check file size (20MB limit)
const stats = await fs.stat(filePath)
if (stats.size > 20 * 1024 * 1024) {
console.warn(`File too large, skipping: ${filePath}`)
continue
}
files.push(filePath)
} catch (error) {
console.error(`Error checking file ${filePath}:`, error)
}
}
}
// Ensure we always return arrays, even if empty
const result = StringArrays.create({
values1: images.length > 0 ? images : [],
values2: files.length > 0 ? files : [],
})
console.log("VSCode selectFiles result:", {
values1Count: result.values1?.length || 0,
values2Count: result.values2?.length || 0,
values1: result.values1,
values2: result.values2,
})
return result
} catch (error) {
console.error("Error selecting images & files:", error)
// Return empty array on error
return StringArrays.create({ values1: [], values2: [] })
}
}
@@ -0,0 +1,46 @@
import * as vscode from "vscode"
import * as path from "path"
import { OpenFileRequest, OpenFileResponse } from "@/shared/proto/host/window"
export async function openFile(request: OpenFileRequest): Promise<OpenFileResponse> {
try {
const fileExtension = path.extname(request.filePath).toLowerCase()
// Binary files that should open in system viewer (not VSCode text editor)
const binaryExtensions = [
".pdf",
".docx",
".xlsx",
".pptx",
".zip",
".rar",
".exe",
".dmg",
".pkg",
".app",
".deb",
".rpm",
]
const shouldOpenInSystemViewer = binaryExtensions.includes(fileExtension)
if (shouldOpenInSystemViewer) {
// For binary files, use openExternal to open in system viewer
await vscode.env.openExternal(vscode.Uri.file(request.filePath))
return OpenFileResponse.create({ success: true })
}
// For text files, try opening in VSCode first
try {
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(request.filePath))
return OpenFileResponse.create({ success: true })
} catch (error) {
console.warn("vscode.open failed for text file, trying openExternal:", error)
// Fallback to system viewer even for text files if VSCode can't handle them
await vscode.env.openExternal(vscode.Uri.file(request.filePath))
return OpenFileResponse.create({ success: true })
}
} catch (error) {
console.error("Failed to open file with both methods:", error)
return OpenFileResponse.create({ success: false })
}
}
+12 -6
View File
@@ -3,7 +3,7 @@ import * as os from "os"
import * as vscode from "vscode"
import { arePathsEqual } from "@utils/path"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { ShowMessageRequest, ShowMessageType, OpenFileRequest } from "@/shared/proto/host/window"
import { writeFile } from "@utils/fs"
export async function openImage(dataUri: string) {
@@ -20,7 +20,11 @@ export async function openImage(dataUri: string) {
const tempFilePath = path.join(os.tmpdir(), `temp_image_${Date.now()}.${format}`)
try {
await writeFile(tempFilePath, new Uint8Array(imageBuffer))
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(tempFilePath))
await HostProvider.window.openFile(
OpenFileRequest.create({
filePath: tempFilePath,
}),
)
} catch (error) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
@@ -50,10 +54,12 @@ export async function openFile(absolutePath: string) {
}
} catch {} // not essential, sometimes tab operations fail
await HostProvider.window.showTextDocument({
path: uri.fsPath,
options: { preview: false },
})
// Let the host bridge decide how to open the file (text editor vs system viewer)
await HostProvider.window.openFile(
OpenFileRequest.create({
filePath: absolutePath,
}),
)
} catch (error) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
+8 -112
View File
@@ -1,123 +1,19 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import sizeOf from "image-size"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageRequest, ShowMessageType, ShowOpenDialogueRequest } from "@/shared/proto/host/window"
/**
* Supports processing of images and other file types
* For models which don't support images, will not allow them to be selected
*/
export async function selectFiles(imagesAllowed: boolean): Promise<{ images: string[]; files: string[] }> {
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"] // supported by anthropic and openrouter
const OTHER_FILE_EXTENSIONS = ["xml", "json", "txt", "log", "md", "docx", "ipynb", "pdf", "xlsx", "csv"]
// Use HostProvider.file.selectFiles() which will route to the correct implementation
// (VSCode native API, IntelliJ hostbridge, etc.)
const response = await HostProvider.file.selectFiles({ value: imagesAllowed })
const showDialogueResponse = await HostProvider.window.showOpenDialogue(
ShowOpenDialogueRequest.create({
canSelectMany: true,
openLabel: "Select",
filters: {
files: imagesAllowed ? [...IMAGE_EXTENSIONS, ...OTHER_FILE_EXTENSIONS] : OTHER_FILE_EXTENSIONS,
},
}),
)
const fileUris = showDialogueResponse.paths.map((path) => vscode.Uri.file(path))
if (!fileUris || fileUris.length === 0) {
return { images: [], files: [] }
}
const processFilesPromises = fileUris.map(async (uri) => {
const filePath = uri.fsPath
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
//const fileName = path.basename(filePath)
const isImage = IMAGE_EXTENSIONS.includes(fileExtension)
if (isImage) {
let buffer: Buffer
try {
// Read the file into a buffer first
buffer = await fs.readFile(filePath)
// Convert Node.js Buffer to Uint8Array
const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const dimensions = sizeOf(uint8Array) // Get dimensions from Uint8Array
if (dimensions.width! > 7500 || dimensions.height! > 7500) {
console.warn(`Image dimensions exceed 7500px, skipping: ${filePath}`)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Image too large: ${path.basename(filePath)} was skipped (dimensions exceed 7500px).`,
})
return null
}
} catch (error) {
console.error(`Error reading file or getting dimensions for ${filePath}:`, error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Could not read dimensions for ${path.basename(filePath)}, skipping.`,
})
return null
}
// If dimensions are valid, proceed to convert the existing buffer to base64
const base64 = buffer.toString("base64")
const mimeType = getMimeType(filePath)
return { type: "image", data: `data:${mimeType};base64,${base64}` }
} else {
// for standard models we will check the size of the file to ensure its not too large
try {
const stats = await fs.stat(filePath)
if (stats.size > 20 * 1000 * 1024) {
console.warn(`File too large, skipping: ${filePath}`)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `File too large: ${path.basename(filePath)} was skipped (size exceeds 20MB).`,
})
return null
}
} catch (error) {
console.error(`Error checking file size for ${filePath}:`, error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Could not check file size for ${path.basename(filePath)}, skipping.`,
})
return null
}
return { type: "file", data: filePath }
}
})
const dataUrlsWithNulls = await Promise.all(processFilesPromises)
const dataUrlsWithoutNulls = dataUrlsWithNulls.filter((item) => item !== null)
const images: string[] = []
const files: string[] = []
for (const item of dataUrlsWithoutNulls) {
if (item.type === "image") {
images.push(item.data)
} else {
files.push(item.data)
}
}
// The hostbridge returns StringArrays with:
// values1: image data URLs (base64)
// values2: file paths
const images = response.values1 || []
const files = response.values2 || []
return { images, files }
}
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 file type: ${ext}`)
}
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import "should"
import * as vscode from "vscode"
import * as sinon from "sinon"
import { openFile } from "@/hosts/vscode/hostbridge/window/openFile"
import { OpenFileRequest } from "@/shared/proto/host/window"
describe("openFile hostbridge integration", () => {
let executeCommandStub: sinon.SinonStub
beforeEach(() => {
// Stub vscode.commands.executeCommand to track calls
executeCommandStub = sinon.stub(vscode.commands, "executeCommand")
executeCommandStub.resolves() // Mock successful execution
})
afterEach(() => {
// Restore all stubs
sinon.restore()
})
it("should successfully call openFile function directly in Extension Host", async () => {
// Arrange
const testFilePath = "/Users/kvyb/Desktop/test-file.txt"
const request = OpenFileRequest.create({
filePath: testFilePath,
})
// Act - Call the openFile function directly
const result = await openFile(request)
// Assert
should.exist(result)
result.success.should.be.true()
// Verify the vscode.open command was called with correct parameters
sinon.assert.calledOnce(executeCommandStub)
sinon.assert.calledWith(
executeCommandStub,
"vscode.open",
sinon.match((arg: vscode.Uri) => {
return arg instanceof vscode.Uri && arg.fsPath === testFilePath
}),
)
})
it("should handle errors gracefully in Extension Host", async () => {
// Arrange
const testFilePath = "/nonexistent/path/file.txt"
const request = OpenFileRequest.create({
filePath: testFilePath,
})
// Setup stub to reject
executeCommandStub.rejects(new Error("File not found"))
// Act
const result = await openFile(request)
// Assert
should.exist(result)
result.success.should.be.false()
// Verify the command was still attempted
sinon.assert.calledOnce(executeCommandStub)
})
it("should work with different file types in Extension Host", async () => {
// Arrange - Test with an image file
const imageFilePath = "/tmp/screenshot.png"
const request = OpenFileRequest.create({
filePath: imageFilePath,
})
// Act
const result = await openFile(request)
// Assert
result.success.should.be.true()
sinon.assert.calledWith(
executeCommandStub,
"vscode.open",
sinon.match((arg: vscode.Uri) => {
return arg.fsPath === imageFilePath
}),
)
})
it("should handle special characters in file paths in Extension Host", async () => {
// Arrange
const specialFilePath = "/path/with spaces/file (copy).txt"
const request = OpenFileRequest.create({
filePath: specialFilePath,
})
// Act
const result = await openFile(request)
// Assert
result.success.should.be.true()
sinon.assert.calledWith(
executeCommandStub,
"vscode.open",
sinon.match((arg: vscode.Uri) => {
return arg.fsPath === specialFilePath
}),
)
})
})
+9 -6
View File
@@ -212,24 +212,27 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)
if (
response &&
response.values1 &&
response.values2 &&
(response.values1.length > 0 || response.values2.length > 0)
(response.values1 || response.values2) &&
((response.values1 && response.values1.length > 0) || (response.values2 && response.values2.length > 0))
) {
const currentTotal = selectedImages.length + selectedFiles.length
const availableSlots = MAX_IMAGES_AND_FILES_PER_MESSAGE - currentTotal
if (availableSlots > 0) {
// Defensive: ensure arrays exist before using them
const images = response.values1 || []
const files = response.values2 || []
// Prioritize images first
const imagesToAdd = Math.min(response.values1.length, availableSlots)
const imagesToAdd = Math.min(images.length, availableSlots)
if (imagesToAdd > 0) {
setSelectedImages((prevImages) => [...prevImages, ...response.values1.slice(0, imagesToAdd)])
setSelectedImages((prevImages) => [...prevImages, ...images.slice(0, imagesToAdd)])
}
// Use remaining slots for files
const remainingSlots = availableSlots - imagesToAdd
if (remainingSlots > 0) {
setSelectedFiles((prevFiles) => [...prevFiles, ...response.values2.slice(0, remainingSlots)])
setSelectedFiles((prevFiles) => [...prevFiles, ...files.slice(0, remainingSlots)])
}
}
}