Skip to content

Commit 2967fbe

Browse files
committed
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
1 parent 6fd261d commit 2967fbe

File tree

4 files changed

+353
-4
lines changed

4 files changed

+353
-4
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { searchWorkspaceFiles } from "../file-search"
3+
4+
// Mock child_process module
5+
vi.mock("child_process", () => ({
6+
spawn: vi.fn(),
7+
}))
8+
9+
// Mock readline module
10+
vi.mock("readline", () => ({
11+
createInterface: vi.fn(),
12+
}))
13+
14+
// Mock the getBinPath function
15+
vi.mock("../../ripgrep", () => ({
16+
getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"),
17+
}))
18+
19+
// Mock vscode
20+
vi.mock("vscode", () => ({
21+
env: {
22+
appRoot: "/mock/app/root",
23+
},
24+
}))
25+
26+
// Mock fs module
27+
vi.mock("fs", () => ({
28+
existsSync: vi.fn(),
29+
lstatSync: vi.fn(),
30+
}))
31+
32+
describe("searchWorkspaceFiles", () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
})
36+
37+
it("should find files with spaces when searching with partial name without spaces", async () => {
38+
const childProcess = await import("child_process")
39+
const readline = await import("readline")
40+
const fs = await import("fs")
41+
42+
// Mock ripgrep output with files containing spaces
43+
const mockFiles = [
44+
"/workspace/test file with spaces.md",
45+
"/workspace/another test file.ts",
46+
"/workspace/normalfile.js",
47+
"/workspace/test-no-spaces.md",
48+
]
49+
50+
// Mock child_process.spawn
51+
const mockStdout = {
52+
on: vi.fn(),
53+
pipe: vi.fn(),
54+
}
55+
const mockStderr = {
56+
on: vi.fn((event, callback) => {
57+
if (event === "data") {
58+
// No error output
59+
}
60+
}),
61+
}
62+
const mockProcess = {
63+
stdout: mockStdout,
64+
stderr: mockStderr,
65+
on: vi.fn((event, callback) => {
66+
if (event === "error") {
67+
// No error
68+
}
69+
}),
70+
kill: vi.fn(),
71+
}
72+
73+
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
74+
75+
// Mock readline interface
76+
const mockReadline = {
77+
on: vi.fn((event, callback) => {
78+
if (event === "line") {
79+
// Simulate ripgrep outputting file paths
80+
mockFiles.forEach((file) => callback(file))
81+
}
82+
if (event === "close") {
83+
// Simulate process closing
84+
setTimeout(() => callback(), 0)
85+
}
86+
}),
87+
close: vi.fn(),
88+
}
89+
90+
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
91+
92+
// Mock fs functions
93+
vi.mocked(fs.existsSync).mockReturnValue(true)
94+
vi.mocked(fs.lstatSync).mockReturnValue({
95+
isDirectory: () => false,
96+
} as any)
97+
98+
// Test searching for "testfile" (without spaces) should find "test file with spaces.md"
99+
const results = await searchWorkspaceFiles("testfile", "/workspace", 20)
100+
101+
// The results should include files with spaces that match the query
102+
const fileNames = results.map((r) => r.path)
103+
104+
// "test file with spaces.md" should be found when searching for "testfile"
105+
expect(fileNames).toContain("test file with spaces.md")
106+
expect(fileNames).toContain("another test file.ts")
107+
})
108+
109+
it("should find files when searching with exact name including spaces", async () => {
110+
const childProcess = await import("child_process")
111+
const readline = await import("readline")
112+
const fs = await import("fs")
113+
114+
// Mock ripgrep output
115+
const mockFiles = [
116+
"/workspace/test file with spaces.md",
117+
"/workspace/another test file.ts",
118+
"/workspace/normalfile.js",
119+
]
120+
121+
const mockStdout = {
122+
on: vi.fn(),
123+
pipe: vi.fn(),
124+
}
125+
const mockStderr = {
126+
on: vi.fn(),
127+
}
128+
const mockProcess = {
129+
stdout: mockStdout,
130+
stderr: mockStderr,
131+
on: vi.fn(),
132+
kill: vi.fn(),
133+
}
134+
135+
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
136+
137+
const mockReadline = {
138+
on: vi.fn((event, callback) => {
139+
if (event === "line") {
140+
mockFiles.forEach((file) => callback(file))
141+
}
142+
if (event === "close") {
143+
setTimeout(() => callback(), 0)
144+
}
145+
}),
146+
close: vi.fn(),
147+
}
148+
149+
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
150+
151+
// Mock fs functions
152+
vi.mocked(fs.existsSync).mockReturnValue(true)
153+
vi.mocked(fs.lstatSync).mockReturnValue({
154+
isDirectory: () => false,
155+
} as any)
156+
157+
// Test searching for "test file" (with space) should find matching files
158+
const results = await searchWorkspaceFiles("test file", "/workspace", 20)
159+
160+
const fileNames = results.map((r) => r.path)
161+
expect(fileNames).toContain("test file with spaces.md")
162+
expect(fileNames).toContain("another test file.ts")
163+
})
164+
165+
it("should find files when searching with partial words", async () => {
166+
const childProcess = await import("child_process")
167+
const readline = await import("readline")
168+
const fs = await import("fs")
169+
170+
// Mock ripgrep output
171+
const mockFiles = [
172+
"/workspace/test file with spaces.md",
173+
"/workspace/documentation file.md",
174+
"/workspace/config.json",
175+
]
176+
177+
const mockStdout = {
178+
on: vi.fn(),
179+
pipe: vi.fn(),
180+
}
181+
const mockStderr = {
182+
on: vi.fn(),
183+
}
184+
const mockProcess = {
185+
stdout: mockStdout,
186+
stderr: mockStderr,
187+
on: vi.fn(),
188+
kill: vi.fn(),
189+
}
190+
191+
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
192+
193+
const mockReadline = {
194+
on: vi.fn((event, callback) => {
195+
if (event === "line") {
196+
mockFiles.forEach((file) => callback(file))
197+
}
198+
if (event === "close") {
199+
setTimeout(() => callback(), 0)
200+
}
201+
}),
202+
close: vi.fn(),
203+
}
204+
205+
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
206+
207+
// Mock fs functions
208+
vi.mocked(fs.existsSync).mockReturnValue(true)
209+
vi.mocked(fs.lstatSync).mockReturnValue({
210+
isDirectory: () => false,
211+
} as any)
212+
213+
// Test searching for just "test" should find files with "test" in the name
214+
const results = await searchWorkspaceFiles("test", "/workspace", 20)
215+
216+
const fileNames = results.map((r) => r.path)
217+
expect(fileNames).toContain("test file with spaces.md")
218+
219+
// Should not contain files without "test" in the name
220+
expect(fileNames).not.toContain("config.json")
221+
})
222+
223+
it("should return all items when query is empty", async () => {
224+
const childProcess = await import("child_process")
225+
const readline = await import("readline")
226+
const fs = await import("fs")
227+
228+
const mockFiles = ["/workspace/file1.ts", "/workspace/file2.js", "/workspace/file3.md"]
229+
230+
const mockStdout = {
231+
on: vi.fn(),
232+
pipe: vi.fn(),
233+
}
234+
const mockStderr = {
235+
on: vi.fn(),
236+
}
237+
const mockProcess = {
238+
stdout: mockStdout,
239+
stderr: mockStderr,
240+
on: vi.fn(),
241+
kill: vi.fn(),
242+
}
243+
244+
vi.mocked(childProcess.spawn).mockReturnValue(mockProcess as any)
245+
246+
const mockReadline = {
247+
on: vi.fn((event, callback) => {
248+
if (event === "line") {
249+
mockFiles.forEach((file) => callback(file))
250+
}
251+
if (event === "close") {
252+
setTimeout(() => callback(), 0)
253+
}
254+
}),
255+
close: vi.fn(),
256+
}
257+
258+
vi.mocked(readline.createInterface).mockReturnValue(mockReadline as any)
259+
260+
// Mock fs functions
261+
vi.mocked(fs.existsSync).mockReturnValue(true)
262+
vi.mocked(fs.lstatSync).mockReturnValue({
263+
isDirectory: () => false,
264+
} as any)
265+
266+
// Test with empty query
267+
const results = await searchWorkspaceFiles("", "/workspace", 2)
268+
269+
// Should return limited number of results
270+
expect(results.length).toBeLessThanOrEqual(2)
271+
})
272+
})

