Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 7552581

Browse files
roomote[bot]roomotehannesrudolph
authored
feat: filter @ mention file search results using .rooignore (#10174)
* feat: filter @ mention file search results using .rooignore - Modify searchFiles case in webviewMessageHandler.ts to filter results using RooIgnoreController - Use existing RooIgnoreController from current task if available, otherwise create a temporary one - Respect showRooIgnoredFiles setting to allow users to toggle this behavior - Add comprehensive test coverage for the new filtering behavior Fixes #10169 * fix: dispose temporary RooIgnoreController to prevent resource leak Addresses Rooviewer feedback: the temporary RooIgnoreController created when no task exists was never disposed, causing file watchers to accumulate. Changes: - Track temporary controller separately with tempController variable - Wrap filtering logic in try/finally block - Call dispose() in finally block to ensure cleanup - Add test cases to verify dispose is called for temp controllers - Verify task's controller is NOT disposed (only temp ones) --------- Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
1 parent e3b90fb commit 7552581

2 files changed

Lines changed: 331 additions & 6 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// npx vitest core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
2+
3+
import type { Mock } from "vitest"
4+
5+
// Mock dependencies - must come before imports
6+
vi.mock("../../../services/search/file-search")
7+
vi.mock("../../ignore/RooIgnoreController")
8+
9+
import { webviewMessageHandler } from "../webviewMessageHandler"
10+
import type { ClineProvider } from "../ClineProvider"
11+
import { searchWorkspaceFiles } from "../../../services/search/file-search"
12+
import { RooIgnoreController } from "../../ignore/RooIgnoreController"
13+
14+
const mockSearchWorkspaceFiles = searchWorkspaceFiles as Mock<typeof searchWorkspaceFiles>
15+
16+
vi.mock("vscode", () => ({
17+
window: {
18+
showInformationMessage: vi.fn(),
19+
showErrorMessage: vi.fn(),
20+
},
21+
workspace: {
22+
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
23+
},
24+
}))
25+
26+
describe("webviewMessageHandler - searchFiles with RooIgnore filtering", () => {
27+
let mockClineProvider: ClineProvider
28+
let mockFilterPaths: Mock
29+
let mockDispose: Mock
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
34+
// Spy on the mock RooIgnoreController prototype methods
35+
mockFilterPaths = vi.fn()
36+
mockDispose = vi.fn()
37+
38+
// Override the filterPaths method on the prototype
39+
;(RooIgnoreController.prototype as any).filterPaths = mockFilterPaths
40+
;(RooIgnoreController.prototype as any).initialize = vi.fn().mockResolvedValue(undefined)
41+
;(RooIgnoreController.prototype as any).dispose = mockDispose
42+
43+
// Create mock ClineProvider
44+
mockClineProvider = {
45+
getState: vi.fn(),
46+
postMessageToWebview: vi.fn(),
47+
getCurrentTask: vi.fn(),
48+
cwd: "/mock/workspace",
49+
} as unknown as ClineProvider
50+
})
51+
52+
it("should filter results using RooIgnoreController when showRooIgnoredFiles is false", async () => {
53+
// Setup mock results from file search
54+
const mockResults = [
55+
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
56+
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
57+
{ path: "src/utils.ts", type: "file" as const, label: "utils.ts" },
58+
]
59+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
60+
61+
// Setup state with showRooIgnoredFiles = false
62+
;(mockClineProvider.getState as Mock).mockResolvedValue({
63+
showRooIgnoredFiles: false,
64+
})
65+
66+
// Setup filter to exclude secrets folder
67+
mockFilterPaths.mockReturnValue(["src/index.ts", "src/utils.ts"])
68+
69+
// No current task, so temporary controller will be created
70+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
71+
72+
await webviewMessageHandler(mockClineProvider, {
73+
type: "searchFiles",
74+
query: "index",
75+
requestId: "test-request-123",
76+
})
77+
78+
// Verify filterPaths was called with all result paths
79+
expect(mockFilterPaths).toHaveBeenCalledWith(["src/index.ts", "secrets/config.json", "src/utils.ts"])
80+
81+
// Verify filtered results were sent to webview
82+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
83+
type: "fileSearchResults",
84+
results: [
85+
{ path: "src/index.ts", type: "file", label: "index.ts" },
86+
{ path: "src/utils.ts", type: "file", label: "utils.ts" },
87+
],
88+
requestId: "test-request-123",
89+
})
90+
})
91+
92+
it("should not filter results when showRooIgnoredFiles is true", async () => {
93+
// Setup mock results from file search
94+
const mockResults = [
95+
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
96+
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
97+
]
98+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
99+
100+
// Setup state with showRooIgnoredFiles = true
101+
;(mockClineProvider.getState as Mock).mockResolvedValue({
102+
showRooIgnoredFiles: true,
103+
})
104+
105+
// No current task
106+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
107+
108+
await webviewMessageHandler(mockClineProvider, {
109+
type: "searchFiles",
110+
query: "index",
111+
requestId: "test-request-456",
112+
})
113+
114+
// Verify filterPaths was NOT called
115+
expect(mockFilterPaths).not.toHaveBeenCalled()
116+
117+
// Verify all results were sent to webview (unfiltered)
118+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
119+
type: "fileSearchResults",
120+
results: mockResults,
121+
requestId: "test-request-456",
122+
})
123+
})
124+
125+
it("should use existing RooIgnoreController from current task", async () => {
126+
// Setup mock results from file search
127+
const mockResults = [
128+
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
129+
{ path: "private/secret.ts", type: "file" as const, label: "secret.ts" },
130+
]
131+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
132+
133+
// Setup state with showRooIgnoredFiles = false
134+
;(mockClineProvider.getState as Mock).mockResolvedValue({
135+
showRooIgnoredFiles: false,
136+
})
137+
138+
// Create a mock task with its own RooIgnoreController
139+
const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
140+
const taskRooIgnoreController = {
141+
filterPaths: taskFilterPaths,
142+
initialize: vi.fn(),
143+
}
144+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
145+
taskId: "test-task-id",
146+
rooIgnoreController: taskRooIgnoreController,
147+
})
148+
149+
await webviewMessageHandler(mockClineProvider, {
150+
type: "searchFiles",
151+
query: "index",
152+
requestId: "test-request-789",
153+
})
154+
155+
// Verify the task's controller was used (not the prototype)
156+
expect(taskFilterPaths).toHaveBeenCalledWith(["src/index.ts", "private/secret.ts"])
157+
158+
// Verify filtered results were sent to webview
159+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
160+
type: "fileSearchResults",
161+
results: [{ path: "src/index.ts", type: "file", label: "index.ts" }],
162+
requestId: "test-request-789",
163+
})
164+
})
165+
166+
it("should handle error when no workspace path is available", async () => {
167+
// Create provider without cwd
168+
mockClineProvider = {
169+
...mockClineProvider,
170+
cwd: undefined,
171+
getCurrentTask: vi.fn().mockReturnValue(null),
172+
} as unknown as ClineProvider
173+
174+
await webviewMessageHandler(mockClineProvider, {
175+
type: "searchFiles",
176+
query: "test",
177+
requestId: "test-request-error",
178+
})
179+
180+
// Verify error response was sent
181+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
182+
type: "fileSearchResults",
183+
results: [],
184+
requestId: "test-request-error",
185+
error: "No workspace path available",
186+
})
187+
})
188+
189+
it("should handle errors from searchWorkspaceFiles", async () => {
190+
mockSearchWorkspaceFiles.mockRejectedValue(new Error("File search failed"))
191+
192+
// Setup state
193+
;(mockClineProvider.getState as Mock).mockResolvedValue({
194+
showRooIgnoredFiles: false,
195+
})
196+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
197+
198+
await webviewMessageHandler(mockClineProvider, {
199+
type: "searchFiles",
200+
query: "test",
201+
requestId: "test-request-fail",
202+
})
203+
204+
// Verify error response was sent
205+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
206+
type: "fileSearchResults",
207+
results: [],
208+
error: "File search failed",
209+
requestId: "test-request-fail",
210+
})
211+
})
212+
213+
it("should default showRooIgnoredFiles to false when state is null", async () => {
214+
// Setup mock results from file search
215+
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
216+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
217+
218+
// Setup state to return null
219+
;(mockClineProvider.getState as Mock).mockResolvedValue(null)
220+
221+
// Setup filter to return all paths (no filtering)
222+
mockFilterPaths.mockReturnValue(["src/index.ts"])
223+
224+
// No current task
225+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
226+
227+
await webviewMessageHandler(mockClineProvider, {
228+
type: "searchFiles",
229+
query: "index",
230+
requestId: "test-request-default",
231+
})
232+
233+
// Verify filterPaths was called (showRooIgnoredFiles defaults to false)
234+
expect(mockFilterPaths).toHaveBeenCalled()
235+
})
236+
237+
it("should dispose temporary RooIgnoreController after use", async () => {
238+
// Setup mock results from file search
239+
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
240+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
241+
242+
// Setup state
243+
;(mockClineProvider.getState as Mock).mockResolvedValue({
244+
showRooIgnoredFiles: false,
245+
})
246+
247+
// Setup filter
248+
mockFilterPaths.mockReturnValue(["src/index.ts"])
249+
250+
// No current task, so temporary controller will be created and should be disposed
251+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
252+
253+
await webviewMessageHandler(mockClineProvider, {
254+
type: "searchFiles",
255+
query: "index",
256+
requestId: "test-request-dispose",
257+
})
258+
259+
// Verify dispose was called on the temporary controller
260+
expect(mockDispose).toHaveBeenCalled()
261+
})
262+
263+
it("should not dispose controller from current task", async () => {
264+
// Setup mock results from file search
265+
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
266+
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
267+
268+
// Setup state
269+
;(mockClineProvider.getState as Mock).mockResolvedValue({
270+
showRooIgnoredFiles: false,
271+
})
272+
273+
// Create a mock task with its own RooIgnoreController
274+
const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
275+
const taskDispose = vi.fn()
276+
const taskRooIgnoreController = {
277+
filterPaths: taskFilterPaths,
278+
initialize: vi.fn(),
279+
dispose: taskDispose,
280+
}
281+
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
282+
taskId: "test-task-id",
283+
rooIgnoreController: taskRooIgnoreController,
284+
})
285+
286+
await webviewMessageHandler(mockClineProvider, {
287+
type: "searchFiles",
288+
query: "index",
289+
requestId: "test-request-no-dispose",
290+
})
291+
292+
// Verify dispose was NOT called on the task's controller
293+
expect(taskDispose).not.toHaveBeenCalled()
294+
// Verify the prototype dispose was also not called
295+
expect(mockDispose).not.toHaveBeenCalled()
296+
})
297+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { getOpenAiModels } from "../../api/providers/openai"
5555
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
5656
import { openMention } from "../mentions"
5757
import { resolveImageMentions } from "../mentions/resolveImageMentions"
58+
import { RooIgnoreController } from "../ignore/RooIgnoreController"
5859
import { getWorkspacePath } from "../../utils/path"
5960
import { Mode, defaultModeSlug } from "../../shared/modes"
6061
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
@@ -1735,12 +1736,39 @@ export const webviewMessageHandler = async (
17351736
20, // Use default limit, as filtering is now done in the backend
17361737
)
17371738

