fix: improve file search to handle filenames with spaces

- Enhanced searchWorkspaceFiles function to create multiple search string variations
- Files with spaces now appear in autocomplete without needing to be opened first
- Added comprehensive unit tests for the fix

Fixes #7272
This commit is contained in:
Roo Code 2025-08-21 10:23:37 +00:00
parent 6fd261d3b6
commit 2967fbe594
4 changed files with 353 additions and 4 deletions

View file

@ -0,0 +1,272 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { searchWorkspaceFiles } from "../file-search"
// Mock child_process module
vi.mock("child_process", () => ({
spawn: vi.fn(),
}))
// Mock readline module
vi.mock("readline", () => ({
createInterface: vi.fn(),
}))
// Mock the getBinPath function
vi.mock("../../ripgrep", () => ({
getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"),
}))
// Mock vscode
vi.mock("vscode", () => ({
env: {
appRoot: "/mock/app/root",
},
}))
// Mock fs module
vi.mock("fs", () => ({
existsSync: vi.fn(),
lstatSync: vi.fn(),
}))
describe("searchWorkspaceFiles", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should find files with spaces when searching with partial name without spaces", async () => {
const childProcess = await import("child_process")
const readline = await import("readline")
const fs = await import("fs")
// Mock ripgrep output with files containing spaces
const mockFiles = [
"/workspace/test file with spaces.md",
"/workspace/another test file.ts",
"/workspace/normalfile.js",
"/workspace/test-no-spaces.md",
]
// Mock child_process.spawn
const mockStdout = {
on: vi.fn(),
pipe: vi.fn(),
}
const mockStderr = {
on: vi.fn((event, callback) => {
if (event === "data") {
// No error output
}
}),
}
const mockProcess = {
stdout: mockStdout,
stderr: mockStderr,
on: vi.fn((event, callback) => {
if (event === "error") {
// No error
}
}),
kill: vi.fn(),
}
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
// Mock readline interface
const mockReadline = {
on: vi.fn((event, callback) => {
if (event === "line") {
// Simulate ripgrep outputting file paths
mockFiles.forEach((file) => callback(file))
}
if (event === "close") {
// Simulate process closing
setTimeout(() => callback(), 0)
}
}),
close: vi.fn(),
}
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
// Mock fs functions
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.lstatSync).mockReturnValue({
isDirectory: () => false,
} as any)
// Test searching for "testfile" (without spaces) should find "test file with spaces.md"
const results = await searchWorkspaceFiles("testfile", "/workspace", 20)
// The results should include files with spaces that match the query
const fileNames = results.map((r) => r.path)
// "test file with spaces.md" should be found when searching for "testfile"
expect(fileNames).toContain("test file with spaces.md")
expect(fileNames).toContain("another test file.ts")
})
it("should find files when searching with exact name including spaces", async () => {
const childProcess = await import("child_process")
const readline = await import("readline")
const fs = await import("fs")
// Mock ripgrep output
const mockFiles = [
"/workspace/test file with spaces.md",
"/workspace/another test file.ts",
"/workspace/normalfile.js",
]
const mockStdout = {
on: vi.fn(),
pipe: vi.fn(),
}
const mockStderr = {
on: vi.fn(),
}
const mockProcess = {
stdout: mockStdout,
stderr: mockStderr,
on: vi.fn(),
kill: vi.fn(),
}
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
const mockReadline = {
on: vi.fn((event, callback) => {
if (event === "line") {
mockFiles.forEach((file) => callback(file))
}
if (event === "close") {
setTimeout(() => callback(), 0)
}
}),
close: vi.fn(),
}
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
// Mock fs functions
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.lstatSync).mockReturnValue({
isDirectory: () => false,
} as any)
// Test searching for "test file" (with space) should find matching files
const results = await searchWorkspaceFiles("test file", "/workspace", 20)
const fileNames = results.map((r) => r.path)
expect(fileNames).toContain("test file with spaces.md")
expect(fileNames).toContain("another test file.ts")
})
it("should find files when searching with partial words", async () => {
const childProcess = await import("child_process")
const readline = await import("readline")
const fs = await import("fs")
// Mock ripgrep output
const mockFiles = [
"/workspace/test file with spaces.md",
"/workspace/documentation file.md",
"/workspace/config.json",
]
const mockStdout = {
on: vi.fn(),
pipe: vi.fn(),
}
const mockStderr = {
on: vi.fn(),
}
const mockProcess = {
stdout: mockStdout,
stderr: mockStderr,
on: vi.fn(),
kill: vi.fn(),
}
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
const mockReadline = {
on: vi.fn((event, callback) => {
if (event === "line") {
mockFiles.forEach((file) => callback(file))
}
if (event === "close") {
setTimeout(() => callback(), 0)
}
}),
close: vi.fn(),
}
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
// Mock fs functions
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.lstatSync).mockReturnValue({
isDirectory: () => false,
} as any)
// Test searching for just "test" should find files with "test" in the name
const results = await searchWorkspaceFiles("test", "/workspace", 20)
const fileNames = results.map((r) => r.path)
expect(fileNames).toContain("test file with spaces.md")
// Should not contain files without "test" in the name
expect(fileNames).not.toContain("config.json")
})
it("should return all items when query is empty", async () => {
const childProcess = await import("child_process")
const readline = await import("readline")
const fs = await import("fs")
const mockFiles = ["/workspace/file1.ts", "/workspace/file2.js", "/workspace/file3.md"]
const mockStdout = {
on: vi.fn(),
pipe: vi.fn(),
}
const mockStderr = {
on: vi.fn(),
}
const mockProcess = {
stdout: mockStdout,
stderr: mockStderr,
on: vi.fn(),
kill: vi.fn(),
}
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
const mockReadline = {
on: vi.fn((event, callback) => {
if (event === "line") {
mockFiles.forEach((file) => callback(file))
}
if (event === "close") {
setTimeout(() => callback(), 0)
}
}),
close: vi.fn(),
}
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
// Mock fs functions
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.lstatSync).mockReturnValue({
isDirectory: () => false,
} as any)
// Test with empty query
const results = await searchWorkspaceFiles("", "/workspace", 2)
// Should return limited number of results
expect(results.length).toBeLessThanOrEqual(2)
})
})

