From d2f1e0cde0b968c21e5dd4db96e334c75df90367 Mon Sep 17 00:00:00 2001 From: Sarah Fortune Date: Fri, 27 Jun 2025 11:51:02 -0700 Subject: [PATCH] Replace vscode.workspace.workspaceFolders with host bridge getWorkspacePaths (#4443) * Use the host bridge in utils/path.ts Update utils/path.ts to use the host bridge to get the workspace folders, instead of the vscode SDK. Update callers to use await as the functions are now async. * Replace vscode workspaceFolders in WorkspaceTracker Make the cwd an instance property because await cannot be used at the top level. * Use the host bridge getWorkspacePaths in FileContextTracker Replace the vscode SDK getWorkspaceFolders with the util function getCwd (this is already switched to the host bridge). * Fix test failure Update the rootDir for the tests to be "." instead of "src". The changes to path.ts pull in new dependencies from the extension, which indirectly include files from the webview-ui. ``` Run npm run pretest > claude-dev@3.18.0 pretest > npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint > claude-dev@3.18.0 compile-tests > node ./scripts/build-tests.js node:child_process:957 throw err; ^ Error: Command failed: tsc -p ./tsconfig.test.json --outDir out at genericNodeError (node:internal/errors:983:15) at wrappedFn (node:internal/errors:537:14) at checkExecSyncError (node:child_process:882:11) at execSync (node:child_process:954:15) at Object. (/home/runner/work/cline/cline/scripts/build-tests.js:55:1) at Module._compile (node:internal/modules/cjs/loader:1730:14) at Object..js (node:internal/modules/cjs/loader:1895:10) at Module.load (node:internal/modules/cjs/loader:1465:32) at Function._load (node:internal/modules/cjs/loader:1282:12) at TracingChannel.traceSync (node:diagnostics_channel:322:14) { status: 2, signal: null, output: [ null, "src/services/test/TestServer.ts(8,35): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/services/grpc-client.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n" + "webview-ui/src/services/grpc-client-base.ts(1,24): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/utils/vscode.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n" + "webview-ui/src/services/grpc-client.ts(4,34): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/services/grpc-client-base.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n", '' ], pid: 2496, stdout: "src/services/test/TestServer.ts(8,35): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/services/grpc-client.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n" + "webview-ui/src/services/grpc-client-base.ts(1,24): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/utils/vscode.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n" + "webview-ui/src/services/grpc-client.ts(4,34): error TS6059: File '/home/runner/work/cline/cline/webview-ui/src/services/grpc-client-base.ts' is not under 'rootDir' '/home/runner/work/cline/cline/src'. 'rootDir' is expected to contain all source files.\n", stderr: '' } ``` * Build the protos before compiling the tests. The tests depend on generated files now, so compile the extension before the tests so that the protos are built. * Set up the host providers in the integration test FileContextTracker.test.ts * Reduce the amount of logging in grpc-service.ts Just log the service registration, instead of every rpc. * In the `clean` build target, also remove the compiled test code. * Correct the alias mapping for the compiled test files. --- package.json | 4 +-- .../FileContextTracker.test.ts | 5 +++- .../context-tracking/FileContextTracker.ts | 10 ++++--- src/core/controller/file/searchCommits.ts | 2 +- src/core/controller/file/searchFiles.ts | 4 +-- src/core/controller/grpc-service.ts | 2 +- src/core/controller/index.ts | 9 ++++--- src/core/controller/task/getTaskHistory.ts | 2 +- src/core/task/ToolExecutor.ts | 20 +++++++------- .../workspace/WorkspaceTracker.ts | 27 +++++++++++-------- src/utils/path.ts | 17 +++++++++--- test-setup.js | 2 +- tsconfig.test.json | 2 +- 13 files changed, 64 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index ec0c6bbfac..6051ad562b 100644 --- a/package.json +++ b/package.json @@ -332,14 +332,14 @@ "package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production", "protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs", "postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn", - "clean": "rimraf dist dist-standalone webview-ui/build src/generated", + "clean": "rimraf dist dist-standalone webview-ui/build src/generated out/", "compile-tests": "node ./scripts/build-tests.js", "watch-tests": "tsc -p . -w --outDir out", - "pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint", "check-types": "npm run protos && tsc --noEmit", "lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint", "format": "prettier . --check", "format:fix": "prettier . --write", + "pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint", "test": "npm-run-all test:unit test:integration", "test:ci": "node scripts/test-ci.js", "test:integration": "vscode-test", diff --git a/src/core/context/context-tracking/FileContextTracker.test.ts b/src/core/context/context-tracking/FileContextTracker.test.ts index f5fe65879b..27fea102c5 100644 --- a/src/core/context/context-tracking/FileContextTracker.test.ts +++ b/src/core/context/context-tracking/FileContextTracker.test.ts @@ -6,6 +6,9 @@ import * as path from "path" import { FileContextTracker } from "./FileContextTracker" import * as diskModule from "@core/storage/disk" import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes" +import type { WebviewProviderCreator } from "@/hosts/host-providers" +import * as hostProviders from "@hosts/host-providers" +import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client" describe("FileContextTracker", () => { let sandbox: sinon.SinonSandbox @@ -37,7 +40,6 @@ describe("FileContextTracker", () => { } // Use a function replacement instead of a direct stub - const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher vscode.workspace.createFileSystemWatcher = function () { return mockFileSystemWatcher } @@ -51,6 +53,7 @@ describe("FileContextTracker", () => { mockTaskMetadata = { files_in_context: [], model_usage: [] } getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata) saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves() + hostProviders.initializeHostProviders(((_) => {}) as WebviewProviderCreator, vscodeHostBridgeClient) // Create tracker instance taskId = "test-task-id" diff --git a/src/core/context/context-tracking/FileContextTracker.ts b/src/core/context/context-tracking/FileContextTracker.ts index 26c70b10c1..14f7d4ce51 100644 --- a/src/core/context/context-tracking/FileContextTracker.ts +++ b/src/core/context/context-tracking/FileContextTracker.ts @@ -5,6 +5,8 @@ import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state" import { getGlobalState } from "@core/storage/state" import type { FileMetadataEntry } from "./ContextTrackerTypes" import type { ClineMessage } from "@shared/ExtensionMessage" +import { getHostBridgeProvider } from "@/hosts/host-providers" +import { getCwd } from "@/utils/path" // This class is responsible for tracking file operations that may result in stale context. // If a user modifies a file outside of Cline, the context may become stale and need to be updated. @@ -37,8 +39,8 @@ export class FileContextTracker { /** * Gets the current working directory or returns undefined if it cannot be determined */ - private getCwd(): string | undefined { - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + private async getCwd(): Promise { + const cwd = await getCwd(undefined) if (!cwd) { console.info("No workspace folder available - cannot determine current working directory") } @@ -54,7 +56,7 @@ export class FileContextTracker { return } - const cwd = this.getCwd() + const cwd = await this.getCwd() if (!cwd) { return } @@ -85,7 +87,7 @@ export class FileContextTracker { */ async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") { try { - const cwd = this.getCwd() + const cwd = await this.getCwd() if (!cwd) { return } diff --git a/src/core/controller/file/searchCommits.ts b/src/core/controller/file/searchCommits.ts index 772be38137..7665f29277 100644 --- a/src/core/controller/file/searchCommits.ts +++ b/src/core/controller/file/searchCommits.ts @@ -13,7 +13,7 @@ import { convertGitCommitsToProtoGitCommits } from "@shared/proto-conversions/fi * @returns GitCommits containing the matching commits */ export const searchCommits: FileMethodHandler = async (controller: Controller, request: StringRequest): Promise => { - const cwd = getWorkspacePath() + const cwd = await getWorkspacePath() if (!cwd) { return GitCommits.create({ commits: [] }) } diff --git a/src/core/controller/file/searchFiles.ts b/src/core/controller/file/searchFiles.ts index f68fb254ca..fe482e24cd 100644 --- a/src/core/controller/file/searchFiles.ts +++ b/src/core/controller/file/searchFiles.ts @@ -12,10 +12,10 @@ import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/ * @returns Results containing matching files/folders */ export const searchFiles: FileMethodHandler = async ( - controller: Controller, + _controller: Controller, request: FileSearchRequest, ): Promise => { - const workspacePath = getWorkspacePath() + const workspacePath = await getWorkspacePath() if (!workspacePath) { // Handle case where workspace path is not available diff --git a/src/core/controller/grpc-service.ts b/src/core/controller/grpc-service.ts index 23cb54ba59..aa9d4a33e3 100644 --- a/src/core/controller/grpc-service.ts +++ b/src/core/controller/grpc-service.ts @@ -37,6 +37,7 @@ export class ServiceRegistry { * @param serviceName The name of the service (used for logging) */ constructor(serviceName: string) { + console.log(`Registering Protobus service: ${serviceName}...`) this.serviceName = serviceName } @@ -56,7 +57,6 @@ export class ServiceRegistry { } this.methodMetadata[methodName] = { isStreaming, ...metadata } - console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`) } /** diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts index efd25b0be8..688c818b00 100644 --- a/src/core/controller/index.ts +++ b/src/core/controller/index.ts @@ -46,6 +46,9 @@ import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceC import { sendRelinquishControlEvent } from "./ui/subscribeToRelinquishControl" import { handleTaskServiceRequest } from "./task" import { BooleanRequest } from "@shared/proto/common" +import { getHostBridgeProvider } from "@/hosts/host-providers" +import { GetWorkspacePathsRequest } from "@/shared/proto/index.host" +import { getCwd } from "@/utils/path" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -683,8 +686,8 @@ export class Controller { // Context menus and code actions - getFileMentionFromPath(filePath: string) { - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + async getFileMentionFromPath(filePath: string) { + const cwd = await getCwd() if (!cwd) { return "@/" + filePath } @@ -988,7 +991,7 @@ export class Controller { async generateGitCommitMessage() { try { // Check if there's a workspace folder open - const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + const cwd = await getCwd() if (!cwd) { vscode.window.showErrorMessage("No workspace folder open") return diff --git a/src/core/controller/task/getTaskHistory.ts b/src/core/controller/task/getTaskHistory.ts index a9c73433e6..90c0d65989 100644 --- a/src/core/controller/task/getTaskHistory.ts +++ b/src/core/controller/task/getTaskHistory.ts @@ -15,7 +15,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis // Get task history from global state const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || [] - const workspacePath = getWorkspacePath() + const workspacePath = await getWorkspacePath() // Apply filters let filteredTasks = taskHistory.filter((item) => { diff --git a/src/core/task/ToolExecutor.ts b/src/core/task/ToolExecutor.ts index 6507b39e3c..777b7e1082 100644 --- a/src/core/task/ToolExecutor.ts +++ b/src/core/task/ToolExecutor.ts @@ -574,7 +574,7 @@ export class ToolExecutor { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(this.cwd, this.removeClosingTag(block, "path", relPath)), content: diff || content, - operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath), } if (block.partial) { @@ -645,7 +645,7 @@ export class ToolExecutor { const completeMessage = JSON.stringify({ ...sharedMessageProps, content: diff || content, - operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath), // ? formatResponse.createPrettyPatch( // relPath, // this.diffViewProvider.originalContent, @@ -788,7 +788,7 @@ export class ToolExecutor { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined, - operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -819,7 +819,7 @@ export class ToolExecutor { const completeMessage = JSON.stringify({ ...sharedMessageProps, content: absolutePath, - operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -870,7 +870,7 @@ export class ToolExecutor { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "", - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -902,7 +902,7 @@ export class ToolExecutor { const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result, - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -945,7 +945,7 @@ export class ToolExecutor { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "", - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -974,7 +974,7 @@ export class ToolExecutor { const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result, - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -1021,7 +1021,7 @@ export class ToolExecutor { const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "", - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") @@ -1058,7 +1058,7 @@ export class ToolExecutor { const completeMessage = JSON.stringify({ ...sharedMessageProps, content: results, - operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path), + operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path), } satisfies ClineSayTool) if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) { this.removeLastPartialMessageIfExistsWithType("ask", "tool") diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index c64ce321cf..7a589dbc81 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -2,13 +2,22 @@ import * as vscode from "vscode" import * as path from "path" import { listFiles } from "@services/glob/list-files" import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates" - -const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) +import { getCwd } from "@/utils/path" // Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected class WorkspaceTracker { private disposables: vscode.Disposable[] = [] private filePaths: Set = new Set() + private cwd: string = "" + + constructor() { + this.initializeCwd() + this.registerListeners() + } + + private async initializeCwd() { + this.cwd = await getCwd() + } private get activeFiles() { return new Set( @@ -18,16 +27,12 @@ class WorkspaceTracker { ) } - constructor() { - this.registerListeners() - } - async populateFilePaths() { // should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file - if (!cwd) { + if (!this.cwd) { return } - const [files, _] = await listFiles(cwd, true, 1_000) + const [files, _] = await listFiles(this.cwd, true, 1_000) files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file))) this.workspaceDidUpdate() } @@ -91,18 +96,18 @@ class WorkspaceTracker { } private async workspaceDidUpdate() { - if (!cwd) { + if (!this.cwd) { return } const filePaths = Array.from(new Set([...this.activeFiles, ...this.filePaths])).map((file) => { - const relativePath = path.relative(cwd, file).toPosix() + const relativePath = path.relative(this.cwd, file).toPosix() return file.endsWith("/") ? relativePath + "/" : relativePath }) await sendWorkspaceUpdateEvent(filePaths) } private normalizeFilePath(filePath: string): string { - const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath) + const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath) return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath } diff --git a/src/utils/path.ts b/src/utils/path.ts index 4a1aee4cd2..fd1f85e714 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,6 +1,7 @@ import * as path from "path" import os from "os" import * as vscode from "vscode" +import { getHostBridgeProvider } from "@/hosts/host-providers" /* The Node.js 'path' module resolves and normalizes paths differently depending on the platform: @@ -101,9 +102,17 @@ export function getReadablePath(cwd: string, relPath?: string): string { } } -export const getWorkspacePath = (defaultCwdPath = "") => { - const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath +// Returns the path of the first workspace directory, or the defaultCwdPath if there is no workspace open. +export const getCwd = async (defaultCwdPath = ""): Promise => { + const workspaceFolders = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({}) + return workspaceFolders.paths.shift() || defaultCwdPath +} + +// Returns the workspace path of the file in the current editor. +// If there is no path, it returns the top level workspace directory. +export const getWorkspacePath = async (defaultCwdPath = "") => { const currentFileUri = vscode.window.activeTextEditor?.document.uri + const cwdPath = await getCwd(defaultCwdPath) if (currentFileUri) { const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri) return workspaceFolder?.uri.fsPath || cwdPath @@ -111,8 +120,8 @@ export const getWorkspacePath = (defaultCwdPath = "") => { return cwdPath } -export const isLocatedInWorkspace = (pathToCheck: string = ""): boolean => { - const workspacePath = getWorkspacePath() +export const isLocatedInWorkspace = async (pathToCheck: string = ""): Promise => { + const workspacePath = await getWorkspacePath() // Handle long paths in Windows if (pathToCheck.startsWith("\\\\?\\") || workspacePath.startsWith("\\\\?\\")) { diff --git a/test-setup.js b/test-setup.js index a096b73744..bdeab16dd8 100644 --- a/test-setup.js +++ b/test-setup.js @@ -16,7 +16,7 @@ const tsConfig = JSON.parse(fs.readFileSync(path.join(baseUrl, "tsconfig.json"), const outPaths = {} Object.keys(tsConfig.compilerOptions.paths).forEach((key) => { const value = tsConfig.compilerOptions.paths[key] - outPaths[key] = value.map((path) => path.replace("src", "out")) + outPaths[key] = value.map((path) => path.replace("src", "out/src")) }) tsConfigPaths.register({ diff --git a/tsconfig.test.json b/tsconfig.test.json index 4699ca7e62..fef4465be6 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -11,7 +11,7 @@ "types": ["node", "mocha", "should", "vscode", "chai"], "typeRoots": ["./node_modules/@types", "./src/test/types"], "outDir": "out", - "rootDir": "src" + "rootDir": "." }, "include": ["src/**/*.test.ts"], "exclude": ["src/test/**/*.js", "src/**/__tests__/*"]