diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index c0139649ab..aaba76d57a 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -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 + } } } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cf16df8dcc..74466de7ef 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2909,6 +2909,33 @@ export class Task extends EventEmitter 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, diff --git a/src/utils/__tests__/path.spec.ts b/src/utils/__tests__/path.spec.ts index a8cf84b68c..b7ba42ed7a 100644 --- a/src/utils/__tests__/path.spec.ts +++ b/src/utils/__tests__/path.spec.ts @@ -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(() => { diff --git a/src/utils/__tests__/pathUtils.spec.ts b/src/utils/__tests__/pathUtils.spec.ts new file mode 100644 index 0000000000..31960df349 --- /dev/null +++ b/src/utils/__tests__/pathUtils.spec.ts @@ -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() + }) + }) + }) +}) diff --git a/src/utils/path.ts b/src/utils/path.ts index 48e2ce6673..53eaf9968a 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -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 => { diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index dae300f8f3..9df3827322 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -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) }) }