src/services/search/file-search.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,31 @@ export async function searchWorkspaceFiles(
122122
}
123123

124124
// Create search items for all files AND directories
125-
const searchItems = allItems.map((item) => ({
126-
original: item,
127-
searchStr: `${item.path} ${item.label || ""}`,
128-
}))
125+
// For better matching of files with spaces, we create multiple search variations:
126+
// 1. The original path as-is
127+
// 2. The path with spaces removed (for matching when user types without spaces)
128+
// 3. The label/basename with and without spaces
129+
const searchItems = allItems.map((item) => {
130+
const pathWithoutSpaces = item.path.replace(/\s+/g, "")
131+
const labelWithoutSpaces = (item.label || "").replace(/\s+/g, "")
132+
133+
// Create a search string that includes multiple variations to improve matching
134+
// This allows "testfile" to match "test file with spaces.md"
135+
const searchStr = [
136+
item.path,
137+
pathWithoutSpaces,
138+
item.label || "",
139+
labelWithoutSpaces,
140+
// Also include individual words from the path for better partial matching
141+
...item.path.split(/[\s\-_\.\/\\]+/).filter(Boolean),
142+
...(item.label || "").split(/[\s\-_\.]+/).filter(Boolean),
143+
].join(" ")
144+
145+
return {
146+
original: item,
147+
searchStr,
148+
}
149+
})
129150

