feat: add multi-root workspace support

- Updated getWorkspacePath to properly handle multi-root workspaces
- Added getAllWorkspacePaths and getWorkspaceFolderForPath utility functions
- Fixed isPathOutsideWorkspace to correctly check all workspace folders
- Updated Task class to support multiple workspace roots
- Modified environment details to show files from all workspace folders
- Added comprehensive tests for multi-root workspace scenarios

Fixes #8041
This commit is contained in:
Roo Code 2025-09-16 23:09:07 +00:00
parent 2263d86a20
commit 49acdc9a97
6 changed files with 560 additions and 47 deletions

View file

@ -238,32 +238,69 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
}
if (includeFileDetails) {
details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
// Check if we have multiple workspace folders
const workspaceFolders = vscode.workspace.workspaceFolders
const isMultiRoot = workspaceFolders && workspaceFolders.length > 1
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.)"
if (isMultiRoot) {
// For multi-root workspaces, show files from all workspace folders
details += `\n\n# Multi-Root Workspace Files\n`
const maxFilesPerFolder = Math.floor((maxWorkspaceFiles ?? 200) / workspaceFolders.length)
for (const folder of workspaceFolders) {
const folderPath = folder.uri.fsPath
const folderName = folder.name
details += `\n## ${folderName} (${folderPath.toPosix()})\n`
const isDesktop = arePathsEqual(folderPath, path.join(os.homedir(), "Desktop"))
if (isDesktop) {
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)\n"
} else if (maxFilesPerFolder === 0) {
details += "(Workspace files context disabled. Use list_files to explore if needed.)\n"
} else {
const [files, didHitLimit] = await listFiles(folderPath, true, maxFilesPerFolder)
const { showRooIgnoredFiles = false } = state ?? {}
const result = formatResponse.formatFilesList(
folderPath,
files,
didHitLimit,
cline.rooIgnoreController,
showRooIgnoredFiles,
)
details += result + "\n"
}
}
} else {
const maxFiles = maxWorkspaceFiles ?? 200
// Single workspace folder - use existing logic
details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
// Early return for limit of 0
if (maxFiles === 0) {
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
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 [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
const { showRooIgnoredFiles = false } = state ?? {}
const maxFiles = maxWorkspaceFiles ?? 200
const result = formatResponse.formatFilesList(
cline.cwd,
files,
didHitLimit,
cline.rooIgnoreController,
showRooIgnoredFiles,
)
// Early return for limit of 0
if (maxFiles === 0) {
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
} else {
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
const { showRooIgnoredFiles = false } = state ?? {}
details += result
const result = formatResponse.formatFilesList(
cline.cwd,
files,
didHitLimit,
cline.rooIgnoreController,
showRooIgnoredFiles,
)
details += result
}
}
}
}

View file

