diff --git a/src/services/search/__tests__/file-search.spec.ts b/src/services/search/__tests__/file-search.spec.ts new file mode 100644 index 0000000000..3a21965430 --- /dev/null +++ b/src/services/search/__tests__/file-search.spec.ts @@ -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) + }) +}) diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index a25dd4068f..8df79ef58f 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -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, { diff --git a/test file with spaces.md b/test file with spaces.md new file mode 100644 index 0000000000..5647ad48ae --- /dev/null +++ b/test file with spaces.md @@ -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. diff --git a/test-file-search.js b/test-file-search.js new file mode 100644 index 0000000000..a252289375 --- /dev/null +++ b/test-file-search.js @@ -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)