1738-
// Send results back to webview
1739-
await provider.postMessageToWebview({
1740-
type: "fileSearchResults",
1741-
results,
1742-
requestId: message.requestId,
1743-
})
1739+
// Get the RooIgnoreController from the current task, or create a new one
1740+
const currentTask = provider.getCurrentTask()
1741+
let rooIgnoreController = currentTask?.rooIgnoreController
1742+
let tempController: RooIgnoreController | undefined
1743+
1744+
// If no current task or no controller, create a temporary one
1745+
if (!rooIgnoreController) {
1746+
tempController = new RooIgnoreController(workspacePath)
1747+
await tempController.initialize()
1748+
rooIgnoreController = tempController
1749+
}
1750+
1751+
try {
1752+
// Get showRooIgnoredFiles setting from state
1753+
const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}
1754+
1755+
// Filter results using RooIgnoreController if showRooIgnoredFiles is false
1756+
let filteredResults = results
1757+
if (!showRooIgnoredFiles && rooIgnoreController) {
1758+
const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
1759+
filteredResults = results.filter((r) => allowedPaths.includes(r.path))
1760+
}
1761+
1762+
// Send results back to webview
1763+
await provider.postMessageToWebview({
1764+
type: "fileSearchResults",
1765+
results: filteredResults,
1766+
requestId: message.requestId,
1767+
})
1768+
} finally {
1769+
// Dispose temporary controller to prevent resource leak
1770+
tempController?.dispose()
1771+
}
17441772
} catch (error) {
17451773
const errorMessage = error instanceof Error ? error.message : String(error)
17461774

0 commit comments

Comments
 (0)