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 <yueminxiong.xym@alibaba-inc.com>
This commit is contained in:
teddyOOXX 2025-03-18 14:14:39 +08:00 committed by GitHub
parent a4853f2faa
commit 23af4c2609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 369 additions and 79 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
add multiple workspaces support

View file

@ -78,9 +78,7 @@ import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy"
import { insertGroups } from "./diff/insert-groups"
import { telemetryService } from "../services/telemetry/TelemetryService"
import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
import { getWorkspacePath } from "../utils/path"
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@ -118,7 +116,9 @@ export type ClineOptions = {
export class Cline extends EventEmitter<ClineEvents> {
readonly taskId: string
readonly instanceId: string
get cwd() {
return getWorkspacePath(path.join(os.homedir(), "Desktop"))
}
// Subtasks
readonly rootTask: Cline | undefined = undefined
readonly parentTask: Cline | undefined = undefined
@ -195,7 +195,7 @@ export class Cline extends EventEmitter<ClineEvents> {
throw new Error("Either historyItem or task/images must be provided")
}
this.rooIgnoreController = new RooIgnoreController(cwd)
this.rooIgnoreController = new RooIgnoreController(this.cwd)
this.rooIgnoreController.initialize().catch((error) => {
console.error("Failed to initialize RooIgnoreController:", error)
})
@ -211,7 +211,7 @@ export class Cline extends EventEmitter<ClineEvents> {
this.diffEnabled = enableDiff ?? false
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
this.providerRef = new WeakRef(provider)
this.diffViewProvider = new DiffViewProvider(cwd)
this.diffViewProvider = new DiffViewProvider(this.cwd)
this.enableCheckpoints = enableCheckpoints
this.checkpointStorage = checkpointStorage
@ -844,7 +844,7 @@ export class Cline extends EventEmitter<ClineEvents> {
newUserContent.push({
type: "text",
text:
`[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${
`[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${this.cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.${
wasRecent
? "\n\nIMPORTANT: If the last tool use was a write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
: ""
@ -941,11 +941,11 @@ export class Cline extends EventEmitter<ClineEvents> {
async executeCommandTool(command: string, customCwd?: string): Promise<[boolean, ToolResponse]> {
let workingDir: string
if (!customCwd) {
workingDir = cwd
workingDir = this.cwd
} else if (path.isAbsolute(customCwd)) {
workingDir = customCwd
} else {
workingDir = path.resolve(cwd, customCwd)
workingDir = path.resolve(this.cwd, customCwd)
}
// Check if directory exists
@ -1125,7 +1125,7 @@ export class Cline extends EventEmitter<ClineEvents> {
}
return SYSTEM_PROMPT(
provider.context,
cwd,
this.cwd,
(this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true),
mcpHub,
this.diffStrategy,
@ -1549,7 +1549,7 @@ export class Cline extends EventEmitter<ClineEvents> {
if (this.diffViewProvider.editType !== undefined) {
fileExists = this.diffViewProvider.editType === "modify"
} else {
const absolutePath = path.resolve(cwd, relPath)
const absolutePath = path.resolve(this.cwd, relPath)
fileExists = await fileExistsAtPath(absolutePath)
this.diffViewProvider.editType = fileExists ? "modify" : "create"
}
@ -1579,7 +1579,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
@ -1696,7 +1696,7 @@ export class Cline extends EventEmitter<ClineEvents> {
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
path: getReadablePath(this.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
@ -1732,7 +1732,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
@ -1769,7 +1769,7 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
const absolutePath = path.resolve(cwd, relPath)
const absolutePath = path.resolve(this.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
if (!fileExists) {
@ -1865,7 +1865,7 @@ export class Cline extends EventEmitter<ClineEvents> {
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
path: getReadablePath(this.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
@ -1904,7 +1904,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
@ -1927,7 +1927,7 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
const absolutePath = path.resolve(cwd, relPath)
const absolutePath = path.resolve(this.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
if (!fileExists) {
@ -2021,7 +2021,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const userFeedbackDiff = JSON.stringify({
tool: "appliedDiff",
path: getReadablePath(cwd, relPath),
path: getReadablePath(this.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool)
@ -2051,7 +2051,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
@ -2078,7 +2078,7 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
const absolutePath = path.resolve(cwd, relPath)
const absolutePath = path.resolve(this.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
if (!fileExists) {
@ -2183,7 +2183,7 @@ export class Cline extends EventEmitter<ClineEvents> {
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
path: getReadablePath(this.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
@ -2216,7 +2216,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const relPath: string | undefined = block.params.path
const sharedMessageProps: ClineSayTool = {
tool: "readFile",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
@ -2242,7 +2242,7 @@ export class Cline extends EventEmitter<ClineEvents> {
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relPath)
const absolutePath = path.resolve(this.cwd, relPath)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: absolutePath,
@ -2267,7 +2267,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const recursive = recursiveRaw?.toLowerCase() === "true"
const sharedMessageProps: ClineSayTool = {
tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive",
path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
}
try {
if (block.partial) {
@ -2284,7 +2284,7 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const absolutePath = path.resolve(this.cwd, relDirPath)
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {}
const result = formatResponse.formatFilesList(
@ -2314,7 +2314,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const relDirPath: string | undefined = block.params.path
const sharedMessageProps: ClineSayTool = {
tool: "listCodeDefinitionNames",
path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
}
try {
if (block.partial) {
@ -2333,7 +2333,7 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const absolutePath = path.resolve(this.cwd, relDirPath)
const result = await parseSourceCodeForDefinitionsTopLevel(
absolutePath,
this.rooIgnoreController,
@ -2360,7 +2360,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const filePattern: string | undefined = block.params.file_pattern
const sharedMessageProps: ClineSayTool = {
tool: "searchFiles",
path: getReadablePath(cwd, removeClosingTag("path", relDirPath)),
path: getReadablePath(this.cwd, removeClosingTag("path", relDirPath)),
regex: removeClosingTag("regex", regex),
filePattern: removeClosingTag("file_pattern", filePattern),
}
@ -2384,9 +2384,9 @@ export class Cline extends EventEmitter<ClineEvents> {
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cwd, relDirPath)
const absolutePath = path.resolve(this.cwd, relDirPath)
const results = await regexSearchFiles(
cwd,
this.cwd,
absolutePath,
regex,
filePattern,
@ -3469,7 +3469,7 @@ export class Cline extends EventEmitter<ClineEvents> {
if (shouldProcessMentions(block.text)) {
return {
...block,
text: await parseMentions(block.text, cwd, this.urlContentFetcher),
text: await parseMentions(block.text, this.cwd, this.urlContentFetcher),
}
}
return block
@ -3478,7 +3478,7 @@ export class Cline extends EventEmitter<ClineEvents> {
if (shouldProcessMentions(block.content)) {
return {
...block,
content: await parseMentions(block.content, cwd, this.urlContentFetcher),
content: await parseMentions(block.content, this.cwd, this.urlContentFetcher),
}
}
return block
@ -3488,7 +3488,11 @@ export class Cline extends EventEmitter<ClineEvents> {
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
return {
...contentBlock,
text: await parseMentions(contentBlock.text, cwd, this.urlContentFetcher),
text: await parseMentions(
contentBlock.text,
this.cwd,
this.urlContentFetcher,
),
}
}
return contentBlock
@ -3518,7 +3522,7 @@ export class Cline extends EventEmitter<ClineEvents> {
const visibleFilePaths = vscode.window.visibleTextEditors
?.map((editor) => editor.document?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath))
.map((absolutePath) => path.relative(this.cwd, absolutePath))
.slice(0, maxWorkspaceFiles ?? 200)
// Filter paths through rooIgnoreController
@ -3539,7 +3543,7 @@ export class Cline extends EventEmitter<ClineEvents> {
.flatMap((group) => group.tabs)
.map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cwd, absolutePath).toPosix())
.map((absolutePath) => path.relative(this.cwd, absolutePath).toPosix())
.slice(0, maxTabs)
// Filter paths through rooIgnoreController
@ -3582,7 +3586,7 @@ export class Cline extends EventEmitter<ClineEvents> {
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error)
if (problems.length > 0) {
diagnosticsDetails += `\n## ${path.relative(cwd, uri.fsPath)}`
diagnosticsDetails += `\n## ${path.relative(this.cwd, uri.fsPath)}`
for (const diagnostic of problems) {
// let severity = diagnostic.severity === vscode.DiagnosticSeverity.Error ? "Error" : "Warning"
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
@ -3696,7 +3700,7 @@ export class Cline extends EventEmitter<ClineEvents> {
} = (await this.providerRef.deref()?.getState()) ?? {}
const currentMode = mode ?? defaultModeSlug
const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
cwd,
cwd: this.cwd,
globalCustomInstructions,
language: language ?? formatLanguage(vscode.env.language),
})
@ -3723,17 +3727,17 @@ export class Cline extends EventEmitter<ClineEvents> {
}
if (includeFileDetails) {
details += `\n\n# Current Working Directory (${cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(cwd, path.join(os.homedir(), "Desktop"))
details += `\n\n# Current Working Directory (${this.cwd.toPosix()}) Files\n`
const isDesktop = arePathsEqual(this.cwd, path.join(os.homedir(), "Desktop"))
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 maxFiles = maxWorkspaceFiles ?? 200
const [files, didHitLimit] = await listFiles(cwd, true, maxFiles)
const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles)
const { showRooIgnoredFiles } = (await this.providerRef.deref()?.getState()) ?? {}
const result = formatResponse.formatFilesList(
cwd,
this.cwd,
files,
didHitLimit,
this.rooIgnoreController,
@ -3768,7 +3772,7 @@ export class Cline extends EventEmitter<ClineEvents> {
}
try {
const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const workspaceDir = getWorkspacePath()
if (!workspaceDir) {
log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")

View file

@ -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"
@ -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
@ -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}`, {

View file

@ -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
@ -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
})
@ -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()

View file

@ -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,
@ -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,
@ -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
@ -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", () => {
@ -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")
})
@ -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,

View file

@ -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
}

View file

@ -63,6 +63,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
@ -87,7 +88,9 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
private contextProxy: ContextProxy
configManager: ConfigManager
customModesManager: CustomModesManager
get cwd() {
return getWorkspacePath()
}
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
@ -501,7 +504,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
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,
@ -1685,7 +1688,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}
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)
@ -1953,7 +1956,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
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()
@ -2301,13 +2304,10 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
// 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 })
@ -2395,7 +2395,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
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 ?? "",

View file

@ -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,(.+)$/)
@ -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")
}

View file

@ -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
@ -13,7 +14,12 @@ 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()
@ -21,17 +27,21 @@ class WorkspaceTracker {
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)
@ -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() {
@ -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,
@ -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
}
@ -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())
}
}

View file

@ -2,25 +2,40 @@ import * as vscode from "vscode"
import WorkspaceTracker from "../WorkspaceTracker"
import { ClineProvider } from "../../../core/webview/ClineProvider"
import { listFiles } from "../../../services/glob/list-files"
import { getWorkspacePath } from "../../../utils/path"
// Mock modules
// Mock functions - must be defined before jest.mock calls
const mockOnDidCreate = jest.fn()
const mockOnDidDelete = jest.fn()
const mockOnDidChange = jest.fn()
const mockDispose = jest.fn()
// Store registered tab change callback
let registeredTabChangeCallback: (() => Promise<void>) | null = null
// Mock workspace path
jest.mock("../../../utils/path", () => ({
getWorkspacePath: jest.fn().mockReturnValue("/test/workspace"),
toRelativePath: jest.fn((path, cwd) => path.replace(`${cwd}/`, "")),
}))
// Mock watcher - must be defined after mockDispose but before jest.mock("vscode")
const mockWatcher = {
onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }),
onDidDelete: mockOnDidDelete.mockReturnValue({ dispose: mockDispose }),
dispose: mockDispose,
}
// Mock vscode
jest.mock("vscode", () => ({
window: {
tabGroups: {
onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })),
onDidChangeTabs: jest.fn((callback) => {
registeredTabChangeCallback = callback
return { dispose: mockDispose }
}),
all: [],
},
onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })),
},
workspace: {
workspaceFolders: [
@ -48,6 +63,12 @@ describe("WorkspaceTracker", () => {
jest.clearAllMocks()
jest.useFakeTimers()
// Reset all mock implementations
registeredTabChangeCallback = null
// Reset workspace path mock
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
// Create provider mock
mockProvider = {
postMessageToWebview: jest.fn().mockResolvedValue(undefined),
@ -55,6 +76,9 @@ describe("WorkspaceTracker", () => {
// Create tracker instance
workspaceTracker = new WorkspaceTracker(mockProvider)
// Ensure the tab change callback was registered
expect(registeredTabChangeCallback).not.toBeNull()
})
it("should initialize with workspace files", async () => {
@ -159,8 +183,148 @@ describe("WorkspaceTracker", () => {
})
it("should clean up watchers and timers on dispose", () => {
// Set up updateTimer
const [[callback]] = mockOnDidCreate.mock.calls
callback({ fsPath: "/test/workspace/file.ts" })
workspaceTracker.dispose()
expect(mockDispose).toHaveBeenCalled()
jest.runAllTimers() // Ensure any pending timers are cleared
// No more updates should happen after dispose
expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
})
it("should handle workspace path changes when tabs change", async () => {
expect(registeredTabChangeCallback).not.toBeNull()
// Set initial workspace path and create tracker
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
workspaceTracker = new WorkspaceTracker(mockProvider)
// Clear any initialization calls
jest.clearAllMocks()
// Mock listFiles to return some files
const mockFiles = [["/test/new-workspace/file1.ts"], false]
;(listFiles as jest.Mock).mockResolvedValue(mockFiles)
// Change workspace path
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace")
// Simulate tab change event
await registeredTabChangeCallback!()
// Run the debounce timer for workspaceDidReset
jest.advanceTimersByTime(300)
// Should clear file paths and reset workspace
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: [],
openedTabs: [],
})
// Run all remaining timers to complete initialization
await Promise.resolve() // Wait for initializeFilePaths to complete
jest.runAllTimers()
// Should initialize file paths for new workspace
expect(listFiles).toHaveBeenCalledWith("/test/new-workspace", true, 1000)
jest.runAllTimers()
})
it("should not update file paths if workspace changes during initialization", async () => {
// Setup initial workspace path
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
workspaceTracker = new WorkspaceTracker(mockProvider)
// Clear any initialization calls
jest.clearAllMocks()
;(mockProvider.postMessageToWebview as jest.Mock).mockClear()
// Create a promise to control listFiles timing
let resolveListFiles: (value: [string[], boolean]) => void
const listFilesPromise = new Promise<[string[], boolean]>((resolve) => {
resolveListFiles = resolve
})
// Setup listFiles to use our controlled promise
;(listFiles as jest.Mock).mockImplementation(() => {
// Change workspace path before listFiles resolves
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/changed-workspace")
return listFilesPromise
})
// Start initialization
const initPromise = workspaceTracker.initializeFilePaths()
// Resolve listFiles after workspace path change
resolveListFiles!([["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false])
// Wait for initialization to complete
await initPromise
jest.runAllTimers()
// Should not update file paths because workspace changed during initialization
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
filePaths: ["/test/workspace/file1.ts", "/test/workspace/file2.ts"],
openedTabs: [],
type: "workspaceUpdated",
})
})
it("should clear resetTimer when calling workspaceDidReset multiple times", async () => {
expect(registeredTabChangeCallback).not.toBeNull()
// Set initial workspace path
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
// Create tracker instance to set initial prevWorkSpacePath
workspaceTracker = new WorkspaceTracker(mockProvider)
// Change workspace path to trigger update
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/new-workspace")
// Call workspaceDidReset through tab change event
await registeredTabChangeCallback!()
// Call again before timer completes
await registeredTabChangeCallback!()
// Advance timer
jest.advanceTimersByTime(300)
// Should only have one call to postMessageToWebview
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: [],
openedTabs: [],
})
expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(1)
})
it("should handle dispose with active resetTimer", async () => {
expect(registeredTabChangeCallback).not.toBeNull()
// Mock workspace path change to trigger resetTimer
;(getWorkspacePath as jest.Mock)
.mockReturnValueOnce("/test/workspace")
.mockReturnValueOnce("/test/new-workspace")
// Trigger resetTimer
await registeredTabChangeCallback!()
// Dispose before timer completes
workspaceTracker.dispose()
// Advance timer
jest.advanceTimersByTime(300)
// Should have called dispose on all disposables
expect(mockDispose).toHaveBeenCalled()
// No postMessage should be called after dispose
expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
})
})

View file

@ -1,12 +1,37 @@
// npx jest src/utils/__tests__/path.test.ts
import os from "os"
import * as path from "path"
import { arePathsEqual, getReadablePath } from "../path"
import { arePathsEqual, getReadablePath, getWorkspacePath } from "../path"
// Mock modules
jest.mock("vscode", () => ({
window: {
activeTextEditor: {
document: {
uri: { fsPath: "/test/workspaceFolder/file.ts" },
},
},
},
workspace: {
workspaceFolders: [
{
uri: { fsPath: "/test/workspace" },
name: "test",
index: 0,
},
],
getWorkspaceFolder: jest.fn().mockReturnValue({
uri: {
fsPath: "/test/workspaceFolder",
},
}),
},
}))
describe("Path Utilities", () => {
const originalPlatform = process.platform
// Helper to mock VS Code configuration
afterEach(() => {
Object.defineProperty(process, "platform", {
@ -30,7 +55,14 @@ 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("/test/workspaceFolder")
})
it("should return undefined when outside a workspace", () => {})
})
describe("arePathsEqual", () => {
describe("on Windows", () => {
beforeEach(() => {

View file

@ -1,5 +1,6 @@
import * as path from "path"
import os from "os"
import * as vscode from "vscode"
/*
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@ -104,3 +105,13 @@ export const toRelativePath = (filePath: string, cwd: string) => {
const relativePath = path.relative(cwd, filePath).toPosix()
return filePath.endsWith("/") ? relativePath + "/" : relativePath
}
export const getWorkspacePath = (defaultCwdPath = "") => {
const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
const currentFileUri = vscode.window.activeTextEditor?.document.uri
if (currentFileUri) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
return workspaceFolder?.uri.fsPath || cwdPath
}
return cwdPath
}