Skip to content

Commit 5ec10d9

Browse files
Chris Hassonhassoncs
authored andcommitted
feat: add multi-workspace support for file search and lookup
Introduce comprehensive support for searching and tracking files across multiple workspace folders in multi-root workspace scenarios. Changes: - Update ripgrep service to search across multiple workspace paths - Enhance file search to support all workspace folders with workspace names - Modify WorkspaceTracker to monitor files across all workspaces - Update environment details to show files from all workspace folders - Add workspace folder information to extension messages - Update search tools to leverage multi-workspace capabilities - Enhance test coverage for multi-workspace scenarios This enables users to search, mention, and work with files across all workspace folders in multi-root workspace setups, improving the overall development experience in complex project structures.
1 parent fa693e1 commit 5ec10d9

File tree

12 files changed

+172
-88
lines changed

12 files changed

+172
-88
lines changed

src/core/environment/__tests__/getEnvironmentDetails.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getApiMetrics } from "../../../shared/getApiMetrics"
1111
import { listFiles } from "../../../services/glob/list-files"
1212
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
1313
import { Terminal } from "../../../integrations/terminal/Terminal"
14-
import { arePathsEqual } from "../../../utils/path"
14+
import { arePathsEqual, getAllWorkspacePaths } from "../../../utils/path"
1515
import { FileContextTracker } from "../../context-tracking/FileContextTracker"
1616
import { ApiHandler } from "../../../api/index"
1717
import { ClineProvider } from "../../webview/ClineProvider"
@@ -129,6 +129,7 @@ describe("getEnvironmentDetails", () => {
129129
;(listFiles as Mock).mockResolvedValue([["file1.ts", "file2.ts"], false])
130130
;(formatResponse.formatFilesList as Mock).mockReturnValue("file1.ts\nfile2.ts")
131131
;(arePathsEqual as Mock).mockReturnValue(false)
132+
;(getAllWorkspacePaths as Mock).mockReturnValue([mockCwd])
132133
;(Terminal.compressTerminalOutput as Mock).mockImplementation((output: string) => output)
133134
;(TerminalRegistry.getTerminals as Mock).mockReturnValue([])
134135
;(TerminalRegistry.getBackgroundTerminals as Mock).mockReturnValue([])

src/core/environment/getEnvironmentDetails.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getApiMetrics } from "../../shared/getApiMetrics"
1414
import { listFiles } from "../../services/glob/list-files"
1515
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
1616
import { Terminal } from "../../integrations/terminal/Terminal"
17-
import { arePathsEqual } from "../../utils/path"
17+
import { arePathsEqual, getAllWorkspacePaths } from "../../utils/path"
1818
import { formatResponse } from "../prompts/responses"
1919

2020
import { Task } from "../task/Task"
@@ -249,27 +249,37 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
249249

250250
if (includeFileDetails) {
251251
details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n`
252-
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
253252

254-
if (isDesktop) {
253+
const allWorkspacePaths = getAllWorkspacePaths()
254+
const maxFilesPerWorkspace = Math.floor((maxWorkspaceFiles ?? 200) / allWorkspacePaths.length)
255+
256+
// Collect all files from all workspaces
257+
let allFiles: string[] = []
258+
let anyDidHitLimit = false
259+
260+
for (const workspacePath of allWorkspacePaths) {
261+
const isDesktop = arePathsEqual(workspacePath, path.join(os.homedir(), "Desktop"))
255262
// Don't want to immediately access desktop since it would show
256263
// permission popup.
257-
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)"
258-
} else {
259-
const maxFiles = maxWorkspaceFiles ?? 200
260-
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles)
261-
const { showRooIgnoredFiles = true } = state ?? {}
262-
263-
const result = formatResponse.formatFilesList(
264-
cline.cwd,
265-
files,
266-
didHitLimit,
267-
cline.rooIgnoreController,
268-
showRooIgnoredFiles,
269-
)
270-
271-
details += result
264+
if (isDesktop) {
265+
details += "(Desktop files not shown automatically. Use list_files to explore if needed.)\n"
266+
continue
267+
}
268+
269+
const [files, didHitLimit] = await listFiles(workspacePath, true, maxFilesPerWorkspace)
270+
anyDidHitLimit = anyDidHitLimit || didHitLimit
271+
allFiles.push(...files)
272272
}
273+
274+
// Format all file paths relative to `cline.cwd`
275+
const { showRooIgnoredFiles = true } = state ?? {}
276+
details += formatResponse.formatFilesList(
277+
cline.cwd,
278+
allFiles,
279+
anyDidHitLimit,
280+
cline.rooIgnoreController,
281+
showRooIgnoredFiles,
282+
)
273283
}
274284

275285
return `<environment_details>\n${details.trim()}\n</environment_details>`

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ vi.mock("../../../integrations/misc/extract-text", () => ({
9898
import { parseMentions, openMention } from "../index"
9999
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
100100
import * as git from "../../../utils/git"
101-
import { getWorkspacePath } from "../../../utils/path"
101+
import { getWorkspacePath, getAllWorkspacePaths } from "../../../utils/path"
102102
import fs from "fs/promises"
103103
import * as path from "path"
104104
import { openFile } from "../../../integrations/misc/open-file"
@@ -141,6 +141,7 @@ describe("mentions", () => {
141141
mockUrlFetcher = new (UrlContentFetcher as any)()
142142
;(fs.stat as Mock).mockResolvedValue({ isFile: () => true, isDirectory: () => false })
143143
;(extractTextFromFile as Mock).mockResolvedValue("Mock file content")
144+
;(getAllWorkspacePaths as Mock).mockReturnValue([mockCwd])
144145
})
145146

146147
it("should parse git commit mentions", async () => {

src/core/task/__tests__/Task.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ vi.mock("vscode", () => {
127127
from: vi.fn(),
128128
},
129129
TabInputText: vi.fn(),
130+
RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, pattern })),
131+
Uri: { file: vi.fn((path) => ({ fsPath: path })) },
132+
FileType: { File: 1, Directory: 2 },
130133
}
131134
})
132135

src/core/tools/searchFilesTool.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import * as vscode from "vscode"
12
import path from "path"
23

34
import { Task } from "../task/Task"
45
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
56
import { ClineSayTool } from "../../shared/ExtensionMessage"
6-
import { getReadablePath } from "../../utils/path"
7+
import { getAllWorkspacePaths, getReadablePath } from "../../utils/path"
78
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
89
import { regexSearchFiles } from "../../services/ripgrep"
910

@@ -52,9 +53,10 @@ export async function searchFilesTool(
5253

5354
cline.consecutiveMistakeCount = 0
5455

56+
const workspacePaths = getAllWorkspacePaths()
5557
const results = await regexSearchFiles(
5658
cline.cwd,
57-
absolutePath,
59+
workspacePaths,
5860
regex,
5961
filePattern,
6062
cline.rooIgnoreController,

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { openImage, saveImage } from "../../integrations/misc/image-handler"
2323
import { selectImages } from "../../integrations/misc/process-images"
2424
import { getTheme } from "../../integrations/theme/getTheme"
2525
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
26-
import { searchWorkspaceFiles } from "../../services/search/file-search"
26+
import { searchAllWorkspaceFiles } from "../../services/search/file-search"
2727
import { fileExistsAtPath } from "../../utils/fs"
2828
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
2929
import { singleCompletionHandler } from "../../utils/single-completion-handler"
@@ -33,7 +33,7 @@ import { getOpenAiModels } from "../../api/providers/openai"
3333
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
3434
import { openMention } from "../mentions"
3535
import { TelemetrySetting } from "../../shared/TelemetrySetting"
36-
import { getWorkspacePath } from "../../utils/path"
36+
import { getPrimaryWorkspaceFolder } from "../../utils/path"
3737
import { Mode, defaultModeSlug } from "../../shared/modes"
3838
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
3939
import { GetModelsOptions } from "../../shared/api"
@@ -600,7 +600,7 @@ export const webviewMessageHandler = async (
600600
return
601601
}
602602

603-
const workspaceFolder = vscode.workspace.workspaceFolders[0]
603+
const workspaceFolder = getPrimaryWorkspaceFolder()
604604
const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo")
605605
const mcpPath = path.join(rooDir, "mcp.json")
606606

@@ -1252,27 +1252,8 @@ export const webviewMessageHandler = async (
12521252
break
12531253
}
12541254
case "searchFiles": {
1255-
const workspacePath = getWorkspacePath()
1256-
1257-
if (!workspacePath) {
1258-
// Handle case where workspace path is not available
1259-
await provider.postMessageToWebview({
1260-
type: "fileSearchResults",
1261-
results: [],
1262-
requestId: message.requestId,
1263-
error: "No workspace path available",
1264-
})
1265-
break
1266-
}
12671255
try {
1268-
// Call file search service with query from message
1269-
const results = await searchWorkspaceFiles(
1270-
message.query || "",
1271-
workspacePath,
1272-
20, // Use default limit, as filtering is now done in the backend
1273-
)
1274-
1275-
// Send results back to webview
1256+
const results = await searchAllWorkspaceFiles(message.query || "", 20)
12761257
await provider.postMessageToWebview({
12771258
type: "fileSearchResults",
12781259
results,

src/integrations/workspace/WorkspaceTracker.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "path"
33

44
import { listFiles } from "../../services/glob/list-files"
55
import { ClineProvider } from "../../core/webview/ClineProvider"
6-
import { toRelativePath, getWorkspacePath } from "../../utils/path"
6+
import { toRelativePath, getWorkspacePath, getAllWorkspacePaths } from "../../utils/path"
77

88
const MAX_INITIAL_FILES = 1_000
99

@@ -19,6 +19,7 @@ class WorkspaceTracker {
1919
get cwd() {
2020
return getWorkspacePath()
2121
}
22+
2223
constructor(provider: ClineProvider) {
2324
this.providerRef = new WeakRef(provider)
2425
this.registerListeners()
@@ -30,17 +31,47 @@ class WorkspaceTracker {
3031
return
3132
}
3233
const tempCwd = this.cwd
33-
const [files, _] = await listFiles(tempCwd, true, MAX_INITIAL_FILES)
34-
if (this.prevWorkSpacePath !== tempCwd) {
35-
return
34+
const allWorkspaces = getAllWorkspacePaths()
35+
36+
// Distribute file limit across all workspaces
37+
const filesPerWorkspace = Math.ceil(MAX_INITIAL_FILES / allWorkspaces.length)
38+
for (const workspacePath of allWorkspaces) {
39+
const [files, _] = await listFiles(workspacePath, true, filesPerWorkspace)
40+
if (this.prevWorkSpacePath !== tempCwd) {
41+
return
42+
}
43+
files.slice(0, filesPerWorkspace).forEach((file) => {
44+
const absolutePath = path.resolve(workspacePath, file)
45+
this.filePaths.add(this.normalizeFilePath(absolutePath))
46+
})
3647
}
37-
files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
3848
this.workspaceDidUpdate()
3949
}
4050

4151
private registerListeners() {
42-
const watcher = vscode.workspace.createFileSystemWatcher("**")
4352
this.prevWorkSpacePath = this.cwd
53+
54+
const workspaceFolders = vscode.workspace.workspaceFolders ?? ["."]
55+
workspaceFolders.forEach((folder) => {
56+
const pattern = new vscode.RelativePattern(folder, "**")
57+
const watcher = vscode.workspace.createFileSystemWatcher(pattern)
58+
this.setupWatcherEvents(watcher)
59+
this.disposables.push(watcher)
60+
})
61+
62+
this.disposables.push(
63+
vscode.window.tabGroups.onDidChangeTabs(() => {
64+
// Reset if workspace path has changed
65+
if (this.prevWorkSpacePath !== this.cwd) {
66+
this.workspaceDidReset()
67+
} else {
68+
this.workspaceDidUpdate()
69+
}
70+
}),
71+
)
72+
}
73+
74+
private setupWatcherEvents(watcher: vscode.FileSystemWatcher) {
4475
this.disposables.push(
4576
watcher.onDidCreate(async (uri) => {
4677
await this.addFilePath(uri.fsPath)
@@ -56,21 +87,6 @@ class WorkspaceTracker {
5687
}
5788
}),
5889
)
59-
60-
this.disposables.push(watcher)
61-
62-
// Listen for tab changes and call workspaceDidUpdate directly
63-
this.disposables.push(
64-
vscode.window.tabGroups.onDidChangeTabs(() => {
65-
// Reset if workspace path has changed
66-
if (this.prevWorkSpacePath !== this.cwd) {
67-
this.workspaceDidReset()
68-
} else {
69-
// Otherwise just update
70-
this.workspaceDidUpdate()
71-
}
72-
}),
73-
)
7490
}
7591

7692
private getOpenedTabsInfo() {

src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as vscode from "vscode"
33
import WorkspaceTracker from "../WorkspaceTracker"
44
import { ClineProvider } from "../../../core/webview/ClineProvider"
55
import { listFiles } from "../../../services/glob/list-files"
6-
import { getWorkspacePath } from "../../../utils/path"
6+
import { getWorkspacePath, getAllWorkspacePaths } from "../../../utils/path"
77

88
// Mock functions - must be defined before vitest.mock calls
99
const mockOnDidCreate = vitest.fn()
@@ -16,6 +16,10 @@ let registeredTabChangeCallback: (() => Promise<void>) | null = null
1616
// Mock workspace path
1717
vitest.mock("../../../utils/path", () => ({
1818
getWorkspacePath: vitest.fn().mockReturnValue("/test/workspace"),
19+
getAllWorkspacePaths: vitest.fn().mockReturnValue(["/test/workspace"]),
20+
getPrimaryWorkspaceFolder: vitest
21+
.fn()
22+
.mockReturnValue({ uri: { fsPath: "/test/workspace" }, name: "test-workspace", index: 0 }),
1923
toRelativePath: vitest.fn((path, cwd) => {
2024
// Handle both Windows and POSIX paths by using path.relative
2125
const relativePath = require("path").relative(cwd, path)
@@ -59,6 +63,9 @@ vitest.mock("vscode", () => ({
5963
},
6064
},
6165
FileType: { File: 1, Directory: 2 },
66+
RelativePattern: vitest.fn().mockImplementation((base, pattern) => ({ base, pattern })),
67+
Uri: { file: vitest.fn((path) => ({ fsPath: path })) },
68+
TabInputText: vitest.fn(),
6269
}))
6370

6471
vitest.mock("../../../services/glob/list-files", () => ({
@@ -148,7 +155,7 @@ describe("WorkspaceTracker", () => {
148155

149156
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
150157
type: "workspaceUpdated",
151-
filePaths: expect.arrayContaining(["newdir"]),
158+
filePaths: expect.arrayContaining(["newdir/"]),
152159
openedTabs: [],
153160
})
154161
const lastCall = (mockProvider.postMessageToWebview as Mock).mock.calls.slice(-1)[0]
@@ -208,10 +215,6 @@ describe("WorkspaceTracker", () => {
208215
it("should handle workspace path changes when tabs change", async () => {
209216
expect(registeredTabChangeCallback).not.toBeNull()
210217

211-
// Set initial workspace path and create tracker
212-
;(getWorkspacePath as Mock).mockReturnValue("/test/workspace")
213-
workspaceTracker = new WorkspaceTracker(mockProvider)
214-
215218
// Clear any initialization calls
216219
vitest.clearAllMocks()
217220

@@ -221,6 +224,7 @@ describe("WorkspaceTracker", () => {
221224

222225
// Change workspace path
223226
;(getWorkspacePath as Mock).mockReturnValue("/test/new-workspace")
227+
;(getAllWorkspacePaths as Mock).mockReturnValue(["/test/new-workspace"])
224228

225229
// Simulate tab change event
226230
await registeredTabChangeCallback!()

src/services/ripgrep/__tests__/index.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// npx vitest run src/services/ripgrep/__tests__/index.spec.ts
22

3-
import { truncateLine } from "../index"
3+
import { truncateLine, regexSearchFiles } from "../index"
44

55
describe("Ripgrep line truncation", () => {
66
// The default MAX_LINE_LENGTH is 500 in the implementation
@@ -48,3 +48,20 @@ describe("Ripgrep line truncation", () => {
4848
expect(truncated).toContain("[truncated...]")
4949
})
5050
})
51+
52+
describe("Multi-workspace search", () => {
53+
it("should handle empty workspace paths array", async () => {
54+
const result = await regexSearchFiles("/mock/cwd", [], "test")
55+
expect(result).toBe("No workspace paths provided")
56+
})
57+
58+
it("should search multiple workspace paths", async () => {
59+
const workspacePaths = ["/workspace1", "/workspace2"]
60+
61+
try {
62+
await regexSearchFiles("/mock/cwd", workspacePaths, "test")
63+
} catch (error) {
64+
expect(error).toBeDefined()
65+
}
66+
})
67+
})

0 commit comments

Comments
 (0)