Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fast-taxis-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"roo-cline": patch
---

add multiple workspaces support
90 changes: 47 additions & 43 deletions src/core/Cline.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as fs from "fs/promises"
import { CustomModesSettingsSchema } from "./CustomModesSchema"
import { ModeConfig } from "../../shared/modes"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
import { logger } from "../../utils/logging"

const ROOMODES_FILENAME = ".roomodes"
Expand Down Expand Up @@ -51,7 +51,7 @@ export class CustomModesManager {
if (!workspaceFolders || workspaceFolders.length === 0) {
return undefined
}
const workspaceRoot = workspaceFolders[0].uri.fsPath
const workspaceRoot = getWorkspacePath()
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
const exists = await fileExistsAtPath(roomodesPath)
return exists ? roomodesPath : undefined
Expand Down Expand Up @@ -226,7 +226,7 @@ export class CustomModesManager {
logger.error("Failed to update project mode: No workspace folder found", { slug })
throw new Error("No workspace folder found for project-specific mode")
}
const workspaceRoot = workspaceFolders[0].uri.fsPath
const workspaceRoot = getWorkspacePath()
targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
const exists = await fileExistsAtPath(targetPath)
logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {
Expand Down
8 changes: 7 additions & 1 deletion src/core/config/__tests__/CustomModesManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import * as fs from "fs/promises"
import { CustomModesManager } from "../CustomModesManager"
import { ModeConfig } from "../../../shared/modes"
import { fileExistsAtPath } from "../../../utils/fs"
import { getWorkspacePath, arePathsEqual } from "../../../utils/path"

jest.mock("vscode")
jest.mock("fs/promises")
jest.mock("../../../utils/fs")
jest.mock("../../../utils/path")

describe("CustomModesManager", () => {
let manager: CustomModesManager
Expand Down Expand Up @@ -37,6 +39,7 @@ describe("CustomModesManager", () => {
mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }]
;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders
;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() })
;(getWorkspacePath as jest.Mock).mockReturnValue("/mock/workspace")
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
return path === mockSettingsPath || path === mockRoomodes
})
Expand Down Expand Up @@ -362,8 +365,11 @@ describe("CustomModesManager", () => {

it("watches file for changes", async () => {
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))

;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => {
return path.normalize(path1) === path.normalize(path2)
})
// Get the registered callback
const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
expect(registerCall).toBeDefined()
Expand Down
46 changes: 41 additions & 5 deletions src/core/mentions/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ const mockVscode = {
{
uri: { fsPath: "/test/workspace" },
},
],
] as { uri: { fsPath: string } }[] | undefined,
getWorkspaceFolder: jest.fn().mockReturnValue("/test/workspace"),
fs: {
stat: jest.fn(),
writeFile: jest.fn(),
},
openTextDocument: jest.fn().mockResolvedValue({}),
},
window: {
showErrorMessage: mockShowErrorMessage,
Expand All @@ -36,7 +42,14 @@ const mockVscode = {
createTextEditorDecorationType: jest.fn(),
createOutputChannel: jest.fn(),
createWebviewPanel: jest.fn(),
activeTextEditor: undefined,
showTextDocument: jest.fn().mockResolvedValue({}),
activeTextEditor: undefined as
| undefined
| {
document: {
uri: { fsPath: string }
}
},
},
commands: {
executeCommand: mockExecuteCommand,
Expand Down Expand Up @@ -64,12 +77,16 @@ const mockVscode = {
jest.mock("vscode", () => mockVscode)
jest.mock("../../../services/browser/UrlContentFetcher")
jest.mock("../../../utils/git")
jest.mock("../../../utils/path")

// Now import the modules that use the mocks
import { parseMentions, openMention } from "../index"
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
import * as git from "../../../utils/git"

import { getWorkspacePath } from "../../../utils/path"
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")

describe("mentions", () => {
const mockCwd = "/test/workspace"
let mockUrlContentFetcher: UrlContentFetcher
Expand All @@ -83,6 +100,15 @@ describe("mentions", () => {
closeBrowser: jest.fn().mockResolvedValue(undefined),
urlToMarkdown: jest.fn().mockResolvedValue(""),
} as unknown as UrlContentFetcher

// Reset all vscode mocks
mockVscode.workspace.fs.stat.mockReset()
mockVscode.workspace.fs.writeFile.mockReset()
mockVscode.workspace.openTextDocument.mockReset().mockResolvedValue({})
mockVscode.window.showTextDocument.mockReset().mockResolvedValue({})
mockVscode.window.showErrorMessage.mockReset()
mockExecuteCommand.mockReset()
mockOpenExternal.mockReset()
})

describe("parseMentions", () => {
Expand Down Expand Up @@ -122,11 +148,21 @@ Detailed commit message with multiple lines

describe("openMention", () => {
it("should handle file paths and problems", async () => {
// Mock stat to simulate file not existing
mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))

// Call openMention and wait for it to complete
await openMention("/path/to/file")

// Verify error handling
expect(mockExecuteCommand).not.toHaveBeenCalled()
expect(mockOpenExternal).not.toHaveBeenCalled()
expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")

// Reset mocks for next test
jest.clearAllMocks()

// Test problems command
await openMention("problems")
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
})
Expand All @@ -135,8 +171,8 @@ Detailed commit message with multiple lines
const url = "https://example.com"
await openMention(url)
const mockUri = mockVscode.Uri.parse(url)
expect(mockOpenExternal).toHaveBeenCalled()
const calledArg = mockOpenExternal.mock.calls[0][0]
expect(mockVscode.env.openExternal).toHaveBeenCalled()
const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
expect(calledArg).toEqual(
expect.objectContaining({
scheme: mockUri.scheme,
Expand Down
3 changes: 2 additions & 1 deletion src/core/mentions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { isBinaryFile } from "isbinaryfile"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import { getCommitInfo, getWorkingState } from "../../utils/git"
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
import { getWorkspacePath } from "../../utils/path"

export async function openMention(mention?: string): Promise<void> {
if (!mention) {
return
}

const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const cwd = getWorkspacePath()
if (!cwd) {
return
}
Expand Down
18 changes: 9 additions & 9 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { getWorkspacePath } from "../../utils/path"

/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
Expand All @@ -81,7 +82,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private contextProxy: ContextProxy
configManager: ConfigManager
customModesManager: CustomModesManager

get cwd() {
return getWorkspacePath()
}
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
Expand Down Expand Up @@ -482,7 +485,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {

const taskId = historyItem.id
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
const workspaceDir = this.cwd

const checkpoints: Pick<ClineOptions, "enableCheckpoints" | "checkpointStorage"> = {
enableCheckpoints,
Expand Down Expand Up @@ -1627,7 +1630,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
case "searchCommits": {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const cwd = this.cwd
if (cwd) {
try {
const commits = await searchCommits(message.query || "", cwd)
Expand Down Expand Up @@ -1895,7 +1898,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
fuzzyMatchThreshold,
Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
)
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
const cwd = this.cwd

const mode = message.mode ?? defaultModeSlug
const customModes = await this.customModesManager.getCustomModes()
Expand Down Expand Up @@ -2225,13 +2228,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// delete task from the task history state
await this.deleteTaskFromState(id)

// get the base directory of the project
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)

// Delete associated shadow repository or branch.
// TODO: Store `workspaceDir` in the `HistoryItem` object.
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
const workspaceDir = baseDir ?? ""
const workspaceDir = this.cwd

try {
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
Expand Down Expand Up @@ -2317,7 +2317,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {

const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []

const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
const cwd = this.cwd

return {
version: this.context.extension?.packageJSON?.version ?? "",
Expand Down
4 changes: 2 additions & 2 deletions src/integrations/misc/open-file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from "path"
import * as os from "os"
import * as vscode from "vscode"
import { arePathsEqual } from "../../utils/path"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"

export async function openImage(dataUri: string) {
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
Expand All @@ -28,7 +28,7 @@ interface OpenFileOptions {
export async function openFile(filePath: string, options: OpenFileOptions = {}) {
try {
// Get workspace root
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const workspaceRoot = getWorkspacePath()
if (!workspaceRoot) {
throw new Error("No workspace root found")
}
Expand Down
51 changes: 41 additions & 10 deletions src/integrations/workspace/WorkspaceTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { toRelativePath } from "../../utils/path"
import { getWorkspacePath } from "../../utils/path"
import { logger } from "../../utils/logging"

const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const MAX_INITIAL_FILES = 1_000

// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
Expand All @@ -13,25 +14,34 @@ class WorkspaceTracker {
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
private updateTimer: NodeJS.Timeout | null = null
private prevWorkSpacePath: string | undefined
private resetTimer: NodeJS.Timeout | null = null

get cwd() {
return getWorkspacePath()
}
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.registerListeners()
}

async initializeFilePaths() {
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
if (!cwd) {
if (!this.cwd) {
return
}
const tempCwd = this.cwd
const [files, _] = await listFiles(tempCwd, true, MAX_INITIAL_FILES)
if (this.prevWorkSpacePath !== tempCwd) {
return
}
const [files, _] = await listFiles(cwd, true, MAX_INITIAL_FILES)
files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
this.workspaceDidUpdate()
}

private registerListeners() {
const watcher = vscode.workspace.createFileSystemWatcher("**")

this.prevWorkSpacePath = this.cwd
this.disposables.push(
watcher.onDidCreate(async (uri) => {
await this.addFilePath(uri.fsPath)
Expand All @@ -50,7 +60,7 @@ class WorkspaceTracker {

this.disposables.push(watcher)

this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidReset()))
}

private getOpenedTabsInfo() {
Expand All @@ -62,23 +72,40 @@ class WorkspaceTracker {
return {
label: tab.label,
isActive: tab.isActive,
path: toRelativePath(path, cwd || ""),
path: toRelativePath(path, this.cwd || ""),
}
}),
)
}

private async workspaceDidReset() {
if (this.resetTimer) {
clearTimeout(this.resetTimer)
}
this.resetTimer = setTimeout(async () => {
if (this.prevWorkSpacePath !== this.cwd) {
await this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: [],
openedTabs: this.getOpenedTabsInfo(),
})
this.filePaths.clear()
this.prevWorkSpacePath = this.cwd
this.initializeFilePaths()
}
}, 300) // Debounce for 300ms
}

private workspaceDidUpdate() {
if (this.updateTimer) {
clearTimeout(this.updateTimer)
}

this.updateTimer = setTimeout(() => {
if (!cwd) {
if (!this.cwd) {
return
}

const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, this.cwd))
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: relativeFilePaths,
Expand All @@ -89,7 +116,7 @@ class WorkspaceTracker {
}

private normalizeFilePath(filePath: string): string {
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
}

Expand Down Expand Up @@ -123,6 +150,10 @@ class WorkspaceTracker {
clearTimeout(this.updateTimer)
this.updateTimer = null
}
if (this.resetTimer) {
clearTimeout(this.resetTimer)
this.resetTimer = null
}
this.disposables.forEach((d) => d.dispose())
}
}
Expand Down
Loading
Loading