Skip to content
Draft
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
233 changes: 233 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// npx vitest core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts

import type { Mock } from "vitest"

// Mock dependencies - must come before imports
vi.mock("../../../services/search/file-search")
vi.mock("../../ignore/RooIgnoreController")

import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import { searchWorkspaceFiles } from "../../../services/search/file-search"
import { RooIgnoreController } from "../../ignore/RooIgnoreController"

const mockSearchWorkspaceFiles = searchWorkspaceFiles as Mock<typeof searchWorkspaceFiles>

vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
}))

describe("webviewMessageHandler - searchFiles with RooIgnore filtering", () => {
let mockClineProvider: ClineProvider
let mockFilterPaths: Mock

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

// Spy on the mock RooIgnoreController prototype methods
mockFilterPaths = vi.fn()

// Override the filterPaths method on the prototype
;(RooIgnoreController.prototype as any).filterPaths = mockFilterPaths
;(RooIgnoreController.prototype as any).initialize = vi.fn().mockResolvedValue(undefined)

// Create mock ClineProvider
mockClineProvider = {
getState: vi.fn(),
postMessageToWebview: vi.fn(),
getCurrentTask: vi.fn(),
cwd: "/mock/workspace",
} as unknown as ClineProvider
})

it("should filter results using RooIgnoreController when showRooIgnoredFiles is false", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
{ path: "src/utils.ts", type: "file" as const, label: "utils.ts" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)

// Setup state with showRooIgnoredFiles = false
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})

// Setup filter to exclude secrets folder
mockFilterPaths.mockReturnValue(["src/index.ts", "src/utils.ts"])

// No current task, so temporary controller will be created
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-123",
})

// Verify filterPaths was called with all result paths
expect(mockFilterPaths).toHaveBeenCalledWith(["src/index.ts", "secrets/config.json", "src/utils.ts"])

// Verify filtered results were sent to webview
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [
{ path: "src/index.ts", type: "file", label: "index.ts" },
{ path: "src/utils.ts", type: "file", label: "utils.ts" },
],
requestId: "test-request-123",
})
})

it("should not filter results when showRooIgnoredFiles is true", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "secrets/config.json", type: "file" as const, label: "config.json" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)

// Setup state with showRooIgnoredFiles = true
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: true,
})

// No current task
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-456",
})

// Verify filterPaths was NOT called
expect(mockFilterPaths).not.toHaveBeenCalled()

// Verify all results were sent to webview (unfiltered)
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: mockResults,
requestId: "test-request-456",
})
})

it("should use existing RooIgnoreController from current task", async () => {
// Setup mock results from file search
const mockResults = [
{ path: "src/index.ts", type: "file" as const, label: "index.ts" },
{ path: "private/secret.ts", type: "file" as const, label: "secret.ts" },
]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)

// Setup state with showRooIgnoredFiles = false
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})

// Create a mock task with its own RooIgnoreController
const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
const taskRooIgnoreController = {
filterPaths: taskFilterPaths,
initialize: vi.fn(),
}
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
taskId: "test-task-id",
rooIgnoreController: taskRooIgnoreController,
})

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-789",
})

// Verify the task's controller was used (not the prototype)
expect(taskFilterPaths).toHaveBeenCalledWith(["src/index.ts", "private/secret.ts"])

// Verify filtered results were sent to webview
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [{ path: "src/index.ts", type: "file", label: "index.ts" }],
requestId: "test-request-789",
})
})

it("should handle error when no workspace path is available", async () => {
// Create provider without cwd
mockClineProvider = {
...mockClineProvider,
cwd: undefined,
getCurrentTask: vi.fn().mockReturnValue(null),
} as unknown as ClineProvider

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "test",
requestId: "test-request-error",
})

// Verify error response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [],
requestId: "test-request-error",
error: "No workspace path available",
})
})

it("should handle errors from searchWorkspaceFiles", async () => {
mockSearchWorkspaceFiles.mockRejectedValue(new Error("File search failed"))

// Setup state
;(mockClineProvider.getState as Mock).mockResolvedValue({
showRooIgnoredFiles: false,
})
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "test",
requestId: "test-request-fail",
})

