feat: improve multi-folder workspace support

- Fix Task to store initial workspace folder and use it consistently throughout task lifetime
- Fix file mentions to use relative paths in multi-folder workspaces
- Fix .roo folder detection to check if .roo is a workspace folder itself
- Add support for workspace folder selection based on active editor
- Add tests for multi-folder workspace functionality

Fixes #6897 and #6720
This commit is contained in:
Roo Code 2025-08-10 13:37:48 +00:00
parent f53fd39014
commit 27a05f8d16
6 changed files with 441 additions and 18 deletions

View file

@ -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
}

View file

@ -274,20 +274,41 @@ async function getFileOrFolderContent(
maxReadFileLine?: number,
): Promise<string> {
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 `<file_content path="${filePath.toPosix()}">\n${content}\n</file_content>`
return `<file_content path="${fileDisplayPath}">\n${content}\n</file_content>`
} 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}`)
}
}

View file

@ -279,10 +279,23 @@ export class Task extends EventEmitter<TaskEvents> 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

View file

@ -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")
})
})
})

View file

@ -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 })

View file

@ -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 {