diff --git a/.changeset/fast-taxis-speak.md b/.changeset/fast-taxis-speak.md new file mode 100644 index 0000000000..02f5db64c7 --- /dev/null +++ b/.changeset/fast-taxis-speak.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +add multiple workspaces support diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 75f239e441..246fc8518e 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -78,9 +78,7 @@ import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy" import { insertGroups } from "./diff/insert-groups" import { telemetryService } from "../services/telemetry/TelemetryService" import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator" - -const cwd = - vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution +import { getWorkspacePath } from "../utils/path" type ToolResponse = string | Array type UserContent = Array @@ -118,7 +116,9 @@ export type ClineOptions = { export class Cline extends EventEmitter { readonly taskId: string readonly instanceId: string - + get cwd() { + return getWorkspacePath(path.join(os.homedir(), "Desktop")) + } // Subtasks readonly rootTask: Cline | undefined = undefined readonly parentTask: Cline | undefined = undefined @@ -195,7 +195,7 @@ export class Cline extends EventEmitter { throw new Error("Either historyItem or task/images must be provided") } - this.rooIgnoreController = new RooIgnoreController(cwd) + this.rooIgnoreController = new RooIgnoreController(this.cwd) this.rooIgnoreController.initialize().catch((error) => { console.error("Failed to initialize RooIgnoreController:", error) }) @@ -211,7 +211,7 @@ export class Cline extends EventEmitter { this.diffEnabled = enableDiff ?? false this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0 this.providerRef = new WeakRef(provider) - this.diffViewProvider = new DiffViewProvider(cwd) + this.diffViewProvider = new DiffViewProvider(this.cwd) this.enableCheckpoints = enableCheckpoints this.checkpointStorage = checkpointStorage @@ -844,7 +844,7 @@ export class Cline extends EventEmitter { newUserContent.push({ type: "text", text: - `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${ + `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${this.cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${ wasRecent ? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents." : "" @@ -941,11 +941,11 @@ export class Cline extends EventEmitter { async executeCommandTool(command: string, customCwd?: string): Promise<[boolean, ToolResponse]> { let workingDir: string if (!customCwd) { - workingDir = cwd + workingDir = this.cwd } else if (path.isAbsolute(customCwd)) { workingDir = customCwd } else { - workingDir = path.resolve(cwd, customCwd) + workingDir = path.resolve(this.cwd, customCwd) } // Check if directory exists @@ -1125,7 +1125,7 @@ export class Cline extends EventEmitter { } return SYSTEM_PROMPT( provider.context, - cwd, + this.cwd, (this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true), mcpHub, this.diffStrategy, @@ -1549,7 +1549,7 @@ export class Cline extends EventEmitter { if (this.diffViewProvider.editType !== undefined) { fileExists = this.diffViewProvider.editType === "modify" } else { - const absolutePath = path.resolve(cwd, relPath) + const absolutePath = path.resolve(this.cwd, relPath) fileExists = await fileExistsAtPath(absolutePath) this.diffViewProvider.editType = fileExists ? "modify" : "create" } @@ -1579,7 +1579,7 @@ export class Cline extends EventEmitter { const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cwd, removeClosingTag("path", relPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relPath)), } try { if (block.partial) { @@ -1696,7 +1696,7 @@ export class Cline extends EventEmitter { "user_feedback_diff", JSON.stringify({ tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cwd, relPath), + path: getReadablePath(this.cwd, relPath), diff: userEdits, } satisfies ClineSayTool), ) @@ -1732,7 +1732,7 @@ export class Cline extends EventEmitter { const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", - path: getReadablePath(cwd, removeClosingTag("path", relPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relPath)), } try { @@ -1769,7 +1769,7 @@ export class Cline extends EventEmitter { break } - const absolutePath = path.resolve(cwd, relPath) + const absolutePath = path.resolve(this.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -1865,7 +1865,7 @@ export class Cline extends EventEmitter { "user_feedback_diff", JSON.stringify({ tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cwd, relPath), + path: getReadablePath(this.cwd, relPath), diff: userEdits, } satisfies ClineSayTool), ) @@ -1904,7 +1904,7 @@ export class Cline extends EventEmitter { const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", - path: getReadablePath(cwd, removeClosingTag("path", relPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relPath)), } try { @@ -1927,7 +1927,7 @@ export class Cline extends EventEmitter { break } - const absolutePath = path.resolve(cwd, relPath) + const absolutePath = path.resolve(this.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -2021,7 +2021,7 @@ export class Cline extends EventEmitter { const userFeedbackDiff = JSON.stringify({ tool: "appliedDiff", - path: getReadablePath(cwd, relPath), + path: getReadablePath(this.cwd, relPath), diff: userEdits, } satisfies ClineSayTool) @@ -2051,7 +2051,7 @@ export class Cline extends EventEmitter { const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", - path: getReadablePath(cwd, removeClosingTag("path", relPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relPath)), } try { @@ -2078,7 +2078,7 @@ export class Cline extends EventEmitter { break } - const absolutePath = path.resolve(cwd, relPath) + const absolutePath = path.resolve(this.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -2183,7 +2183,7 @@ export class Cline extends EventEmitter { "user_feedback_diff", JSON.stringify({ tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(cwd, relPath), + path: getReadablePath(this.cwd, relPath), diff: userEdits, } satisfies ClineSayTool), ) @@ -2216,7 +2216,7 @@ export class Cline extends EventEmitter { const relPath: string | undefined = block.params.path const sharedMessageProps: ClineSayTool = { tool: "readFile", - path: getReadablePath(cwd, removeClosingTag("path", relPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relPath)), } try { if (block.partial) { @@ -2242,7 +2242,7 @@ export class Cline extends EventEmitter { } this.consecutiveMistakeCount = 0 - const absolutePath = path.resolve(cwd, relPath) + const absolutePath = path.resolve(this.cwd, relPath) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: absolutePath, @@ -2267,7 +2267,7 @@ export class Cline extends EventEmitter { const recursive = recursiveRaw?.toLowerCase() === "true" const sharedMessageProps: ClineSayTool = { tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive", - path: getReadablePath(cwd, removeClosingTag("path", relDirPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)), } try { if (block.partial) { @@ -2284,7 +2284,7 @@ export class Cline extends EventEmitter { break } this.consecutiveMistakeCount = 0 - const absolutePath = path.resolve(cwd, relDirPath) + const absolutePath = path.resolve(this.cwd, relDirPath) const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200) const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( @@ -2314,7 +2314,7 @@ export class Cline extends EventEmitter { const relDirPath: string | undefined = block.params.path const sharedMessageProps: ClineSayTool = { tool: "listCodeDefinitionNames", - path: getReadablePath(cwd, removeClosingTag("path", relDirPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)), } try { if (block.partial) { @@ -2333,7 +2333,7 @@ export class Cline extends EventEmitter { break } this.consecutiveMistakeCount = 0 - const absolutePath = path.resolve(cwd, relDirPath) + const absolutePath = path.resolve(this.cwd, relDirPath) const result = await parseSourceCodeForDefinitionsTopLevel( absolutePath, this.rooIgnoreController, @@ -2360,7 +2360,7 @@ export class Cline extends EventEmitter { const filePattern: string | undefined = block.params.file_pattern const sharedMessageProps: ClineSayTool = { tool: "searchFiles", - path: getReadablePath(cwd, removeClosingTag("path", relDirPath)), + path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)), regex: removeClosingTag("regex", regex), filePattern: removeClosingTag("file_pattern", filePattern), } @@ -2384,9 +2384,9 @@ export class Cline extends EventEmitter { break } this.consecutiveMistakeCount = 0 - const absolutePath = path.resolve(cwd, relDirPath) + const absolutePath = path.resolve(this.cwd, relDirPath) const results = await regexSearchFiles( - cwd, + this.cwd, absolutePath, regex, filePattern, @@ -3469,7 +3469,7 @@ export class Cline extends EventEmitter { if (shouldProcessMentions(block.text)) { return { ...block, - text: await parseMentions(block.text, cwd, this.urlContentFetcher), + text: await parseMentions(block.text, this.cwd, this.urlContentFetcher), } } return block @@ -3478,7 +3478,7 @@ export class Cline extends EventEmitter { if (shouldProcessMentions(block.content)) { return { ...block, - content: await parseMentions(block.content, cwd, this.urlContentFetcher), + content: await parseMentions(block.content, this.cwd, this.urlContentFetcher), } } return block @@ -3488,7 +3488,11 @@ export class Cline extends EventEmitter { if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { return { ...contentBlock, - text: await parseMentions(contentBlock.text, cwd, this.urlContentFetcher), + text: await parseMentions( + contentBlock.text, + this.cwd, + this.urlContentFetcher, + ), } } return contentBlock @@ -3518,7 +3522,7 @@ export class Cline extends EventEmitter { const visibleFilePaths = vscode.window.visibleTextEditors ?.map((editor) => editor.document?.uri?.fsPath) .filter(Boolean) - .map((absolutePath) => path.relative(cwd, absolutePath)) + .map((absolutePath) => path.relative(this.cwd, absolutePath)) .slice(0, maxWorkspaceFiles ?? 200) // Filter paths through rooIgnoreController @@ -3539,7 +3543,7 @@ export class Cline extends EventEmitter { .flatMap((group) => group.tabs) .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) .filter(Boolean) - .map((absolutePath) => path.relative(cwd, absolutePath).toPosix()) + .map((absolutePath) => path.relative(this.cwd, absolutePath).toPosix()) .slice(0, maxTabs) // Filter paths through rooIgnoreController @@ -3582,7 +3586,7 @@ export class Cline extends EventEmitter { for (const [uri, fileDiagnostics] of diagnostics) { const problems = fileDiagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error) if (problems.length > 0) { - diagnosticsDetails += `\n## ${path.relative(cwd, uri.fsPath)}` + diagnosticsDetails += `\n## ${path.relative(this.cwd, uri.fsPath)}` for (const diagnostic of problems) { // let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning" const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed @@ -3696,7 +3700,7 @@ export class Cline extends EventEmitter { } = (await this.providerRef.deref()?.getState()) ?? {} const currentMode = mode ?? defaultModeSlug const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { - cwd, + cwd: this.cwd, globalCustomInstructions, language: language ?? formatLanguage(vscode.env.language), }) @@ -3723,17 +3727,17 @@ export class Cline extends EventEmitter { } if (includeFileDetails) { - details += `\n\n# Current Working Directory (${cwd.toPosix()}) Files\n` - const isDesktop = arePathsEqual(cwd, path.join(os.homedir(), "Desktop")) + details += `\n\n# Current Working Directory (${this.cwd.toPosix()}) Files\n` + const isDesktop = arePathsEqual(this.cwd, path.join(os.homedir(), "Desktop")) if (isDesktop) { // don't want to immediately access desktop since it would show permission popup details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" } else { const maxFiles = maxWorkspaceFiles ?? 200 - const [files, didHitLimit] = await listFiles(cwd, true, maxFiles) + const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles) const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {} const result = formatResponse.formatFilesList( - cwd, + this.cwd, files, didHitLimit, this.rooIgnoreController, @@ -3768,7 +3772,7 @@ export class Cline extends EventEmitter { } try { - const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + const workspaceDir = getWorkspacePath() if (!workspaceDir) { log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints") diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 91b91c38ba..cb4759fca4 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -4,7 +4,7 @@ import * as fs from "fs/promises" import { CustomModesSettingsSchema } from "./CustomModesSchema" import { ModeConfig } from "../../shared/modes" import { fileExistsAtPath } from "../../utils/fs" -import { arePathsEqual } from "../../utils/path" +import { arePathsEqual, getWorkspacePath } from "../../utils/path" import { logger } from "../../utils/logging" const ROOMODES_FILENAME = ".roomodes" @@ -51,7 +51,7 @@ export class CustomModesManager { if (!workspaceFolders || workspaceFolders.length === 0) { return undefined } - const workspaceRoot = workspaceFolders[0].uri.fsPath + const workspaceRoot = getWorkspacePath() const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) const exists = await fileExistsAtPath(roomodesPath) return exists ? roomodesPath : undefined @@ -226,7 +226,7 @@ export class CustomModesManager { logger.error("Failed to update project mode: No workspace folder found", { slug }) throw new Error("No workspace folder found for project-specific mode") } - const workspaceRoot = workspaceFolders[0].uri.fsPath + const workspaceRoot = getWorkspacePath() targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) const exists = await fileExistsAtPath(targetPath) logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { diff --git a/src/core/config/__tests__/CustomModesManager.test.ts b/src/core/config/__tests__/CustomModesManager.test.ts index 4031bff906..300f1b7c0f 100644 --- a/src/core/config/__tests__/CustomModesManager.test.ts +++ b/src/core/config/__tests__/CustomModesManager.test.ts @@ -6,10 +6,12 @@ import * as fs from "fs/promises" import { CustomModesManager } from "../CustomModesManager" import { ModeConfig } from "../../../shared/modes" import { fileExistsAtPath } from "../../../utils/fs" +import { getWorkspacePath, arePathsEqual } from "../../../utils/path" jest.mock("vscode") jest.mock("fs/promises") jest.mock("../../../utils/fs") +jest.mock("../../../utils/path") describe("CustomModesManager", () => { let manager: CustomModesManager @@ -37,6 +39,7 @@ describe("CustomModesManager", () => { mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }] ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + ;(getWorkspacePath as jest.Mock).mockReturnValue("/mock/workspace") ;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => { return path === mockSettingsPath || path === mockRoomodes }) @@ -362,8 +365,11 @@ describe("CustomModesManager", () => { it("watches file for changes", async () => { const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json") - ;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] })) + ;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] })) + ;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => { + return path.normalize(path1) === path.normalize(path2) + }) // Get the registered callback const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0] expect(registerCall).toBeDefined() diff --git a/src/core/mentions/__tests__/index.test.ts b/src/core/mentions/__tests__/index.test.ts index 7a779d3d73..a85fe1f0a8 100644 --- a/src/core/mentions/__tests__/index.test.ts +++ b/src/core/mentions/__tests__/index.test.ts @@ -27,7 +27,13 @@ const mockVscode = { { uri: { fsPath: "/test/workspace" }, }, - ], + ] as { uri: { fsPath: string } }[] | undefined, + getWorkspaceFolder: jest.fn().mockReturnValue("/test/workspace"), + fs: { + stat: jest.fn(), + writeFile: jest.fn(), + }, + openTextDocument: jest.fn().mockResolvedValue({}), }, window: { showErrorMessage: mockShowErrorMessage, @@ -36,7 +42,14 @@ const mockVscode = { createTextEditorDecorationType: jest.fn(), createOutputChannel: jest.fn(), createWebviewPanel: jest.fn(), - activeTextEditor: undefined, + showTextDocument: jest.fn().mockResolvedValue({}), + activeTextEditor: undefined as + | undefined + | { + document: { + uri: { fsPath: string } + } + }, }, commands: { executeCommand: mockExecuteCommand, @@ -64,12 +77,16 @@ const mockVscode = { jest.mock("vscode", () => mockVscode) jest.mock("../../../services/browser/UrlContentFetcher") jest.mock("../../../utils/git") +jest.mock("../../../utils/path") // Now import the modules that use the mocks import { parseMentions, openMention } from "../index" import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher" import * as git from "../../../utils/git" +import { getWorkspacePath } from "../../../utils/path" +;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace") + describe("mentions", () => { const mockCwd = "/test/workspace" let mockUrlContentFetcher: UrlContentFetcher @@ -83,6 +100,15 @@ describe("mentions", () => { closeBrowser: jest.fn().mockResolvedValue(undefined), urlToMarkdown: jest.fn().mockResolvedValue(""), } as unknown as UrlContentFetcher + + // Reset all vscode mocks + mockVscode.workspace.fs.stat.mockReset() + mockVscode.workspace.fs.writeFile.mockReset() + mockVscode.workspace.openTextDocument.mockReset().mockResolvedValue({}) + mockVscode.window.showTextDocument.mockReset().mockResolvedValue({}) + mockVscode.window.showErrorMessage.mockReset() + mockExecuteCommand.mockReset() + mockOpenExternal.mockReset() }) describe("parseMentions", () => { @@ -122,11 +148,21 @@ Detailed commit message with multiple lines describe("openMention", () => { it("should handle file paths and problems", async () => { + // Mock stat to simulate file not existing + mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist")) + + // Call openMention and wait for it to complete await openMention("/path/to/file") + + // Verify error handling expect(mockExecuteCommand).not.toHaveBeenCalled() expect(mockOpenExternal).not.toHaveBeenCalled() - expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist") + expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist") + // Reset mocks for next test + jest.clearAllMocks() + + // Test problems command await openMention("problems") expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems") }) @@ -135,8 +171,8 @@ Detailed commit message with multiple lines const url = "https://example.com" await openMention(url) const mockUri = mockVscode.Uri.parse(url) - expect(mockOpenExternal).toHaveBeenCalled() - const calledArg = mockOpenExternal.mock.calls[0][0] + expect(mockVscode.env.openExternal).toHaveBeenCalled() + const calledArg = mockVscode.env.openExternal.mock.calls[0][0] expect(calledArg).toEqual( expect.objectContaining({ scheme: mockUri.scheme, diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index caa239168e..57bb40811e 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -9,13 +9,14 @@ import { isBinaryFile } from "isbinaryfile" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" import { getCommitInfo, getWorkingState } from "../../utils/git" import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output" +import { getWorkspacePath } from "../../utils/path" export async function openMention(mention?: string): Promise { if (!mention) { return } - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + const cwd = getWorkspacePath() if (!cwd) { return } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b748bddf44..050f63d195 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -63,6 +63,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { telemetryService } from "../../services/telemetry/TelemetryService" import { TelemetrySetting } from "../../shared/TelemetrySetting" +import { getWorkspacePath } from "../../utils/path" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -87,7 +88,9 @@ export class ClineProvider extends EventEmitter implements private contextProxy: ContextProxy configManager: ConfigManager customModesManager: CustomModesManager - + get cwd() { + return getWorkspacePath() + } constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -501,7 +504,7 @@ export class ClineProvider extends EventEmitter implements const taskId = historyItem.id const globalStorageDir = this.contextProxy.globalStorageUri.fsPath - const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" + const workspaceDir = this.cwd const checkpoints: Pick = { enableCheckpoints, @@ -1685,7 +1688,7 @@ export class ClineProvider extends EventEmitter implements } break case "searchCommits": { - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + const cwd = this.cwd if (cwd) { try { const commits = await searchCommits(message.query || "", cwd) @@ -1953,7 +1956,7 @@ export class ClineProvider extends EventEmitter implements fuzzyMatchThreshold, Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY), ) - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || "" + const cwd = this.cwd const mode = message.mode ?? defaultModeSlug const customModes = await this.customModesManager.getCustomModes() @@ -2301,13 +2304,10 @@ export class ClineProvider extends EventEmitter implements // delete task from the task history state await this.deleteTaskFromState(id) - // get the base directory of the project - const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - // Delete associated shadow repository or branch. // TODO: Store `workspaceDir` in the `HistoryItem` object. const globalStorageDir = this.contextProxy.globalStorageUri.fsPath - const workspaceDir = baseDir ?? "" + const workspaceDir = this.cwd try { await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir }) @@ -2395,7 +2395,7 @@ export class ClineProvider extends EventEmitter implements const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get("allowedCommands") || [] - const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || "" + const cwd = this.cwd return { version: this.context.extension?.packageJSON?.version ?? "", diff --git a/src/integrations/misc/open-file.ts b/src/integrations/misc/open-file.ts index daf36f1caa..5698e919de 100644 --- a/src/integrations/misc/open-file.ts +++ b/src/integrations/misc/open-file.ts @@ -1,7 +1,7 @@ import * as path from "path" import * as os from "os" import * as vscode from "vscode" -import { arePathsEqual } from "../../utils/path" +import { arePathsEqual, getWorkspacePath } from "../../utils/path" export async function openImage(dataUri: string) { const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/) @@ -28,7 +28,7 @@ interface OpenFileOptions { export async function openFile(filePath: string, options: OpenFileOptions = {}) { try { // Get workspace root - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + const workspaceRoot = getWorkspacePath() if (!workspaceRoot) { throw new Error("No workspace root found") } diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index 57c7f7f6f8..dbb6647e44 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -3,8 +3,9 @@ import * as path from "path" import { listFiles } from "../../services/glob/list-files" import { ClineProvider } from "../../core/webview/ClineProvider" import { toRelativePath } from "../../utils/path" +import { getWorkspacePath } from "../../utils/path" +import { logger } from "../../utils/logging" -const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) const MAX_INITIAL_FILES = 1_000 // 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 @@ -13,7 +14,12 @@ class WorkspaceTracker { private disposables: vscode.Disposable[] = [] private filePaths: Set = new Set() private updateTimer: NodeJS.Timeout | null = null + private prevWorkSpacePath: string | undefined + private resetTimer: NodeJS.Timeout | null = null + get cwd() { + return getWorkspacePath() + } constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) this.registerListeners() @@ -21,17 +27,21 @@ class WorkspaceTracker { async initializeFilePaths() { // 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 tempCwd = this.cwd + const [files, _] = await listFiles(tempCwd, true, MAX_INITIAL_FILES) + if (this.prevWorkSpacePath !== tempCwd) { return } - const [files, _] = await listFiles(cwd, true, MAX_INITIAL_FILES) files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file))) this.workspaceDidUpdate() } private registerListeners() { const watcher = vscode.workspace.createFileSystemWatcher("**") - + this.prevWorkSpacePath = this.cwd this.disposables.push( watcher.onDidCreate(async (uri) => { await this.addFilePath(uri.fsPath) @@ -50,7 +60,7 @@ class WorkspaceTracker { this.disposables.push(watcher) - this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate())) + this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidReset())) } private getOpenedTabsInfo() { @@ -62,23 +72,40 @@ class WorkspaceTracker { return { label: tab.label, isActive: tab.isActive, - path: toRelativePath(path, cwd || ""), + path: toRelativePath(path, this.cwd || ""), } }), ) } + private async workspaceDidReset() { + if (this.resetTimer) { + clearTimeout(this.resetTimer) + } + this.resetTimer = setTimeout(async () => { + if (this.prevWorkSpacePath !== this.cwd) { + await this.providerRef.deref()?.postMessageToWebview({ + type: "workspaceUpdated", + filePaths: [], + openedTabs: this.getOpenedTabsInfo(), + }) + this.filePaths.clear() + this.prevWorkSpacePath = this.cwd + this.initializeFilePaths() + } + }, 300) // Debounce for 300ms + } + private workspaceDidUpdate() { if (this.updateTimer) { clearTimeout(this.updateTimer) } - this.updateTimer = setTimeout(() => { - if (!cwd) { + if (!this.cwd) { return } - const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd)) + const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, this.cwd)) this.providerRef.deref()?.postMessageToWebview({ type: "workspaceUpdated", filePaths: relativeFilePaths, @@ -89,7 +116,7 @@ class WorkspaceTracker { } 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 } @@ -123,6 +150,10 @@ class WorkspaceTracker { clearTimeout(this.updateTimer) this.updateTimer = null } + if (this.resetTimer) { + clearTimeout(this.resetTimer) + this.resetTimer = null + } this.disposables.forEach((d) => d.dispose()) } } diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts index 47b678a7bb..5f7cf3d292 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.test.ts @@ -2,25 +2,40 @@ import * as vscode from "vscode" import WorkspaceTracker from "../WorkspaceTracker" import { ClineProvider } from "../../../core/webview/ClineProvider" import { listFiles } from "../../../services/glob/list-files" +import { getWorkspacePath } from "../../../utils/path" -// Mock modules +// Mock functions - must be defined before jest.mock calls const mockOnDidCreate = jest.fn() const mockOnDidDelete = jest.fn() -const mockOnDidChange = jest.fn() const mockDispose = jest.fn() +// Store registered tab change callback +let registeredTabChangeCallback: (() => Promise) | null = null + +// Mock workspace path +jest.mock("../../../utils/path", () => ({ + getWorkspacePath: jest.fn().mockReturnValue("/test/workspace"), + toRelativePath: jest.fn((path, cwd) => path.replace(`${cwd}/`, "")), +})) + +// Mock watcher - must be defined after mockDispose but before jest.mock("vscode") const mockWatcher = { onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }), onDidDelete: mockOnDidDelete.mockReturnValue({ dispose: mockDispose }), dispose: mockDispose, } +// Mock vscode jest.mock("vscode", () => ({ window: { tabGroups: { - onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })), + onDidChangeTabs: jest.fn((callback) => { + registeredTabChangeCallback = callback + return { dispose: mockDispose } + }), all: [], }, + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), }, workspace: { workspaceFolders: [ @@ -48,6 +63,12 @@ describe("WorkspaceTracker", () => { jest.clearAllMocks() jest.useFakeTimers() + // Reset all mock implementations + registeredTabChangeCallback = null + + // Reset workspace path mock + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace") + // Create provider mock mockProvider = { postMessageToWebview: jest.fn().mockResolvedValue(undefined), @@ -55,6 +76,9 @@ describe("WorkspaceTracker", () => { // Create tracker instance workspaceTracker = new WorkspaceTracker(mockProvider) + + // Ensure the tab change callback was registered + expect(registeredTabChangeCallback).not.toBeNull() }) it("should initialize with workspace files", async () => { @@ -159,8 +183,148 @@ describe("WorkspaceTracker", () => { }) it("should clean up watchers and timers on dispose", () => { + // Set up updateTimer + const [[callback]] = mockOnDidCreate.mock.calls + callback({ fsPath: "/test/workspace/file.ts" }) + workspaceTracker.dispose() expect(mockDispose).toHaveBeenCalled() jest.runAllTimers() // Ensure any pending timers are cleared + + // No more updates should happen after dispose + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("should handle workspace path changes when tabs change", async () => { + expect(registeredTabChangeCallback).not.toBeNull() + + // Set initial workspace path and create tracker + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace") + workspaceTracker = new WorkspaceTracker(mockProvider) + + // Clear any initialization calls + jest.clearAllMocks() + + // Mock listFiles to return some files + const mockFiles = [["/test/new-workspace/file1.ts"], false] + ;(listFiles as jest.Mock).mockResolvedValue(mockFiles) + + // Change workspace path + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace") + + // Simulate tab change event + await registeredTabChangeCallback!() + + // Run the debounce timer for workspaceDidReset + jest.advanceTimersByTime(300) + + // Should clear file paths and reset workspace + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "workspaceUpdated", + filePaths: [], + openedTabs: [], + }) + + // Run all remaining timers to complete initialization + await Promise.resolve() // Wait for initializeFilePaths to complete + jest.runAllTimers() + + // Should initialize file paths for new workspace + expect(listFiles).toHaveBeenCalledWith("/test/new-workspace", true, 1000) + jest.runAllTimers() + }) + + it("should not update file paths if workspace changes during initialization", async () => { + // Setup initial workspace path + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace") + workspaceTracker = new WorkspaceTracker(mockProvider) + + // Clear any initialization calls + jest.clearAllMocks() + ;(mockProvider.postMessageToWebview as jest.Mock).mockClear() + + // Create a promise to control listFiles timing + let resolveListFiles: (value: [string[], boolean]) => void + const listFilesPromise = new Promise<[string[], boolean]>((resolve) => { + resolveListFiles = resolve + }) + + // Setup listFiles to use our controlled promise + ;(listFiles as jest.Mock).mockImplementation(() => { + // Change workspace path before listFiles resolves + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/changed-workspace") + return listFilesPromise + }) + + // Start initialization + const initPromise = workspaceTracker.initializeFilePaths() + + // Resolve listFiles after workspace path change + resolveListFiles!([["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false]) + + // Wait for initialization to complete + await initPromise + jest.runAllTimers() + + // Should not update file paths because workspace changed during initialization + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + filePaths: ["/test/workspace/file1.ts", "/test/workspace/file2.ts"], + openedTabs: [], + type: "workspaceUpdated", + }) + }) + + it("should clear resetTimer when calling workspaceDidReset multiple times", async () => { + expect(registeredTabChangeCallback).not.toBeNull() + + // Set initial workspace path + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace") + + // Create tracker instance to set initial prevWorkSpacePath + workspaceTracker = new WorkspaceTracker(mockProvider) + + // Change workspace path to trigger update + ;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace") + + // Call workspaceDidReset through tab change event + await registeredTabChangeCallback!() + + // Call again before timer completes + await registeredTabChangeCallback!() + + // Advance timer + jest.advanceTimersByTime(300) + + // Should only have one call to postMessageToWebview + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "workspaceUpdated", + filePaths: [], + openedTabs: [], + }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + }) + + it("should handle dispose with active resetTimer", async () => { + expect(registeredTabChangeCallback).not.toBeNull() + + // Mock workspace path change to trigger resetTimer + ;(getWorkspacePath as jest.Mock) + .mockReturnValueOnce("/test/workspace") + .mockReturnValueOnce("/test/new-workspace") + + // Trigger resetTimer + await registeredTabChangeCallback!() + + // Dispose before timer completes + workspaceTracker.dispose() + + // Advance timer + jest.advanceTimersByTime(300) + + // Should have called dispose on all disposables + expect(mockDispose).toHaveBeenCalled() + + // No postMessage should be called after dispose + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) }) diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts index 8c8a8cc672..74856b5450 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -1,12 +1,37 @@ // npx jest src/utils/__tests__/path.test.ts - import os from "os" import * as path from "path" -import { arePathsEqual, getReadablePath } from "../path" +import { arePathsEqual, getReadablePath, getWorkspacePath } from "../path" +// Mock modules + +jest.mock("vscode", () => ({ + window: { + activeTextEditor: { + document: { + uri: { fsPath: "/test/workspaceFolder/file.ts" }, + }, + }, + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + name: "test", + index: 0, + }, + ], + getWorkspaceFolder: jest.fn().mockReturnValue({ + uri: { + fsPath: "/test/workspaceFolder", + }, + }), + }, +})) describe("Path Utilities", () => { const originalPlatform = process.platform + // Helper to mock VS Code configuration afterEach(() => { Object.defineProperty(process, "platform", { @@ -30,7 +55,14 @@ describe("Path Utilities", () => { expect(extendedPath.toPosix()).toBe("\\\\?\\C:\\Very\\Long\\Path") }) }) + describe("getWorkspacePath", () => { + it("should return the current workspace path", () => { + const workspacePath = "/Users/test/project" + expect(getWorkspacePath(workspacePath)).toBe("/test/workspaceFolder") + }) + it("should return undefined when outside a workspace", () => {}) + }) describe("arePathsEqual", () => { describe("on Windows", () => { beforeEach(() => { diff --git a/src/utils/path.ts b/src/utils/path.ts index a15d4e0910..a58d630172 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,5 +1,6 @@ import * as path from "path" import os from "os" +import * as vscode from "vscode" /* The Node.js 'path' module resolves and normalizes paths differently depending on the platform: @@ -104,3 +105,13 @@ export const toRelativePath = (filePath: string, cwd: string) => { const relativePath = path.relative(cwd, filePath).toPosix() return filePath.endsWith("/") ? relativePath + "/" : relativePath } + +export const getWorkspacePath = (defaultCwdPath = "") => { + const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath + const currentFileUri = vscode.window.activeTextEditor?.document.uri + if (currentFileUri) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri) + return workspaceFolder?.uri.fsPath || cwdPath + } + return cwdPath +}