View file

@ -122,10 +122,31 @@ export async function searchWorkspaceFiles(
}
// Create search items for all files AND directories
const searchItems = allItems.map((item) => ({
original: item,
searchStr: `${item.path} ${item.label || ""}`,
}))
// For better matching of files with spaces, we create multiple search variations:
// 1. The original path as-is
// 2. The path with spaces removed (for matching when user types without spaces)
// 3. The label/basename with and without spaces
const searchItems = allItems.map((item) => {
const pathWithoutSpaces = item.path.replace(/\s+/g, "")
const labelWithoutSpaces = (item.label || "").replace(/\s+/g, "")
// Create a search string that includes multiple variations to improve matching
// This allows "testfile" to match "test file with spaces.md"
const searchStr = [
item.path,
pathWithoutSpaces,
item.label || "",
labelWithoutSpaces,
// Also include individual words from the path for better partial matching
...item.path.split(/[\s\-_\.\/\\]+/).filter(Boolean),
...(item.label || "").split(/[\s\-_\.]+/).filter(Boolean),
].join(" ")
return {
original: item,
searchStr,
}
})
// Run fzf search on all items
const fzf = new Fzf(searchItems, {

13
test file with spaces.md Normal file
View file

@ -0,0 +1,13 @@
# Test File with Spaces
This is a test file to reproduce the issue where files with spaces in their names don't appear in the @ autocomplete suggestions unless they are opened in VS Code first.
## Issue Details
- Files with spaces should appear in autocomplete
- Currently they only appear after being opened in a tab
- This is a regression from previous fixes
## Test Content
This file should be discoverable when typing `@test` in the Roo Code chat input.

43
test-file-search.js Normal file
View file

@ -0,0 +1,43 @@
const { searchWorkspaceFiles } = require("./src/services/search/file-search")
const path = require("path")
async function testFileSearch() {
console.log("Testing file search with spaces in filenames...\n")
const testQueries = [
"testfile", // Should match "test file with spaces.md"
"test file", // Should match "test file with spaces.md"
"spaces", // Should match "test file with spaces.md"
"withspaces", // Should match "test file with spaces.md"
]
const cwd = process.cwd()
for (const query of testQueries) {
console.log(`\nSearching for: "${query}"`)
console.log("-".repeat(40))
try {
const results = await searchWorkspaceFiles(cwd, query)
if (results.length === 0) {
console.log("No results found")
} else {
console.log(`Found ${results.length} result(s):`)
results.forEach((result) => {
console.log(` - ${result.path}`)
if (result.label && result.label !== path.basename(result.path)) {
console.log(` Label: ${result.label}`)
}
})
}
} catch (error) {
console.error(`Error: ${error.message}`)
}
}
console.log("\n" + "=".repeat(40))
console.log("Test completed!")
}
testFileSearch().catch(console.error)