// Verify error response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "fileSearchResults",
results: [],
error: "File search failed",
requestId: "test-request-fail",
})
})

it("should default showRooIgnoredFiles to false when state is null", async () => {
// Setup mock results from file search
const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
mockSearchWorkspaceFiles.mockResolvedValue(mockResults)

// Setup state to return null
;(mockClineProvider.getState as Mock).mockResolvedValue(null)

// Setup filter to return all paths (no filtering)
mockFilterPaths.mockReturnValue(["src/index.ts"])

// No current task
;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)

await webviewMessageHandler(mockClineProvider, {
type: "searchFiles",
query: "index",
requestId: "test-request-default",
})

// Verify filterPaths was called (showRooIgnoredFiles defaults to false)
expect(mockFilterPaths).toHaveBeenCalled()
})
})
23 changes: 22 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { exportSettings, importSettingsWithFeedback } from "../config/importExpo
import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { openMention } from "../mentions"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
Expand Down Expand Up @@ -1692,10 +1693,30 @@ export const webviewMessageHandler = async (
20, // Use default limit, as filtering is now done in the backend
)

// Get the RooIgnoreController from the current task, or create a new one
const currentTask = provider.getCurrentTask()
let rooIgnoreController = currentTask?.rooIgnoreController

// If no current task or no controller, create a temporary one
if (!rooIgnoreController) {
rooIgnoreController = new RooIgnoreController(workspacePath)
await rooIgnoreController.initialize()
}

// Get showRooIgnoredFiles setting from state
const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}

// Filter results using RooIgnoreController if showRooIgnoredFiles is false
let filteredResults = results
if (!showRooIgnoredFiles && rooIgnoreController) {
const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
filteredResults = results.filter((r) => allowedPaths.includes(r.path))
}
Comment on lines +1696 to +1714
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource leak: When a temporary RooIgnoreController is created here, it is never disposed. The constructor calls setupFileWatcher() which creates a vscode.workspace.createFileSystemWatcher() and adds it to the controller's disposables array. Without calling dispose(), these file watchers accumulate and leak resources each time searchFiles is called without an active task.

Suggested change
// Get the RooIgnoreController from the current task, or create a new one
const currentTask = provider.getCurrentTask()
let rooIgnoreController = currentTask?.rooIgnoreController
// If no current task or no controller, create a temporary one
if (!rooIgnoreController) {
rooIgnoreController = new RooIgnoreController(workspacePath)
await rooIgnoreController.initialize()
}
// Get showRooIgnoredFiles setting from state
const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}
// Filter results using RooIgnoreController if showRooIgnoredFiles is false
let filteredResults = results
if (!showRooIgnoredFiles && rooIgnoreController) {
const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
filteredResults = results.filter((r) => allowedPaths.includes(r.path))
}
// Get the RooIgnoreController from the current task, or create a new one
const currentTask = provider.getCurrentTask()
let rooIgnoreController = currentTask?.rooIgnoreController
let isTemporaryController = false
// If no current task or no controller, create a temporary one
if (!rooIgnoreController) {
rooIgnoreController = new RooIgnoreController(workspacePath)
await rooIgnoreController.initialize()
isTemporaryController = true
}
// Get showRooIgnoredFiles setting from state
const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}
// Filter results using RooIgnoreController if showRooIgnoredFiles is false
let filteredResults = results
if (!showRooIgnoredFiles && rooIgnoreController) {
const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
filteredResults = results.filter((r) => allowedPaths.includes(r.path))
}
// Dispose temporary controller to clean up file watchers
if (isTemporaryController && rooIgnoreController) {
rooIgnoreController.dispose()
}

Fix it with Roo Code or mention @roomote and request a fix.


// Send results back to webview
await provider.postMessageToWebview({
type: "fileSearchResults",
results,
results: filteredResults,
requestId: message.requestId,
})
} catch (error) {
Expand Down
Loading