diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..21d761cfb1 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -96,8 +96,18 @@ export class CustomModesManager { return undefined } - const workspaceRoot = getWorkspacePath() - const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) + // Check if .roo is a workspace folder itself + const rooWorkspaceFolder = workspaceFolders.find((folder) => path.basename(folder.uri.fsPath) === ".roo") + + let roomodesPath: string + if (rooWorkspaceFolder) { + // .roo is a workspace folder itself, look for .roomodes directly in it + roomodesPath = path.join(rooWorkspaceFolder.uri.fsPath, ROOMODES_FILENAME.replace(".roo/", "")) + } else { + // Use the current workspace path (which considers the active file's workspace) + const workspaceRoot = getWorkspacePath() + roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) + } const exists = await fileExistsAtPath(roomodesPath) return exists ? roomodesPath : undefined } diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index a57dfcb6d4..7572a8ef97 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -274,20 +274,41 @@ async function getFileOrFolderContent( maxReadFileLine?: number, ): Promise { const unescapedPath = unescapeSpaces(mentionPath) - const absPath = path.resolve(cwd, unescapedPath) + + // Check if this is an absolute path from a different workspace folder + let absPath: string + let displayPath: string = mentionPath + + if (path.isAbsolute(unescapedPath)) { + absPath = unescapedPath + + // In multi-folder workspaces, convert absolute paths to relative paths + // based on the workspace folder they belong to + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(absPath)) + if (workspaceFolder) { + // Use relative path from the workspace folder + const relativePath = path.relative(workspaceFolder.uri.fsPath, absPath) + displayPath = path.join(workspaceFolder.name, relativePath).toPosix() + } + } + } else { + absPath = path.resolve(cwd, unescapedPath) + displayPath = mentionPath + } try { const stats = await fs.stat(absPath) if (stats.isFile()) { if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) { - return `(File ${mentionPath} is ignored by .rooignore)` + return `(File ${displayPath} is ignored by .rooignore)` } try { const content = await extractTextFromFile(absPath, maxReadFileLine) return content } catch (error) { - return `(Failed to read contents of ${mentionPath}): ${error.message}` + return `(Failed to read contents of ${displayPath}): ${error.message}` } } else if (stats.isDirectory()) { const entries = await fs.readdir(absPath, { withFileTypes: true }) @@ -315,8 +336,24 @@ async function getFileOrFolderContent( if (entry.isFile()) { folderContent += `${linePrefix}${displayName}\n` if (!isIgnored) { - const filePath = path.join(mentionPath, entry.name) const absoluteFilePath = path.resolve(absPath, entry.name) + + // Determine the display path for the file + let fileDisplayPath: string + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder( + vscode.Uri.file(absoluteFilePath), + ) + if (workspaceFolder) { + const relativePath = path.relative(workspaceFolder.uri.fsPath, absoluteFilePath) + fileDisplayPath = path.join(workspaceFolder.name, relativePath).toPosix() + } else { + fileDisplayPath = path.join(displayPath, entry.name).toPosix() + } + } else { + fileDisplayPath = path.join(displayPath, entry.name).toPosix() + } + fileContentPromises.push( (async () => { try { @@ -325,7 +362,7 @@ async function getFileOrFolderContent( return undefined } const content = await extractTextFromFile(absoluteFilePath, maxReadFileLine) - return `\n${content}\n` + return `\n${content}\n` } catch (error) { return undefined } @@ -341,10 +378,10 @@ async function getFileOrFolderContent( const fileContents = (await Promise.all(fileContentPromises)).filter((content) => content) return `${folderContent}\n${fileContents.join("\n\n")}`.trim() } else { - return `(Failed to read contents of ${mentionPath})` + return `(Failed to read contents of ${displayPath})` } } catch (error) { - throw new Error(`Failed to access path "${mentionPath}": ${error.message}`) + throw new Error(`Failed to access path "${displayPath}": ${error.message}`) } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 5e96b6fb16..49fa9e7494 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -279,10 +279,23 @@ export class Task extends EventEmitter implements TaskLike { this.taskId = historyItem ? historyItem.id : crypto.randomUUID() - // Normal use-case is usually retry similar history task with new workspace. - this.workspacePath = parentTask - ? parentTask.workspacePath - : getWorkspacePath(path.join(os.homedir(), "Desktop")) + // Store the initial workspace path when the task is created + // In multi-folder workspaces, we want to maintain the same workspace context throughout the task + if (parentTask) { + // Inherit workspace from parent task + this.workspacePath = parentTask.workspacePath + } else { + // Determine workspace based on current context + // If there's an active editor, use its workspace folder + // Otherwise, use the first workspace folder or fallback to Desktop + const currentFileUri = vscode.window.activeTextEditor?.document.uri + if (currentFileUri) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri) + this.workspacePath = workspaceFolder?.uri.fsPath || getWorkspacePath(path.join(os.homedir(), "Desktop")) + } else { + this.workspacePath = getWorkspacePath(path.join(os.homedir(), "Desktop")) + } + } this.instanceId = crypto.randomUUID().slice(0, 8) this.taskNumber = -1 diff --git a/src/core/task/__tests__/multi-folder-workspace.spec.ts b/src/core/task/__tests__/multi-folder-workspace.spec.ts new file mode 100644 index 0000000000..a9dbfbaddf --- /dev/null +++ b/src/core/task/__tests__/multi-folder-workspace.spec.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import * as path from "path" +import * as os from "os" +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +// Mock vscode module +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [], + getWorkspaceFolder: vi.fn(), + createFileSystemWatcher: vi.fn(() => ({ + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + })), + }, + window: { + activeTextEditor: undefined, + showErrorMessage: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({ + dispose: vi.fn(), + })), + }, + Uri: { + file: vi.fn((path) => ({ fsPath: path })), + }, + RelativePattern: vi.fn(), + DiagnosticSeverity: { + Error: 0, + Warning: 1, + Information: 2, + Hint: 3, + }, + languages: { + getDiagnostics: vi.fn(() => []), + }, +})) + +// Mock other dependencies +vi.mock("../../webview/ClineProvider") +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/default/workspace"), +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../../integrations/editor/DecorationController") +vi.mock("../../../services/browser/UrlContentFetcher") +vi.mock("../../../integrations/terminal/TerminalRegistry") +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn(() => Promise.resolve(false)), +})) +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(() => ({ + getModel: vi.fn(() => ({ info: {} })), + })), +})) +vi.mock("../../tools/ToolRepetitionDetector", () => ({ + ToolRepetitionDetector: vi.fn().mockImplementation(() => ({})), +})) +vi.mock("../../tools/AutoApprovalHandler", () => ({ + AutoApprovalHandler: vi.fn().mockImplementation(() => ({})), +})) + +describe("Multi-folder Workspace Support", () => { + let mockProvider: ClineProvider + + beforeEach(() => { + mockProvider = { + context: { + globalStorageUri: { fsPath: "/global/storage" }, + }, + } as any + + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("Task workspace path initialization", () => { + it("should use the active editor's workspace folder in multi-folder workspace", () => { + // Setup multi-folder workspace + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Set active editor in backend folder + const activeFileUri = { fsPath: "/workspace/backend/src/index.ts" } + ;(vscode.window as any).activeTextEditor = { + document: { uri: activeFileUri }, + } + ;(vscode.workspace.getWorkspaceFolder as any).mockReturnValue(workspaceFolders[1]) + + // Create task + const task = new Task({ + provider: mockProvider, + apiConfiguration: {} as any, + task: "Test task", + startTask: false, + }) + + // Should use backend workspace folder + expect(task.workspacePath).toBe("/workspace/backend") + }) + + it("should maintain consistent workspace path throughout task lifetime", () => { + // Setup multi-folder workspace + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Set active editor in frontend folder + const activeFileUri = { fsPath: "/workspace/frontend/src/App.tsx" } + ;(vscode.window as any).activeTextEditor = { + document: { uri: activeFileUri }, + } + ;(vscode.workspace.getWorkspaceFolder as any).mockReturnValue(workspaceFolders[0]) + + // Create task + const task = new Task({ + provider: mockProvider, + apiConfiguration: {} as any, + task: "Test task", + startTask: false, + }) + + const initialWorkspacePath = task.workspacePath + expect(initialWorkspacePath).toBe("/workspace/frontend") + + // Change active editor to backend folder + ;(vscode.window as any).activeTextEditor = { + document: { uri: { fsPath: "/workspace/backend/src/index.ts" } }, + } + ;(vscode.workspace.getWorkspaceFolder as any).mockReturnValue(workspaceFolders[1]) + + // Workspace path should remain the same + expect(task.workspacePath).toBe(initialWorkspacePath) + expect(task.cwd).toBe(initialWorkspacePath) + }) + + it("should inherit workspace path from parent task", () => { + const parentTask = { + workspacePath: "/workspace/parent", + } as any + + const task = new Task({ + provider: mockProvider, + apiConfiguration: {} as any, + task: "Child task", + parentTask, + startTask: false, + }) + + expect(task.workspacePath).toBe("/workspace/parent") + }) + + it("should fallback to first workspace folder when no active editor", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + ;(vscode.window as any).activeTextEditor = undefined + + // Mock getWorkspacePath to return first folder + const { getWorkspacePath } = require("../../../utils/path") + getWorkspacePath.mockReturnValue("/workspace/frontend") + + const task = new Task({ + provider: mockProvider, + apiConfiguration: {} as any, + task: "Test task", + startTask: false, + }) + + expect(task.workspacePath).toBe("/workspace/frontend") + }) + }) + + describe(".roo folder detection", () => { + it("should detect .roo folder as a workspace folder", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/src" }, name: "src" }, + { uri: { fsPath: "/workspace/.roo" }, name: ".roo" }, + { uri: { fsPath: "/workspace/docs" }, name: "docs" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Check if .roo is detected as a workspace folder + const rooWorkspaceFolder = workspaceFolders.find((folder) => path.basename(folder.uri.fsPath) === ".roo") + + expect(rooWorkspaceFolder).toBeDefined() + expect(rooWorkspaceFolder?.uri.fsPath).toBe("/workspace/.roo") + }) + + it("should use .roo subfolder in active workspace folder", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Set active editor in backend folder + const activeFileUri = { fsPath: "/workspace/backend/src/index.ts" } + ;(vscode.window as any).activeTextEditor = { + document: { uri: activeFileUri }, + } + ;(vscode.workspace.getWorkspaceFolder as any).mockReturnValue(workspaceFolders[1]) + + // Determine .roo folder path + const targetWorkspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(activeFileUri.fsPath)) + const rooPath = path.join(targetWorkspaceFolder!.uri.fsPath, ".roo") + + expect(rooPath).toBe("/workspace/backend/.roo") + }) + }) + + describe("File mention paths in multi-folder workspace", () => { + it("should convert absolute paths to workspace-relative paths", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Mock getWorkspaceFolder to return the correct folder + ;(vscode.workspace.getWorkspaceFolder as any).mockImplementation((uri: any) => { + const filePath = uri.fsPath + if (filePath.startsWith("/workspace/frontend")) { + return workspaceFolders[0] + } else if (filePath.startsWith("/workspace/backend")) { + return workspaceFolders[1] + } + return undefined + }) + + // Test absolute path from backend folder + const absolutePath = "/workspace/backend/src/api/server.ts" + const fileUri = vscode.Uri.file(absolutePath) + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri) + + expect(workspaceFolder).toBeDefined() + expect(workspaceFolder?.name).toBe("backend") + + // Convert to relative path + const relativePath = path.relative(workspaceFolder!.uri.fsPath, absolutePath) + const displayPath = path.join(workspaceFolder!.name, relativePath) + + expect(displayPath).toBe("backend/src/api/server.ts") + }) + + it("should handle file mentions across different workspace folders", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/frontend" }, name: "frontend" }, + { uri: { fsPath: "/workspace/backend" }, name: "backend" }, + ] + ;(vscode.workspace as any).workspaceFolders = workspaceFolders + + // Mock getWorkspaceFolder + ;(vscode.workspace.getWorkspaceFolder as any).mockImplementation((uri: any) => { + const filePath = uri.fsPath + if (filePath.startsWith("/workspace/frontend")) { + return workspaceFolders[0] + } else if (filePath.startsWith("/workspace/backend")) { + return workspaceFolders[1] + } + return undefined + }) + + // Test files from different folders + const frontendFile = "/workspace/frontend/src/App.tsx" + const backendFile = "/workspace/backend/src/server.ts" + + // Get workspace folders for each file + const frontendFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(frontendFile)) + const backendFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(backendFile)) + + // Convert to display paths + const frontendDisplayPath = path.join( + frontendFolder!.name, + path.relative(frontendFolder!.uri.fsPath, frontendFile), + ) + const backendDisplayPath = path.join( + backendFolder!.name, + path.relative(backendFolder!.uri.fsPath, backendFile), + ) + + expect(frontendDisplayPath).toBe("frontend/src/App.tsx") + expect(backendDisplayPath).toBe("backend/src/server.ts") + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f5dc6a467f..d726c7d43f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -800,9 +800,33 @@ export const webviewMessageHandler = async ( return } - const workspaceFolder = vscode.workspace.workspaceFolders[0] - const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo") - const mcpPath = path.join(rooDir, "mcp.json") + // Check if .roo is a workspace folder itself + const rooWorkspaceFolder = vscode.workspace.workspaceFolders.find( + (folder) => path.basename(folder.uri.fsPath) === ".roo", + ) + + let rooDir: string + let mcpPath: string + + if (rooWorkspaceFolder) { + // .roo is a workspace folder itself + rooDir = rooWorkspaceFolder.uri.fsPath + mcpPath = path.join(rooDir, "mcp.json") + } else { + // Use the workspace folder of the active file, or fall back to first workspace folder + const activeFileUri = vscode.window.activeTextEditor?.document.uri + let targetWorkspaceFolder = vscode.workspace.workspaceFolders[0] + + if (activeFileUri) { + const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeFileUri) + if (activeWorkspaceFolder) { + targetWorkspaceFolder = activeWorkspaceFolder + } + } + + rooDir = path.join(targetWorkspaceFolder.uri.fsPath, ".roo") + mcpPath = path.join(rooDir, "mcp.json") + } try { await fs.mkdir(rooDir, { recursive: true }) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 271c6e1fb3..a94c71eda5 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -347,6 +347,20 @@ export class McpHub { return } + // Watch for mcp.json in all workspace folders + for (const workspaceFolder of vscode.workspace.workspaceFolders) { + // Check if .roo is a workspace folder itself + if (path.basename(workspaceFolder.uri.fsPath) === ".roo") { + const projectMcpPattern = new vscode.RelativePattern(workspaceFolder, "mcp.json") + this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher(projectMcpPattern) + } else { + // Watch for .roo/mcp.json in regular workspace folders + const projectMcpPattern = new vscode.RelativePattern(workspaceFolder, ".roo/mcp.json") + this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher(projectMcpPattern) + } + } + + // Use the first watcher if multiple were created (for simplicity) const workspaceFolder = vscode.workspace.workspaceFolders[0] const projectMcpPattern = new vscode.RelativePattern(workspaceFolder, ".roo/mcp.json") @@ -553,8 +567,31 @@ export class McpHub { return null } - const workspaceFolder = vscode.workspace.workspaceFolders[0] - const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo") + // Check if there's a .roo folder in any workspace folder + // First, check if there's a .roo folder that is itself a workspace folder + const rooWorkspaceFolder = vscode.workspace.workspaceFolders.find( + (folder) => path.basename(folder.uri.fsPath) === ".roo", + ) + + if (rooWorkspaceFolder) { + // .roo is a workspace folder itself + const projectMcpPath = path.join(rooWorkspaceFolder.uri.fsPath, "mcp.json") + return projectMcpPath + } + + // Otherwise, look for .roo subfolder in workspace folders + // Prioritize the workspace folder containing the active file + const activeFileUri = vscode.window.activeTextEditor?.document.uri + let targetWorkspaceFolder = vscode.workspace.workspaceFolders[0] + + if (activeFileUri) { + const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeFileUri) + if (activeWorkspaceFolder) { + targetWorkspaceFolder = activeWorkspaceFolder + } + } + + const projectMcpDir = path.join(targetWorkspaceFolder.uri.fsPath, ".roo") const projectMcpPath = path.join(projectMcpDir, "mcp.json") try {