130151
// Run fzf search on all items
131152
const fzf = new Fzf(searchItems, {

test file with spaces.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Test File with Spaces
2+
3+
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.
4+
5+
## Issue Details
6+
7+
- Files with spaces should appear in autocomplete
8+
- Currently they only appear after being opened in a tab
9+
- This is a regression from previous fixes
10+
11+
## Test Content
12+
13+
This file should be discoverable when typing `@test` in the Roo Code chat input.

test-file-search.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const { searchWorkspaceFiles } = require("./src/services/search/file-search")
2+
const path = require("path")
3+
4+
async function testFileSearch() {
5+
console.log("Testing file search with spaces in filenames...\n")
6+
7+
const testQueries = [
8+
"testfile", // Should match "test file with spaces.md"
9+
"test file", // Should match "test file with spaces.md"
10+
"spaces", // Should match "test file with spaces.md"
11+
"withspaces", // Should match "test file with spaces.md"
12+
]
13+
14+
const cwd = process.cwd()
15+
16+
for (const query of testQueries) {
17+
console.log(`\nSearching for: "${query}"`)
18+
console.log("-".repeat(40))
19+
20+
try {
21+
const results = await searchWorkspaceFiles(cwd, query)
22+
23+
if (results.length === 0) {
24+
console.log("No results found")
25+
} else {
26+
console.log(`Found ${results.length} result(s):`)
27+
results.forEach((result) => {
28+
console.log(` - ${result.path}`)
29+
if (result.label && result.label !== path.basename(result.path)) {
30+
console.log(` Label: ${result.label}`)
31+
}
32+
})
33+
}
34+
} catch (error) {
35+
console.error(`Error: ${error.message}`)
36+
}
37+
}
38+
39+
console.log("\n" + "=".repeat(40))
40+
console.log("Test completed!")
41+
}
42+
43+
testFileSearch().catch(console.error)

0 commit comments

Comments
 (0)