@ -2909,6 +2909,33 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return this.workspacePath
}
/**
* Get all workspace folders for multi-root workspace support
* @returns Array of all workspace folder paths
*/
public get workspaceFolders(): string[] {
const folders = vscode.workspace.workspaceFolders
if (!folders || folders.length === 0) {
return [this.workspacePath]
}
return folders.map((folder) => folder.uri.fsPath)
}
/**
* Check if a path is within any of the workspace folders
* @param filePath The file path to check
* @returns true if the path is in a workspace folder, false otherwise
*/
public isPathInWorkspace(filePath: string): boolean {
const absolutePath = path.resolve(filePath)
const normalizedPath = path.normalize(absolutePath)
return this.workspaceFolders.some((folderPath) => {
const normalizedFolderPath = path.normalize(folderPath)
return normalizedPath === normalizedFolderPath || normalizedPath.startsWith(normalizedFolderPath + path.sep)
})
}
/**
* Process any queued messages by dequeuing and submitting them.
* This ensures that queued user messages are sent when appropriate,

View file

@ -3,37 +3,62 @@
import os from "os"
import * as path from "path"
import { arePathsEqual, getReadablePath, getWorkspacePath } from "../path"
import {
arePathsEqual,
getReadablePath,
getWorkspacePath,
getAllWorkspacePaths,
getWorkspaceFolderForPath,
} from "../path"
// Mock modules
const mockWorkspaceFolders = vi.fn()
const mockGetWorkspaceFolder = vi.fn()
const mockActiveTextEditor = vi.fn()
vi.mock("vscode", () => ({
window: {
activeTextEditor: {
document: {
uri: { fsPath: "/test/workspaceFolder/file.ts" },
},
get activeTextEditor() {
return mockActiveTextEditor()
},
},
workspace: {
workspaceFolders: [
{
uri: { fsPath: "/test/workspace" },
name: "test",
index: 0,
},
],
getWorkspaceFolder: vi.fn().mockReturnValue({
uri: {
fsPath: "/test/workspaceFolder",
},
}),
get workspaceFolders() {
return mockWorkspaceFolders()
},
getWorkspaceFolder: mockGetWorkspaceFolder,
},
}))
describe("Path Utilities", () => {
const originalPlatform = process.platform
// Helper to mock VS Code configuration
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks()
// Set default mock values
mockWorkspaceFolders.mockReturnValue([
{
uri: { fsPath: "/test/workspace" },
name: "test",
index: 0,
},
])
mockActiveTextEditor.mockReturnValue({
document: {
uri: { fsPath: "/test/workspaceFolder/file.ts" },
},
})
mockGetWorkspaceFolder.mockReturnValue({
uri: {
fsPath: "/test/workspaceFolder",
},
})
})
afterEach(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
@ -56,14 +81,129 @@ 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("/Users/test/project")
it("should return the workspace folder of the active editor", () => {
mockActiveTextEditor.mockReturnValue({
document: {
uri: { fsPath: "/test/workspaceFolder/file.ts" },
},
})
mockGetWorkspaceFolder.mockReturnValue({
uri: { fsPath: "/test/workspaceFolder" },
})
mockWorkspaceFolders.mockReturnValue([{ uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }])
expect(getWorkspacePath()).toBe("/test/workspaceFolder")
})
it("should return undefined when outside a workspace", () => {})
it("should return the first workspace folder when no active editor", () => {
mockActiveTextEditor.mockReturnValue(undefined)
mockGetWorkspaceFolder.mockReturnValue(undefined)
mockWorkspaceFolders.mockReturnValue([
{ uri: { fsPath: "/test/workspace1" }, name: "workspace1", index: 0 },
{ uri: { fsPath: "/test/workspace2" }, name: "workspace2", index: 1 },
])
expect(getWorkspacePath()).toBe("/test/workspace1")
})
it("should return default path when no workspace folders", () => {
mockActiveTextEditor.mockReturnValue(undefined)
mockGetWorkspaceFolder.mockReturnValue(undefined)
mockWorkspaceFolders.mockReturnValue(undefined)
expect(getWorkspacePath("/default/path")).toBe("/default/path")
})
it("should handle multi-root workspaces correctly", () => {
mockWorkspaceFolders.mockReturnValue([
{ uri: { fsPath: "/test/frontend" }, name: "frontend", index: 0 },
{ uri: { fsPath: "/test/backend" }, name: "backend", index: 1 },
])
// When active editor is in backend folder
mockActiveTextEditor.mockReturnValue({
document: { uri: { fsPath: "/test/backend/src/app.ts" } },
})
mockGetWorkspaceFolder.mockReturnValue({
uri: { fsPath: "/test/backend" },
})
expect(getWorkspacePath()).toBe("/test/backend")
})
})
describe("getAllWorkspacePaths", () => {
it("should return all workspace folder paths", () => {
mockWorkspaceFolders.mockReturnValue([
{ uri: { fsPath: "/test/frontend" }, name: "frontend", index: 0 },
{ uri: { fsPath: "/test/backend" }, name: "backend", index: 1 },
])
const paths = getAllWorkspacePaths()
expect(paths).toEqual(["/test/frontend", "/test/backend"])
})
it("should return empty array when no workspace folders", () => {
mockWorkspaceFolders.mockReturnValue(undefined)
const paths = getAllWorkspacePaths()
expect(paths).toEqual([])
})
it("should handle single workspace folder", () => {
mockWorkspaceFolders.mockReturnValue([{ uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 }])
const paths = getAllWorkspacePaths()
expect(paths).toEqual(["/test/workspace"])
})
})
describe("getWorkspaceFolderForPath", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([
{ uri: { fsPath: "/test/frontend" }, name: "frontend", index: 0 },
{ uri: { fsPath: "/test/backend" }, name: "backend", index: 1 },
])
})
it("should return the workspace folder containing the path", () => {
expect(getWorkspaceFolderForPath("/test/frontend/src/app.ts")).toBe("/test/frontend")
expect(getWorkspaceFolderForPath("/test/backend/src/server.ts")).toBe("/test/backend")
})
it("should return the workspace folder for exact match", () => {
expect(getWorkspaceFolderForPath("/test/frontend")).toBe("/test/frontend")
expect(getWorkspaceFolderForPath("/test/backend")).toBe("/test/backend")
})
it("should return null for paths outside any workspace", () => {
expect(getWorkspaceFolderForPath("/other/path/file.ts")).toBeNull()
expect(getWorkspaceFolderForPath("/test/other/file.ts")).toBeNull()
})
it("should return null when no workspace folders", () => {
mockWorkspaceFolders.mockReturnValue(undefined)
expect(getWorkspaceFolderForPath("/test/frontend/src/app.ts")).toBeNull()
})
it("should handle relative paths by resolving them", () => {
// This depends on the current working directory, so we'll use path.resolve
const resolvedPath = path.resolve("./src/app.ts")
const result = getWorkspaceFolderForPath("./src/app.ts")
// The result should be based on the resolved path
if (resolvedPath.startsWith("/test/frontend")) {
expect(result).toBe("/test/frontend")
} else if (resolvedPath.startsWith("/test/backend")) {
expect(result).toBe("/test/backend")
} else {
expect(result).toBeNull()
}
})
})
describe("arePathsEqual", () => {
describe("on Windows", () => {
beforeEach(() => {

View file

@ -0,0 +1,233 @@
// npx vitest utils/__tests__/pathUtils.spec.ts
import * as path from "path"
import { isPathOutsideWorkspace, getContainingWorkspaceFolder } from "../pathUtils"
// Mock vscode module
const mockWorkspaceFolders = vi.fn()
vi.mock("vscode", () => ({
workspace: {
get workspaceFolders() {
return mockWorkspaceFolders()
},
},
WorkspaceFolder: class {
constructor(
public uri: { fsPath: string },
public name: string,
public index: number,
) {}
},
}))
describe("pathUtils", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("isPathOutsideWorkspace", () => {
describe("single workspace folder", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([
{
uri: { fsPath: "/test/workspace" },
name: "workspace",
index: 0,
},
])
})
it("should return false for paths inside the workspace", () => {
expect(isPathOutsideWorkspace("/test/workspace")).toBe(false)
expect(isPathOutsideWorkspace("/test/workspace/src/file.ts")).toBe(false)
expect(isPathOutsideWorkspace("/test/workspace/nested/deep/file.ts")).toBe(false)
})
it("should return true for paths outside the workspace", () => {
expect(isPathOutsideWorkspace("/test/other")).toBe(true)
expect(isPathOutsideWorkspace("/other/path/file.ts")).toBe(true)
expect(isPathOutsideWorkspace("/test")).toBe(true) // Parent directory
})
it("should handle relative paths by resolving them", () => {
const originalCwd = process.cwd()
// Mock process.cwd to be inside workspace
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/test/workspace/src")
expect(isPathOutsideWorkspace("./file.ts")).toBe(false) // Resolves to /test/workspace/src/file.ts
expect(isPathOutsideWorkspace("../file.ts")).toBe(false) // Resolves to /test/workspace/file.ts
expect(isPathOutsideWorkspace("../../other/file.ts")).toBe(true) // Resolves to /test/other/file.ts
cwdSpy.mockRestore()
})
})
describe("multi-root workspace", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([
{
uri: { fsPath: "/test/frontend" },
name: "frontend",
index: 0,
},
{
uri: { fsPath: "/test/backend" },
name: "backend",
index: 1,
},
])
})
it("should return false for paths in any workspace folder", () => {
// Frontend paths
expect(isPathOutsideWorkspace("/test/frontend")).toBe(false)
expect(isPathOutsideWorkspace("/test/frontend/src/app.ts")).toBe(false)
// Backend paths
expect(isPathOutsideWorkspace("/test/backend")).toBe(false)
expect(isPathOutsideWorkspace("/test/backend/src/server.ts")).toBe(false)
})
it("should return true for paths outside all workspace folders", () => {
expect(isPathOutsideWorkspace("/test/other")).toBe(true)
expect(isPathOutsideWorkspace("/other/path")).toBe(true)
expect(isPathOutsideWorkspace("/test")).toBe(true) // Parent of both workspaces
})
it("should handle paths between workspace folders correctly", () => {
// This is the key test for the bug fix
// Paths in sibling workspace folders should NOT be considered outside
expect(isPathOutsideWorkspace("/test/frontend/src/app.ts")).toBe(false)
expect(isPathOutsideWorkspace("/test/backend/src/server.ts")).toBe(false)
// But paths that are truly outside should still be blocked
expect(isPathOutsideWorkspace("/test/shared/lib.ts")).toBe(true)
})
})
describe("no workspace folders", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue(undefined)
})
it("should return true for all paths when no workspace", () => {
expect(isPathOutsideWorkspace("/any/path")).toBe(true)
expect(isPathOutsideWorkspace("./relative/path")).toBe(true)
expect(isPathOutsideWorkspace("/test/workspace/file.ts")).toBe(true)
})
})
describe("empty workspace folders array", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([])
})
it("should return true for all paths when workspace array is empty", () => {
expect(isPathOutsideWorkspace("/any/path")).toBe(true)
expect(isPathOutsideWorkspace("./relative/path")).toBe(true)
})
})
describe("path normalization", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([
{
uri: { fsPath: "/test/workspace" },
name: "workspace",
index: 0,
},
])
})
it("should normalize paths with .. and .", () => {
expect(isPathOutsideWorkspace("/test/workspace/../workspace/src/file.ts")).toBe(false)
expect(isPathOutsideWorkspace("/test/workspace/./src/file.ts")).toBe(false)
expect(isPathOutsideWorkspace("/test/workspace/src/../src/file.ts")).toBe(false)
})
it("should handle trailing slashes", () => {
expect(isPathOutsideWorkspace("/test/workspace/")).toBe(false)
expect(isPathOutsideWorkspace("/test/workspace/src/")).toBe(false)
})
})
})
describe("getContainingWorkspaceFolder", () => {
describe("single workspace folder", () => {
const workspaceFolder = {
uri: { fsPath: "/test/workspace" },
name: "workspace",
index: 0,
}
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([workspaceFolder])
})
it("should return the workspace folder for paths inside it", () => {
expect(getContainingWorkspaceFolder("/test/workspace")).toEqual(workspaceFolder)
expect(getContainingWorkspaceFolder("/test/workspace/src/file.ts")).toEqual(workspaceFolder)
})
it("should return undefined for paths outside the workspace", () => {
expect(getContainingWorkspaceFolder("/test/other")).toBeUndefined()
expect(getContainingWorkspaceFolder("/other/path")).toBeUndefined()
})
})
describe("multi-root workspace", () => {
const frontendFolder = {
uri: { fsPath: "/test/frontend" },
name: "frontend",
index: 0,
}
const backendFolder = {
uri: { fsPath: "/test/backend" },
name: "backend",
index: 1,
}
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([frontendFolder, backendFolder])
})
it("should return the correct workspace folder for each path", () => {
expect(getContainingWorkspaceFolder("/test/frontend/src/app.ts")).toEqual(frontendFolder)
expect(getContainingWorkspaceFolder("/test/backend/src/server.ts")).toEqual(backendFolder)
})
it("should return the exact workspace folder for root paths", () => {
expect(getContainingWorkspaceFolder("/test/frontend")).toEqual(frontendFolder)
expect(getContainingWorkspaceFolder("/test/backend")).toEqual(backendFolder)
})
it("should return undefined for paths outside all workspaces", () => {
expect(getContainingWorkspaceFolder("/test/other")).toBeUndefined()
expect(getContainingWorkspaceFolder("/test")).toBeUndefined()
})
})
describe("no workspace folders", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue(undefined)
})
it("should return undefined when no workspace folders", () => {
expect(getContainingWorkspaceFolder("/any/path")).toBeUndefined()
})
})
describe("empty workspace folders array", () => {
beforeEach(() => {
mockWorkspaceFolders.mockReturnValue([])
})
it("should return undefined when workspace array is empty", () => {
expect(getContainingWorkspaceFolder("/any/path")).toBeUndefined()
})
})
})
})

View file

@ -107,13 +107,61 @@ export const toRelativePath = (filePath: string, cwd: string) => {
}
export const getWorkspacePath = (defaultCwdPath = "") => {
const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
// For multi-root workspaces, we should consider all workspace folders
const workspaceFolders = vscode.workspace.workspaceFolders
// If no workspace folders, return the default
if (!workspaceFolders || workspaceFolders.length === 0) {
return defaultCwdPath
}
// If there's an active editor, use its workspace folder
const currentFileUri = vscode.window.activeTextEditor?.document.uri
if (currentFileUri) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
return workspaceFolder?.uri.fsPath || cwdPath
if (workspaceFolder) {
return workspaceFolder.uri.fsPath
}
}
return cwdPath
// For multi-root workspaces, we'll return the first folder as the default
// but the system should be aware of all folders
return workspaceFolders[0].uri.fsPath
}
/**
* Get all workspace paths in a multi-root workspace
* @returns Array of all workspace folder paths
*/
export const getAllWorkspacePaths = (): string[] => {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return []
}
return workspaceFolders.map((folder) => folder.uri.fsPath)
}
/**
* Check if a path belongs to any workspace folder
* @param filePath The file path to check
* @returns The workspace folder path if the file belongs to a workspace, null otherwise
*/
export const getWorkspaceFolderForPath = (filePath: string): string | null => {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return null
}
const normalizedFilePath = path.normalize(filePath)
for (const folder of workspaceFolders) {
const folderPath = path.normalize(folder.uri.fsPath)
if (normalizedFilePath === folderPath || normalizedFilePath.startsWith(folderPath + path.sep)) {
return folder.uri.fsPath
}
}
return null
}
export const getWorkspacePathForContext = (contextPath?: string): string => {

View file

@ -14,11 +14,39 @@ export function isPathOutsideWorkspace(filePath: string): boolean {
// Normalize and resolve the path to handle .. and . components correctly
const absolutePath = path.resolve(filePath)
const normalizedPath = path.normalize(absolutePath)
// Check if the path is within any workspace folder
return !vscode.workspace.workspaceFolders.some((folder) => {
const folderPath = folder.uri.fsPath
// This properly supports multi-root workspaces by checking against ALL folders
for (const folder of vscode.workspace.workspaceFolders) {
const folderPath = path.normalize(folder.uri.fsPath)
// Path is inside a workspace if it equals the workspace path or is a subfolder
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
if (normalizedPath === folderPath || normalizedPath.startsWith(folderPath + path.sep)) {
return false // Path is inside this workspace folder
}
}
// Path is not in any workspace folder
return true
}
/**
* Get the workspace folder that contains the given path
* @param filePath The file path to check
* @returns The workspace folder URI if found, undefined otherwise
*/
export function getContainingWorkspaceFolder(filePath: string): vscode.WorkspaceFolder | undefined {
if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {
return undefined
}
const absolutePath = path.resolve(filePath)
const normalizedPath = path.normalize(absolutePath)
// Find the workspace folder that contains this path
return vscode.workspace.workspaceFolders.find((folder) => {
const folderPath = path.normalize(folder.uri.fsPath)
return normalizedPath === folderPath || normalizedPath.startsWith(folderPath + path.sep)
})
}