diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..f66f518479 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -10,7 +10,7 @@ import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeC import { fileExistsAtPath } from "../../utils/fs" import { getWorkspacePath } from "../../utils/path" -import { getGlobalRooDirectory } from "../../services/roo-config" +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../../services/roo-config" import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" @@ -566,14 +566,15 @@ export class CustomModesManager { if (scope === "project") { const workspacePath = getWorkspacePath() if (workspacePath) { - rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`) + const rooDir = await getProjectRooDirectoryForCwd(workspacePath) + rulesFolderPath = path.join(rooDir, `rules-${slug}`) } else { return // No workspace, can't delete project rules } } else { - // Global scope - use OS home directory - const homeDir = os.homedir() - rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`) + // Global scope - use global .roo directory + const globalRooDir = getGlobalRooDirectory() + rulesFolderPath = path.join(globalRooDir, `rules-${slug}`) } // Check if the rules folder exists and delete it @@ -665,7 +666,8 @@ export class CustomModesManager { if (!workspacePath) { return false } - modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`) + const rooDir = await getProjectRooDirectoryForCwd(workspacePath) + modeRulesDir = path.join(rooDir, `rules-${slug}`) } try { @@ -769,9 +771,13 @@ export class CustomModesManager { } // Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global) - const modeRulesDir = isGlobalMode - ? path.join(baseDir, `rules-${slug}`) - : path.join(baseDir, ".roo", `rules-${slug}`) + let modeRulesDir: string + if (isGlobalMode) { + modeRulesDir = path.join(baseDir, `rules-${slug}`) + } else { + const rooDir = await getProjectRooDirectoryForCwd(baseDir) + modeRulesDir = path.join(rooDir, `rules-${slug}`) + } let rulesFiles: RuleFile[] = [] try { @@ -855,7 +861,7 @@ export class CustomModesManager { rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`) } else { const workspacePath = getWorkspacePath() - baseDir = path.join(workspacePath, ".roo") + baseDir = await getProjectRooDirectoryForCwd(workspacePath) rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`) } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 97ef0ddc3c..a611dddedf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -50,6 +50,7 @@ import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" import { getCommand } from "../../utils/commands" +import { getProjectRooDirectoryForCwd } from "../../services/roo-config" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -801,7 +802,7 @@ export const webviewMessageHandler = async ( } const workspaceFolder = vscode.workspace.workspaceFolders[0] - const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo") + const rooDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath) const mcpPath = path.join(rooDir, "mcp.json") try { @@ -2483,7 +2484,8 @@ export const webviewMessageHandler = async ( vscode.window.showErrorMessage(t("common:errors.no_workspace_for_project_command")) break } - commandsDir = path.join(workspaceRoot, ".roo", "commands") + const rooDir = await getProjectRooDirectoryForCwd(workspaceRoot) + commandsDir = path.join(rooDir, "commands") } // Ensure the commands directory exists diff --git a/src/services/marketplace/SimpleInstaller.ts b/src/services/marketplace/SimpleInstaller.ts index be002e2f1d..58dd7f32b0 100644 --- a/src/services/marketplace/SimpleInstaller.ts +++ b/src/services/marketplace/SimpleInstaller.ts @@ -6,6 +6,7 @@ import type { MarketplaceItem, MarketplaceItemType, InstallMarketplaceItemOption import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" import type { CustomModesManager } from "../../core/config/CustomModesManager" +import { getProjectRooDirectoryForCwd } from "../../services/roo-config" export interface InstallOptions extends InstallMarketplaceItemOptions { target: "project" | "global" @@ -375,7 +376,8 @@ export class SimpleInstaller { if (!workspaceFolder) { throw new Error("No workspace folder found") } - return path.join(workspaceFolder.uri.fsPath, ".roo", "mcp.json") + const rooDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath) + return path.join(rooDir, "mcp.json") } else { const globalSettingsPath = await ensureSettingsDirectoryExists(this.context) return path.join(globalSettingsPath, GlobalFileNames.mcpSettings) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 10a74712ef..9a1a6fe3ca 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -32,6 +32,7 @@ import { import { fileExistsAtPath } from "../../utils/fs" import { arePathsEqual } from "../../utils/path" import { injectVariables } from "../../utils/config" +import { getProjectRooDirectoryForCwd } from "../../services/roo-config" export type McpConnection = { server: McpServer @@ -536,7 +537,7 @@ export class McpHub { } const workspaceFolder = vscode.workspace.workspaceFolders[0] - const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo") + const projectMcpDir = await getProjectRooDirectoryForCwd(workspaceFolder.uri.fsPath) const projectMcpPath = path.join(projectMcpDir, "mcp.json") try { diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index 8e9bf929cc..d098c84c08 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -1,11 +1,13 @@ import * as path from "path" +import * as vscode from "vscode" import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({ +const { mockStat, mockReadFile, mockHomedir, mockWorkspaceFolders } = vi.hoisted(() => ({ mockStat: vi.fn(), mockReadFile: vi.fn(), mockHomedir: vi.fn(), + mockWorkspaceFolders: vi.fn(), })) // Mock fs/promises module @@ -21,6 +23,18 @@ vi.mock("os", () => ({ homedir: mockHomedir, })) +// Mock vscode module +vi.mock("vscode", () => ({ + workspace: { + get workspaceFolders() { + return mockWorkspaceFolders() + }, + }, + Uri: { + file: (path: string) => ({ fsPath: path }), + }, +})) + import { getGlobalRooDirectory, getProjectRooDirectoryForCwd, @@ -29,12 +43,14 @@ import { readFileIfExists, getRooDirectoriesForCwd, loadConfiguration, + findWorkspaceWithRoo, } from "../index" describe("RooConfigService", () => { beforeEach(() => { vi.clearAllMocks() mockHomedir.mockReturnValue("/mock/home") + mockWorkspaceFolders.mockReturnValue(undefined) }) afterEach(() => { @@ -54,10 +70,81 @@ describe("RooConfigService", () => { }) }) + describe("findWorkspaceWithRoo", () => { + it("should return undefined when no workspace folders exist", () => { + mockWorkspaceFolders.mockReturnValue(undefined) + const result = findWorkspaceWithRoo() + expect(result).toBeUndefined() + }) + + it("should return undefined when workspace folders array is empty", () => { + mockWorkspaceFolders.mockReturnValue([]) + const result = findWorkspaceWithRoo() + expect(result).toBeUndefined() + }) + + it("should return the workspace folder named .roo", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }, + { uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 }, + { uri: { fsPath: "/workspace/project2" }, name: "project2", index: 2 }, + ] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + + const result = findWorkspaceWithRoo() + expect(result).toBe(workspaceFolders[1]) + }) + + it("should return undefined when no workspace folder is named .roo", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }, + { uri: { fsPath: "/workspace/project2" }, name: "project2", index: 1 }, + ] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + + const result = findWorkspaceWithRoo() + expect(result).toBeUndefined() + }) + + it("should handle workspace folders with .roo in path but not as basename", () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/.roo/subproject" }, name: "subproject", index: 0 }, + { uri: { fsPath: "/workspace/project.roo" }, name: "project.roo", index: 1 }, + ] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + + const result = findWorkspaceWithRoo() + expect(result).toBeUndefined() + }) + }) + describe("getProjectRooDirectoryForCwd", () => { - it("should return correct path for given cwd", () => { + it("should return .roo workspace folder path when it exists", async () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }, + { uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 }, + ] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + + const cwd = "/workspace/project1" + const result = await getProjectRooDirectoryForCwd(cwd) + expect(result).toBe("/workspace/.roo") + }) + + it("should return cwd/.roo when no .roo workspace folder exists", async () => { + const workspaceFolders = [{ uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + const cwd = "/custom/project/path" - const result = getProjectRooDirectoryForCwd(cwd) + const result = await getProjectRooDirectoryForCwd(cwd) + expect(result).toBe(path.join(cwd, ".roo")) + }) + + it("should return cwd/.roo when no workspace folders exist", async () => { + mockWorkspaceFolders.mockReturnValue(undefined) + + const cwd = "/custom/project/path" + const result = await getProjectRooDirectoryForCwd(cwd) expect(result).toBe(path.join(cwd, ".roo")) }) }) @@ -206,13 +293,26 @@ describe("RooConfigService", () => { }) describe("getRooDirectoriesForCwd", () => { - it("should return directories for given cwd", () => { + it("should return directories for given cwd", async () => { const cwd = "/custom/project/path" - const result = getRooDirectoriesForCwd(cwd) + const result = await getRooDirectoriesForCwd(cwd) expect(result).toEqual([path.join("/mock/home", ".roo"), path.join(cwd, ".roo")]) }) + + it("should use .roo workspace folder when it exists", async () => { + const workspaceFolders = [ + { uri: { fsPath: "/workspace/project1" }, name: "project1", index: 0 }, + { uri: { fsPath: "/workspace/.roo" }, name: ".roo", index: 1 }, + ] + mockWorkspaceFolders.mockReturnValue(workspaceFolders) + + const cwd = "/workspace/project1" + const result = await getRooDirectoriesForCwd(cwd) + + expect(result).toEqual([path.join("/mock/home", ".roo"), "/workspace/.roo"]) + }) }) describe("loadConfiguration", () => { diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index b46c39e354..ba5c6208a1 100644 --- a/src/services/roo-config/index.ts +++ b/src/services/roo-config/index.ts @@ -1,6 +1,36 @@ import * as path from "path" import * as os from "os" import fs from "fs/promises" +import * as vscode from "vscode" + +/** + * Finds the workspace folder that contains a .roo directory + * + * @returns The workspace folder containing .roo, or undefined if not found + * + * @example + * ```typescript + * const workspaceWithRoo = findWorkspaceWithRoo() + * if (workspaceWithRoo) { + * // .roo folder exists as one of the workspace folders + * const rooPath = workspaceWithRoo.uri.fsPath + * } + * ``` + */ +export function findWorkspaceWithRoo(): vscode.WorkspaceFolder | undefined { + if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { + return undefined + } + + // Check if any workspace folder is named .roo + for (const folder of vscode.workspace.workspaceFolders) { + if (path.basename(folder.uri.fsPath) === ".roo") { + return folder + } + } + + return undefined +} /** * Gets the global .roo directory path based on the current platform @@ -56,8 +86,19 @@ export function getGlobalRooDirectory(): string { * │ └── index.ts * └── package.json * ``` + * + * @note In multi-root workspaces, if .roo is one of the workspace folders, + * this function will return that folder's path instead of creating a .roo + * subdirectory in the first workspace folder. */ export function getProjectRooDirectoryForCwd(cwd: string): string { + // Check if .roo is one of the workspace folders in a multi-root workspace + const workspaceWithRoo = findWorkspaceWithRoo() + if (workspaceWithRoo) { + return workspaceWithRoo.uri.fsPath + } + + // Default behavior: create .roo as a subdirectory return path.join(cwd, ".roo") }