mirror of
https://github.com/cline/cline.git
synced 2026-09-03 12:14:00 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5778d86b3 | |||
| 597901ba7a |
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user