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/gentle-masks-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"roo-cline": patch
---

Use createFileSystemWatcher to more reliably update list of files to @-mention
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
- Quick prompt copying from history
- OpenRouter compression support
- Includes current time in the system prompt
- Uses a file system watcher to more reliably watch for file system changes
- Language selection for Cline's communication (English, Japanese, Spanish, French, German, and more)
- Support for Meta 3, 3.1, and 3.2 models via AWS Bedrock
- Per-tool MCP auto-approval
Expand Down
17 changes: 10 additions & 7 deletions src/core/__tests__/Cline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,16 @@ jest.mock('vscode', () => {
name: 'mock-workspace',
index: 0
}],
onDidCreateFiles: jest.fn(() => mockDisposable),
onDidDeleteFiles: jest.fn(() => mockDisposable),
onDidRenameFiles: jest.fn(() => mockDisposable),
onDidSaveTextDocument: jest.fn(() => mockDisposable),
onDidChangeTextDocument: jest.fn(() => mockDisposable),
onDidOpenTextDocument: jest.fn(() => mockDisposable),
onDidCloseTextDocument: jest.fn(() => mockDisposable)
createFileSystemWatcher: jest.fn(() => ({
onDidCreate: jest.fn(() => mockDisposable),
onDidDelete: jest.fn(() => mockDisposable),
onDidChange: jest.fn(() => mockDisposable),
dispose: jest.fn()
})),
fs: {
stat: jest.fn().mockResolvedValue({ type: 1 }) // FileType.File = 1
},
onDidSaveTextDocument: jest.fn(() => mockDisposable)
},
env: {
uriScheme: 'vscode',
Expand Down
68 changes: 23 additions & 45 deletions src/integrations/workspace/WorkspaceTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,58 +27,36 @@ class WorkspaceTracker {
}

private registerListeners() {
// Listen for file creation
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))

// Listen for file deletion
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
// Create a file system watcher for all files
const watcher = vscode.workspace.createFileSystemWatcher('**')

// Listen for file renaming
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))

/*
An event that is emitted when a workspace folder is added or removed.
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
because in that case the currently executing extensions (including the one that listens to this
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
to point to the first workspace folder.
*/
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
}

private async onFilesCreated(event: vscode.FileCreateEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.addFilePath(file.fsPath)
}),
// Listen for file creation
this.disposables.push(
watcher.onDidCreate(async (uri) => {
await this.addFilePath(uri.fsPath)
this.workspaceDidUpdate()
})
)
this.workspaceDidUpdate()
}

private async onFilesDeleted(event: vscode.FileDeleteEvent) {
let updated = false
await Promise.all(
event.files.map(async (file) => {
if (await this.removeFilePath(file.fsPath)) {
updated = true
// Listen for file deletion
this.disposables.push(
watcher.onDidDelete(async (uri) => {
if (await this.removeFilePath(uri.fsPath)) {
this.workspaceDidUpdate()
}
}),
})
)
if (updated) {
this.workspaceDidUpdate()
}
}

private async onFilesRenamed(event: vscode.FileRenameEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.removeFilePath(file.oldUri.fsPath)
await this.addFilePath(file.newUri.fsPath)
}),
// Listen for file changes (which could include renames)
this.disposables.push(
watcher.onDidChange(async (uri) => {
await this.addFilePath(uri.fsPath)
this.workspaceDidUpdate()
})
)
this.workspaceDidUpdate()

// Add the watcher itself to disposables
this.disposables.push(watcher)
}

private workspaceDidUpdate() {
Expand Down
116 changes: 116 additions & 0 deletions src/integrations/workspace/__tests__/WorkspaceTracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as vscode from "vscode"
import WorkspaceTracker from "../WorkspaceTracker"
import { ClineProvider } from "../../../core/webview/ClineProvider"
import { listFiles } from "../../../services/glob/list-files"

// Mock modules
const mockOnDidCreate = jest.fn()
const mockOnDidDelete = jest.fn()
const mockOnDidChange = jest.fn()
const mockDispose = jest.fn()

const mockWatcher = {
onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }),
onDidDelete: mockOnDidDelete.mockReturnValue({ dispose: mockDispose }),
onDidChange: mockOnDidChange.mockReturnValue({ dispose: mockDispose }),
dispose: mockDispose
}

jest.mock("vscode", () => ({
workspace: {
workspaceFolders: [{
uri: { fsPath: "/test/workspace" },
name: "test",
index: 0
}],
createFileSystemWatcher: jest.fn(() => mockWatcher),
fs: {
stat: jest.fn().mockResolvedValue({ type: 1 }) // FileType.File = 1
}
},
FileType: { File: 1, Directory: 2 }
}))

jest.mock("../../../services/glob/list-files")

describe("WorkspaceTracker", () => {
let workspaceTracker: WorkspaceTracker
let mockProvider: ClineProvider

beforeEach(() => {
jest.clearAllMocks()

// Create provider mock
mockProvider = { postMessageToWebview: jest.fn() } as any

// Create tracker instance
workspaceTracker = new WorkspaceTracker(mockProvider)
})

it("should initialize with workspace files", async () => {
const mockFiles = [["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false]
;(listFiles as jest.Mock).mockResolvedValue(mockFiles)

await workspaceTracker.initializeFilePaths()

expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: ["file1.ts", "file2.ts"]
})
})

it("should handle file creation events", async () => {
// Get the creation callback and call it
const [[callback]] = mockOnDidCreate.mock.calls
await callback({ fsPath: "/test/workspace/newfile.ts" })

expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: ["newfile.ts"]
})
})

it("should handle file deletion events", async () => {
// First add a file
const [[createCallback]] = mockOnDidCreate.mock.calls
await createCallback({ fsPath: "/test/workspace/file.ts" })

// Then delete it
const [[deleteCallback]] = mockOnDidDelete.mock.calls
await deleteCallback({ fsPath: "/test/workspace/file.ts" })

// The last call should have empty filePaths
expect(mockProvider.postMessageToWebview).toHaveBeenLastCalledWith({
type: "workspaceUpdated",
filePaths: []
})
})

it("should handle file change events", async () => {
const [[callback]] = mockOnDidChange.mock.calls
await callback({ fsPath: "/test/workspace/changed.ts" })

expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: ["changed.ts"]
})
})

it("should handle directory paths correctly", async () => {
// Mock stat to return directory type
;(vscode.workspace.fs.stat as jest.Mock).mockResolvedValueOnce({ type: 2 }) // FileType.Directory = 2

const [[callback]] = mockOnDidCreate.mock.calls
await callback({ fsPath: "/test/workspace/newdir" })

expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "workspaceUpdated",
filePaths: ["newdir"]
})
})

it("should clean up watchers on dispose", () => {
workspaceTracker.dispose()
expect(mockDispose).toHaveBeenCalled()
})
})
Loading