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..7516c02ee0 --- /dev/null +++ b/src/services/search/__tests__/file-search.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Fzf } from "fzf" + +// Test the fallback search logic for period-prefixed queries +describe("searchWorkspaceFiles period handling", () => { + it("should handle period-prefixed queries with fallback search", () => { + // Mock file data that would come from ripgrep + const mockFiles = [ + { path: ".rooignore", type: "file" as const, label: ".rooignore" }, + { path: ".gitignore", type: "file" as const, label: ".gitignore" }, + { path: ".env", type: "file" as const, label: ".env" }, + { path: "src/app.ts", type: "file" as const, label: "app.ts" }, + { path: "package.json", type: "file" as const, label: "package.json" }, + ] + + // Test the fallback search logic directly + const query = ".rooignore" + + // Create search items like the real function does + const searchItems = mockFiles.map((item) => ({ + original: item, + searchStr: `${item.path} ${item.label || ""}`, + })) + + // Test fzf search first + const fzf = new Fzf(searchItems, { + selector: (item) => item.searchStr, + }) + + let fzfResults = fzf.find(query).map((result) => result.item.original) + + // If fzf doesn't return results for period-prefixed queries, use fallback + if (fzfResults.length === 0 && query.startsWith(".")) { + // Fallback: exact substring matching + const exactMatches = mockFiles.filter((item) => { + const searchStr = `${item.path} ${item.label || ""}` + return searchStr.toLowerCase().includes(query.toLowerCase()) + }) + + // Sort by relevance + exactMatches.sort((a, b) => { + const aLabel = (a.label || "").toLowerCase() + const bLabel = (b.label || "").toLowerCase() + const queryLower = query.toLowerCase() + + // Prioritize exact filename matches + if (aLabel === queryLower && bLabel !== queryLower) return -1 + if (bLabel === queryLower && aLabel !== queryLower) return 1 + + // Then prioritize filename starts with query + if (aLabel.startsWith(queryLower) && !bLabel.startsWith(queryLower)) return -1 + if (bLabel.startsWith(queryLower) && !aLabel.startsWith(queryLower)) return 1 + + // Finally sort by path length + return a.path.length - b.path.length + }) + + fzfResults = exactMatches + } + + // Should find the .rooignore file + expect(fzfResults.length).toBeGreaterThan(0) + expect(fzfResults[0].path).toBe(".rooignore") + expect(fzfResults[0].label).toBe(".rooignore") + }) + + it("should prioritize exact matches over partial matches", () => { + const mockFiles = [ + { path: ".rooignore", type: "file" as const, label: ".rooignore" }, + { path: "src/.rooignore.backup", type: "file" as const, label: ".rooignore.backup" }, + { path: "docs/rooignore-guide.md", type: "file" as const, label: "rooignore-guide.md" }, + ] + + const query = ".rooignore" + + // Simulate fallback search + const exactMatches = mockFiles.filter((item) => { + const searchStr = `${item.path} ${item.label || ""}` + return searchStr.toLowerCase().includes(query.toLowerCase()) + }) + + // Sort by relevance + exactMatches.sort((a, b) => { + const aLabel = (a.label || "").toLowerCase() + const bLabel = (b.label || "").toLowerCase() + const queryLower = query.toLowerCase() + + // Prioritize exact filename matches + if (aLabel === queryLower && bLabel !== queryLower) return -1 + if (bLabel === queryLower && aLabel !== queryLower) return 1 + + // Then prioritize filename starts with query + if (aLabel.startsWith(queryLower) && !bLabel.startsWith(queryLower)) return -1 + if (bLabel.startsWith(queryLower) && !aLabel.startsWith(queryLower)) return 1 + + // Finally sort by path length + return a.path.length - b.path.length + }) + + // The exact match should be first + expect(exactMatches.length).toBeGreaterThan(0) + expect(exactMatches[0].label).toBe(".rooignore") + }) + + it("should handle .gitignore searches correctly", () => { + const mockFiles = [ + { path: ".gitignore", type: "file" as const, label: ".gitignore" }, + { path: "src/.gitignore", type: "file" as const, label: ".gitignore" }, + { path: "docs/gitignore-examples.md", type: "file" as const, label: "gitignore-examples.md" }, + ] + + const query = ".gitignore" + + // Simulate fallback search + const exactMatches = mockFiles.filter((item) => { + const searchStr = `${item.path} ${item.label || ""}` + return searchStr.toLowerCase().includes(query.toLowerCase()) + }) + + // Sort by relevance + exactMatches.sort((a, b) => { + const aLabel = (a.label || "").toLowerCase() + const bLabel = (b.label || "").toLowerCase() + const queryLower = query.toLowerCase() + + // Prioritize exact filename matches + if (aLabel === queryLower && bLabel !== queryLower) return -1 + if (bLabel === queryLower && aLabel !== queryLower) return 1 + + // Then prioritize filename starts with query + if (aLabel.startsWith(queryLower) && !bLabel.startsWith(queryLower)) return -1 + if (bLabel.startsWith(queryLower) && !aLabel.startsWith(queryLower)) return 1 + + // Finally sort by path length + return a.path.length - b.path.length + }) + + // Should find .gitignore files + expect(exactMatches.length).toBeGreaterThan(0) + expect(exactMatches.some((result) => result.label === ".gitignore")).toBe(true) + }) +}) diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index a25dd4068f..8dadcf27f8 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -135,7 +135,40 @@ export async function searchWorkspaceFiles( }) // Get all matching results from fzf - const fzfResults = fzf.find(query).map((result) => result.item.original) + // The fzf library supports exact matching with quotes, but for queries starting + // with special characters like periods, we need to handle them specially. + // The issue is that fzf may not handle leading periods well in fuzzy matching. + let fzfResults = fzf.find(query).map((result) => result.item.original) + + // If the original query didn't return results and starts with a period, + // try alternative search strategies + if (fzfResults.length === 0 && query.startsWith(".")) { + // Try exact substring matching as a fallback for period-prefixed queries + const exactMatches = allItems.filter((item) => { + const searchStr = `${item.path} ${item.label || ""}` + return searchStr.toLowerCase().includes(query.toLowerCase()) + }) + + // Sort by relevance (exact filename matches first, then path matches) + exactMatches.sort((a, b) => { + const aLabel = (a.label || "").toLowerCase() + const bLabel = (b.label || "").toLowerCase() + const queryLower = query.toLowerCase() + + // Prioritize exact filename matches + if (aLabel === queryLower && bLabel !== queryLower) return -1 + if (bLabel === queryLower && aLabel !== queryLower) return 1 + + // Then prioritize filename starts with query + if (aLabel.startsWith(queryLower) && !bLabel.startsWith(queryLower)) return -1 + if (bLabel.startsWith(queryLower) && !aLabel.startsWith(queryLower)) return 1 + + // Finally sort by path length (shorter paths first) + return a.path.length - b.path.length + }) + + fzfResults = exactMatches.slice(0, limit) + } // Verify types of the shortest results const verifiedResults = await Promise.all(