Skip to content

Commit aaa7d43

Browse files
authored
Merge pull request #203 from RooVetGit/file_system_watcher
Use createFileSystemWatcher to more reliably watch for file system changes
2 parents 1ac0938 + 947adc4 commit aaa7d43

File tree

5 files changed

+155
-52
lines changed

5 files changed

+155
-52
lines changed

.changeset/gentle-masks-notice.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+
Use createFileSystemWatcher to more reliably update list of files to @-mention

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
1010
- Quick prompt copying from history
1111
- OpenRouter compression support
1212
- Includes current time in the system prompt
13+
- Uses a file system watcher to more reliably watch for file system changes
1314
- Language selection for Cline's communication (English, Japanese, Spanish, French, German, and more)
1415
- Support for Meta 3, 3.1, and 3.2 models via AWS Bedrock
1516
- Per-tool MCP auto-approval

src/core/__tests__/Cline.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,16 @@ jest.mock('vscode', () => {
121121
name: 'mock-workspace',
122122
index: 0
123123
}],
124-
onDidCreateFiles: jest.fn(() => mockDisposable),
125-
onDidDeleteFiles: jest.fn(() => mockDisposable),
126-
onDidRenameFiles: jest.fn(() => mockDisposable),
127-
onDidSaveTextDocument: jest.fn(() => mockDisposable),
128-
onDidChangeTextDocument: jest.fn(() => mockDisposable),
129-
onDidOpenTextDocument: jest.fn(() => mockDisposable),
130-
onDidCloseTextDocument: jest.fn(() => mockDisposable)
124+
createFileSystemWatcher: jest.fn(() => ({
125+
onDidCreate: jest.fn(() => mockDisposable),
126+
onDidDelete: jest.fn(() => mockDisposable),
127+
onDidChange: jest.fn(() => mockDisposable),
128+
dispose: jest.fn()
129+
})),
130+
fs: {
131+
stat: jest.fn().mockResolvedValue({ type: 1 }) // FileType.File = 1
132+
},
133+
onDidSaveTextDocument: jest.fn(() => mockDisposable)
131134
},
132135
env: {
133136
uriScheme: 'vscode',

src/integrations/workspace/WorkspaceTracker.ts

Lines changed: 23 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -27,58 +27,36 @@ class WorkspaceTracker {
2727
}
2828

2929
private registerListeners() {
30-
// Listen for file creation
31-
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
32-
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
33-
34-
// Listen for file deletion
35-
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
30+
// Create a file system watcher for all files
31+
const watcher = vscode.workspace.createFileSystemWatcher('**')
3632

37-
// Listen for file renaming
38-
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
39-
40-
/*
41-
An event that is emitted when a workspace folder is added or removed.
42-
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
43-
because in that case the currently executing extensions (including the one that listens to this
44-
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
45-
to point to the first workspace folder.
46-
*/
47-
// 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)
48-
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
49-
}
50-
51-
private async onFilesCreated(event: vscode.FileCreateEvent) {
52-
await Promise.all(
53-
event.files.map(async (file) => {
54-
await this.addFilePath(file.fsPath)
55-
}),
33+
// Listen for file creation
34+
this.disposables.push(
35+
watcher.onDidCreate(async (uri) => {
36+
await this.addFilePath(uri.fsPath)
37+
this.workspaceDidUpdate()
38+
})
5639
)
57-
this.workspaceDidUpdate()
58-
}
5940

60-
private async onFilesDeleted(event: vscode.FileDeleteEvent) {
61-
let updated = false
62-
await Promise.all(
63-
event.files.map(async (file) => {
64-
if (await this.removeFilePath(file.fsPath)) {
65-
updated = true
41+
// Listen for file deletion
42+
this.disposables.push(
43+
watcher.onDidDelete(async (uri) => {
44+
if (await this.removeFilePath(uri.fsPath)) {
45+
this.workspaceDidUpdate()
6646
}
67-
}),
47+
})
6848
)
69-
if (updated) {
70-
this.workspaceDidUpdate()
71-
}
72-
}
7349

74-
private async onFilesRenamed(event: vscode.FileRenameEvent) {
75-
await Promise.all(
76-
event.files.map(async (file) => {
77-
await this.removeFilePath(file.oldUri.fsPath)
78-
await this.addFilePath(file.newUri.fsPath)
79-
}),
50+
// Listen for file changes (which could include renames)
51+
this.disposables.push(
52+
watcher.onDidChange(async (uri) => {
53+
await this.addFilePath(uri.fsPath)
54+
this.workspaceDidUpdate()
55+
})
8056
)
81-
this.workspaceDidUpdate()
57+
58+
// Add the watcher itself to disposables
59+
this.disposables.push(watcher)
8260
}
8361

8462
private workspaceDidUpdate() {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as vscode from "vscode"
2+
import WorkspaceTracker from "../WorkspaceTracker"
3+
import { ClineProvider } from "../../../core/webview/ClineProvider"
4+
import { listFiles } from "../../../services/glob/list-files"
5+
6+
// Mock modules
7+
const mockOnDidCreate = jest.fn()
8+
const mockOnDidDelete = jest.fn()
9+
const mockOnDidChange = jest.fn()
10+
const mockDispose = jest.fn()
11+
12+
const mockWatcher = {
13+
onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }),
14+
onDidDelete: mockOnDidDelete.mockReturnValue({ dispose: mockDispose }),
15+
onDidChange: mockOnDidChange.mockReturnValue({ dispose: mockDispose }),
16+
dispose: mockDispose
17+
}
18+
19+
jest.mock("vscode", () => ({
20+
workspace: {
21+
workspaceFolders: [{
22+
uri: { fsPath: "/test/workspace" },
23+
name: "test",
24+
index: 0
25+
}],
26+
createFileSystemWatcher: jest.fn(() => mockWatcher),
27+
fs: {
28+
stat: jest.fn().mockResolvedValue({ type: 1 }) // FileType.File = 1
29+
}
30+
},
31+
FileType: { File: 1, Directory: 2 }
32+
}))
33+
34+
jest.mock("../../../services/glob/list-files")
35+
36+
describe("WorkspaceTracker", () => {
37+
let workspaceTracker: WorkspaceTracker
38+
let mockProvider: ClineProvider
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks()
42+
43+
// Create provider mock
44+
mockProvider = { postMessageToWebview: jest.fn() } as any
45+
46+
// Create tracker instance
47+
workspaceTracker = new WorkspaceTracker(mockProvider)
48+
})
49+
50+
it("should initialize with workspace files", async () => {
51+
const mockFiles = [["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false]
52+
;(listFiles as jest.Mock).mockResolvedValue(mockFiles)
53+
54+
await workspaceTracker.initializeFilePaths()
55+
56+
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
57+
type: "workspaceUpdated",
58+
filePaths: ["file1.ts", "file2.ts"]
59+
})
60+
})
61+
62+
it("should handle file creation events", async () => {
63+
// Get the creation callback and call it
64+
const [[callback]] = mockOnDidCreate.mock.calls
65+
await callback({ fsPath: "/test/workspace/newfile.ts" })
66+
67+
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
68+
type: "workspaceUpdated",
69+
filePaths: ["newfile.ts"]
70+
})
71+
})
72+
73+
it("should handle file deletion events", async () => {
74+
// First add a file
75+
const [[createCallback]] = mockOnDidCreate.mock.calls
76+
await createCallback({ fsPath: "/test/workspace/file.ts" })
77+
78+
// Then delete it
79+
const [[deleteCallback]] = mockOnDidDelete.mock.calls
80+
await deleteCallback({ fsPath: "/test/workspace/file.ts" })
81+
82+
// The last call should have empty filePaths
83+
expect(mockProvider.postMessageToWebview).toHaveBeenLastCalledWith({
84+
type: "workspaceUpdated",
85+
filePaths: []
86+
})
87+
})
88+
89+
it("should handle file change events", async () => {
90+
const [[callback]] = mockOnDidChange.mock.calls
91+
await callback({ fsPath: "/test/workspace/changed.ts" })
92+
93+
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
94+
type: "workspaceUpdated",
95+
filePaths: ["changed.ts"]
96+
})
97+
})
98+
99+
it("should handle directory paths correctly", async () => {
100+
// Mock stat to return directory type
101+
;(vscode.workspace.fs.stat as jest.Mock).mockResolvedValueOnce({ type: 2 }) // FileType.Directory = 2
102+
103+
const [[callback]] = mockOnDidCreate.mock.calls
104+
await callback({ fsPath: "/test/workspace/newdir" })
105+
106+
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
107+
type: "workspaceUpdated",
108+
filePaths: ["newdir"]
109+
})
110+
})
111+
112+
it("should clean up watchers on dispose", () => {
113+
workspaceTracker.dispose()
114+
expect(mockDispose).toHaveBeenCalled()
115+
})
116+
})

0 commit comments

Comments
 (0)