Skip to content

Commit 23af4c2

Browse files
teddyOOXXxiong
andauthored
add multiple workspaces support (#1725)
feat: add multiple workspaces support - Add getWorkspacePath function to centralize workspace directory path retrieval - Use the new workspace directory retrieval logic in Cline, Mentions, ClineProvider, and WorkspaceTracker - Update WorkspaceFile on tab switch and prevent redundant updates by checking prevWorkSpacePath - Fix the bug that loads the contents of the previous tab when quickly switching tabs - Optimize getWorkspacePath return value for better reliability Co-authored-by: xiong <[email protected]>
1 parent a4853f2 commit 23af4c2

File tree

12 files changed

+370
-80
lines changed

12 files changed

+370
-80
lines changed

.changeset/fast-taxis-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
add multiple workspaces support

src/core/Cline.ts

Lines changed: 47 additions & 43 deletions
Large diffs are not rendered by default.

src/core/config/CustomModesManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fs from "fs/promises"
44
import { CustomModesSettingsSchema } from "./CustomModesSchema"
55
import { ModeConfig } from "../../shared/modes"
66
import { fileExistsAtPath } from "../../utils/fs"
7-
import { arePathsEqual } from "../../utils/path"
7+
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
88
import { logger } from "../../utils/logging"
99

1010
const ROOMODES_FILENAME = ".roomodes"
@@ -51,7 +51,7 @@ export class CustomModesManager {
5151
if (!workspaceFolders || workspaceFolders.length === 0) {
5252
return undefined
5353
}
54-
const workspaceRoot = workspaceFolders[0].uri.fsPath
54+
const workspaceRoot = getWorkspacePath()
5555
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
5656
const exists = await fileExistsAtPath(roomodesPath)
5757
return exists ? roomodesPath : undefined
@@ -226,7 +226,7 @@ export class CustomModesManager {
226226
logger.error("Failed to update project mode: No workspace folder found", { slug })
227227
throw new Error("No workspace folder found for project-specific mode")
228228
}
229-
const workspaceRoot = workspaceFolders[0].uri.fsPath
229+
const workspaceRoot = getWorkspacePath()
230230
targetPath = path.join(workspaceRoot, ROOMODES_FILENAME)
231231
const exists = await fileExistsAtPath(targetPath)
232232
logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, {

src/core/config/__tests__/CustomModesManager.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import * as fs from "fs/promises"
66
import { CustomModesManager } from "../CustomModesManager"
77
import { ModeConfig } from "../../../shared/modes"
88
import { fileExistsAtPath } from "../../../utils/fs"
9+
import { getWorkspacePath, arePathsEqual } from "../../../utils/path"
910

1011
jest.mock("vscode")
1112
jest.mock("fs/promises")
1213
jest.mock("../../../utils/fs")
14+
jest.mock("../../../utils/path")
1315

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

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

369+
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
370+
;(arePathsEqual as jest.Mock).mockImplementation((path1: string, path2: string) => {
371+
return path.normalize(path1) === path.normalize(path2)
372+
})
367373
// Get the registered callback
368374
const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
369375
expect(registerCall).toBeDefined()

src/core/mentions/__tests__/index.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ const mockVscode = {
2727
{
2828
uri: { fsPath: "/test/workspace" },
2929
},
30-
],
30+
] as { uri: { fsPath: string } }[] | undefined,
31+
getWorkspaceFolder: jest.fn().mockReturnValue("/test/workspace"),
32+
fs: {
33+
stat: jest.fn(),
34+
writeFile: jest.fn(),
35+
},
36+
openTextDocument: jest.fn().mockResolvedValue({}),
3137
},
3238
window: {
3339
showErrorMessage: mockShowErrorMessage,
@@ -36,7 +42,14 @@ const mockVscode = {
3642
createTextEditorDecorationType: jest.fn(),
3743
createOutputChannel: jest.fn(),
3844
createWebviewPanel: jest.fn(),
39-
activeTextEditor: undefined,
45+
showTextDocument: jest.fn().mockResolvedValue({}),
46+
activeTextEditor: undefined as
47+
| undefined
48+
| {
49+
document: {
50+
uri: { fsPath: string }
51+
}
52+
},
4053
},
4154
commands: {
4255
executeCommand: mockExecuteCommand,
@@ -64,12 +77,16 @@ const mockVscode = {
6477
jest.mock("vscode", () => mockVscode)
6578
jest.mock("../../../services/browser/UrlContentFetcher")
6679
jest.mock("../../../utils/git")
80+
jest.mock("../../../utils/path")
6781

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

87+
import { getWorkspacePath } from "../../../utils/path"
88+
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
89+
7390
describe("mentions", () => {
7491
const mockCwd = "/test/workspace"
7592
let mockUrlContentFetcher: UrlContentFetcher
@@ -83,6 +100,15 @@ describe("mentions", () => {
83100
closeBrowser: jest.fn().mockResolvedValue(undefined),
84101
urlToMarkdown: jest.fn().mockResolvedValue(""),
85102
} as unknown as UrlContentFetcher
103+
104+
// Reset all vscode mocks
105+
mockVscode.workspace.fs.stat.mockReset()
106+
mockVscode.workspace.fs.writeFile.mockReset()
107+
mockVscode.workspace.openTextDocument.mockReset().mockResolvedValue({})
108+
mockVscode.window.showTextDocument.mockReset().mockResolvedValue({})
109+
mockVscode.window.showErrorMessage.mockReset()
110+
mockExecuteCommand.mockReset()
111+
mockOpenExternal.mockReset()
86112
})
87113

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

123149
describe("openMention", () => {
124150
it("should handle file paths and problems", async () => {
151+
// Mock stat to simulate file not existing
152+
mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))
153+
154+
// Call openMention and wait for it to complete
125155
await openMention("/path/to/file")
156+
157+
// Verify error handling
126158
expect(mockExecuteCommand).not.toHaveBeenCalled()
127159
expect(mockOpenExternal).not.toHaveBeenCalled()
128-
expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
160+
expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
161+
162+
// Reset mocks for next test
163+
jest.clearAllMocks()
129164

165+
// Test problems command
130166
await openMention("problems")
131167
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
132168
})
@@ -135,8 +171,8 @@ Detailed commit message with multiple lines
135171
const url = "https://example.com"
136172
await openMention(url)
137173
const mockUri = mockVscode.Uri.parse(url)
138-
expect(mockOpenExternal).toHaveBeenCalled()
139-
const calledArg = mockOpenExternal.mock.calls[0][0]
174+
expect(mockVscode.env.openExternal).toHaveBeenCalled()
175+
const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
140176
expect(calledArg).toEqual(
141177
expect.objectContaining({
142178
scheme: mockUri.scheme,

src/core/mentions/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { isBinaryFile } from "isbinaryfile"
99
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
1010
import { getCommitInfo, getWorkingState } from "../../utils/git"
1111
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
12+
import { getWorkspacePath } from "../../utils/path"
1213

1314
export async function openMention(mention?: string): Promise<void> {
1415
if (!mention) {
1516
return
1617
}
1718

18-
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
19+
const cwd = getWorkspacePath()
1920
if (!cwd) {
2021
return
2122
}

src/core/webview/ClineProvider.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { getNonce } from "./getNonce"
6363
import { getUri } from "./getUri"
6464
import { telemetryService } from "../../services/telemetry/TelemetryService"
6565
import { TelemetrySetting } from "../../shared/TelemetrySetting"
66+
import { getWorkspacePath } from "../../utils/path"
6667

6768
/**
6869
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -87,7 +88,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
8788
private contextProxy: ContextProxy
8889
configManager: ConfigManager
8990
customModesManager: CustomModesManager
90-
91+
get cwd() {
92+
return getWorkspacePath()
93+
}
9194
constructor(
9295
readonly context: vscode.ExtensionContext,
9396
private readonly outputChannel: vscode.OutputChannel,
@@ -501,7 +504,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
501504

502505
const taskId = historyItem.id
503506
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
504-
const workspaceDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
507+
const workspaceDir = this.cwd
505508

506509
const checkpoints: Pick<ClineOptions, "enableCheckpoints" | "checkpointStorage"> = {
507510
enableCheckpoints,
@@ -1685,7 +1688,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
16851688
}
16861689
break
16871690
case "searchCommits": {
1688-
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
1691+
const cwd = this.cwd
16891692
if (cwd) {
16901693
try {
16911694
const commits = await searchCommits(message.query || "", cwd)
@@ -1953,7 +1956,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
19531956
fuzzyMatchThreshold,
19541957
Experiments.isEnabled(experiments, EXPERIMENT_IDS.DIFF_STRATEGY),
19551958
)
1956-
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
1959+
const cwd = this.cwd
19571960

19581961
const mode = message.mode ?? defaultModeSlug
19591962
const customModes = await this.customModesManager.getCustomModes()
@@ -2301,13 +2304,10 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
23012304
// delete task from the task history state
23022305
await this.deleteTaskFromState(id)
23032306

2304-
// get the base directory of the project
2305-
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
2306-
23072307
// Delete associated shadow repository or branch.
23082308
// TODO: Store `workspaceDir` in the `HistoryItem` object.
23092309
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
2310-
const workspaceDir = baseDir ?? ""
2310+
const workspaceDir = this.cwd
23112311

23122312
try {
23132313
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
@@ -2395,7 +2395,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
23952395

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

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

24002400
return {
24012401
version: this.context.extension?.packageJSON?.version ?? "",

src/integrations/misc/open-file.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as path from "path"
22
import * as os from "os"
33
import * as vscode from "vscode"
4-
import { arePathsEqual } from "../../utils/path"
4+
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
55

66
export async function openImage(dataUri: string) {
77
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
@@ -28,7 +28,7 @@ interface OpenFileOptions {
2828
export async function openFile(filePath: string, options: OpenFileOptions = {}) {
2929
try {
3030
// Get workspace root
31-
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
31+
const workspaceRoot = getWorkspacePath()
3232
if (!workspaceRoot) {
3333
throw new Error("No workspace root found")
3434
}

src/integrations/workspace/WorkspaceTracker.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import * as path from "path"
33
import { listFiles } from "../../services/glob/list-files"
44
import { ClineProvider } from "../../core/webview/ClineProvider"
55
import { toRelativePath } from "../../utils/path"
6+
import { getWorkspacePath } from "../../utils/path"
7+
import { logger } from "../../utils/logging"
68

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

1011
// 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
@@ -13,25 +14,34 @@ class WorkspaceTracker {
1314
private disposables: vscode.Disposable[] = []
1415
private filePaths: Set<string> = new Set()
1516
private updateTimer: NodeJS.Timeout | null = null
17+
private prevWorkSpacePath: string | undefined
18+
private resetTimer: NodeJS.Timeout | null = null
1619

20+
get cwd() {
21+
return getWorkspacePath()
22+
}
1723
constructor(provider: ClineProvider) {
1824
this.providerRef = new WeakRef(provider)
1925
this.registerListeners()
2026
}
2127

2228
async initializeFilePaths() {
2329
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
24-
if (!cwd) {
30+
if (!this.cwd) {
31+
return
32+
}
33+
const tempCwd = this.cwd
34+
const [files, _] = await listFiles(tempCwd, true, MAX_INITIAL_FILES)
35+
if (this.prevWorkSpacePath !== tempCwd) {
2536
return
2637
}
27-
const [files, _] = await listFiles(cwd, true, MAX_INITIAL_FILES)
2838
files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
2939
this.workspaceDidUpdate()
3040
}
3141

3242
private registerListeners() {
3343
const watcher = vscode.workspace.createFileSystemWatcher("**")
34-
44+
this.prevWorkSpacePath = this.cwd
3545
this.disposables.push(
3646
watcher.onDidCreate(async (uri) => {
3747
await this.addFilePath(uri.fsPath)
@@ -50,7 +60,7 @@ class WorkspaceTracker {
5060

5161
this.disposables.push(watcher)
5262

53-
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
63+
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidReset()))
5464
}
5565

5666
private getOpenedTabsInfo() {
@@ -62,23 +72,40 @@ class WorkspaceTracker {
6272
return {
6373
label: tab.label,
6474
isActive: tab.isActive,
65-
path: toRelativePath(path, cwd || ""),
75+
path: toRelativePath(path, this.cwd || ""),
6676
}
6777
}),
6878
)
6979
}
7080

81+
private async workspaceDidReset() {
82+
if (this.resetTimer) {
83+
clearTimeout(this.resetTimer)
84+
}
85+
this.resetTimer = setTimeout(async () => {
86+
if (this.prevWorkSpacePath !== this.cwd) {
87+
await this.providerRef.deref()?.postMessageToWebview({
88+
type: "workspaceUpdated",
89+
filePaths: [],
90+
openedTabs: this.getOpenedTabsInfo(),
91+
})
92+
this.filePaths.clear()
93+
this.prevWorkSpacePath = this.cwd
94+
this.initializeFilePaths()
95+
}
96+
}, 300) // Debounce for 300ms
97+
}
98+
7199
private workspaceDidUpdate() {
72100
if (this.updateTimer) {
73101
clearTimeout(this.updateTimer)
74102
}
75-
76103
this.updateTimer = setTimeout(() => {
77-
if (!cwd) {
104+
if (!this.cwd) {
78105
return
79106
}
80107

81-
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
108+
const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, this.cwd))
82109
this.providerRef.deref()?.postMessageToWebview({
83110
type: "workspaceUpdated",
84111
filePaths: relativeFilePaths,
@@ -89,7 +116,7 @@ class WorkspaceTracker {
89116
}
90117

91118
private normalizeFilePath(filePath: string): string {
92-
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
119+
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
93120
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
94121
}
95122

@@ -123,6 +150,10 @@ class WorkspaceTracker {
123150
clearTimeout(this.updateTimer)
124151
this.updateTimer = null
125152
}
153+
if (this.resetTimer) {
154+
clearTimeout(this.resetTimer)
155+
this.resetTimer = null
156+
}
126157
this.disposables.forEach((d) => d.dispose())
127158
}
128159
}

0 commit comments

Comments
 (0)