From 384e4a3a7d6e75caadec8b22152b80c8c49383dd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 27 Nov 2025 00:59:12 +0100 Subject: [PATCH] feat: fuzzy file search with @ prefix - Type @ to fuzzy-search files/folders across project - Respects .gitignore and skips hidden files - Pure Node.js implementation using readdir with withFileTypes - No external dependencies (fd/find) required - Also optimized Tab completion to use withFileTypes instead of statSync Based on PR #60 by @fightbulc, reimplemented for performance. --- package-lock.json | 3 + packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 3 +- packages/tui/package.json | 1 + packages/tui/src/autocomplete.ts | 350 +++++++++++------------------ 5 files changed, 137 insertions(+), 224 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2256cf27..a6e655fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2379,6 +2379,8 @@ }, "node_modules/minimatch": { "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" @@ -3955,6 +3957,7 @@ "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", + "minimatch": "^10.1.1", "string-width": "^8.1.0" }, "devDependencies": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7067035..45b87821 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Fuzzy File Search (`@`)**: Type `@` followed by a search term to fuzzy-search files and folders across your project. Respects `.gitignore` and skips hidden files. Directories are prioritized in results. Based on [PR #60](https://github.com/badlogic/pi-mono/pull/60) by [@fightbulc](https://github.com/fightbulc), reimplemented with pure Node.js for fast, dependency-free searching. + ### Fixed - **Emoji Text Wrapping Crash**: Fixed crash when rendering text containing emojis (e.g., 😂) followed by long content like URLs. The `breakLongWord` function in `pi-tui` was iterating over UTF-16 code units instead of grapheme clusters, causing emojis (which are surrogate pairs) to be miscounted during line wrapping. Now uses `Intl.Segmenter` to properly handle multi-codepoint characters. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 58f8274a..1909539e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -469,9 +469,8 @@ Type **`@`** to fuzzy-search for files and folders in your project: - Directories are prioritized and shown with trailing `/` - Autocomplete triggers immediately when you type `@` - Use **Up/Down arrows** to navigate, **Tab**/**Enter** to select -- Only shows attachable files (text, code, images) and directories -Uses `fdfind`/`fd` for fast searching if available, falls back to `find` on all Unix systems. +Respects `.gitignore` files and skips hidden files/directories. ### Path Completion diff --git a/packages/tui/package.json b/packages/tui/package.json index a0f37af4..2eba819e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -41,6 +41,7 @@ "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", + "minimatch": "^10.1.1", "string-width": "^8.1.0" }, "devDependencies": { diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index d4065282..8adebd12 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -1,89 +1,111 @@ -import { execSync } from "child_process"; -import { readdirSync, statSync } from "fs"; -import mimeTypes from "mime-types"; +import { type Dirent, readdirSync, readFileSync } from "fs"; +import { minimatch } from "minimatch"; import { homedir } from "os"; -import { basename, dirname, extname, join } from "path"; +import { basename, dirname, join, relative } from "path"; -function isAttachableFile(filePath: string): boolean { - const mimeType = mimeTypes.lookup(filePath); +// Parse gitignore-style file into patterns +function parseIgnoreFile(filePath: string): string[] { + try { + const content = readFileSync(filePath, "utf-8"); + return content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + } catch { + return []; + } +} - // Check file extension for common text files that might be misidentified - const textExtensions = [ - ".txt", - ".md", - ".markdown", - ".js", - ".ts", - ".tsx", - ".jsx", - ".py", - ".java", - ".c", - ".cpp", - ".h", - ".hpp", - ".cs", - ".php", - ".rb", - ".go", - ".rs", - ".swift", - ".kt", - ".scala", - ".sh", - ".bash", - ".zsh", - ".fish", - ".html", - ".htm", - ".css", - ".scss", - ".sass", - ".less", - ".xml", - ".json", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - ".conf", - ".log", - ".sql", - ".r", - ".R", - ".m", - ".pl", - ".lua", - ".vim", - ".dockerfile", - ".makefile", - ".cmake", - ".gradle", - ".maven", - ".properties", - ".env", - ]; +// Check if a path matches gitignore patterns +function isIgnored(filePath: string, patterns: string[]): boolean { + const pathWithoutSlash = filePath.endsWith("/") ? filePath.slice(0, -1) : filePath; + const isDir = filePath.endsWith("/"); - const ext = extname(filePath).toLowerCase(); - if (textExtensions.includes(ext)) return true; + let ignored = false; - if (!mimeType) return false; + for (const pattern of patterns) { + let p = pattern; + const negated = p.startsWith("!"); + if (negated) p = p.slice(1); - if (mimeType.startsWith("image/")) return true; - if (mimeType.startsWith("text/")) return true; + // Directory-only pattern + const dirOnly = p.endsWith("/"); + if (dirOnly) { + if (!isDir) continue; + p = p.slice(0, -1); + } - // Special cases for common text files that might not be detected as text/ - const commonTextTypes = [ - "application/json", - "application/javascript", - "application/typescript", - "application/xml", - "application/yaml", - "application/x-yaml", - ]; + // Remove leading slash (means anchored to root) + const anchored = p.startsWith("/"); + if (anchored) p = p.slice(1); - return commonTextTypes.includes(mimeType); + // Match - either at any level or anchored + const matchPattern = anchored ? p : "**/" + p; + const matches = minimatch(pathWithoutSlash, matchPattern, { dot: true }); + + if (matches) { + ignored = !negated; + } + } + + return ignored; +} + +// Walk directory tree respecting .gitignore, similar to fd +function walkDirectory( + baseDir: string, + query: string, + maxResults: number, +): Array<{ path: string; isDirectory: boolean }> { + const results: Array<{ path: string; isDirectory: boolean }> = []; + const rootIgnorePatterns = parseIgnoreFile(join(baseDir, ".gitignore")); + + function walk(currentDir: string, ignorePatterns: string[]): void { + if (results.length >= maxResults) return; + + // Load local .gitignore if exists + const localPatterns = parseIgnoreFile(join(currentDir, ".gitignore")); + const combinedPatterns = [...ignorePatterns, ...localPatterns]; + + let entries: Dirent[]; + try { + entries = readdirSync(currentDir, { withFileTypes: true }); + } catch { + return; // Can't read directory, skip + } + + for (const entry of entries) { + if (results.length >= maxResults) return; + + // Skip hidden files/dirs + if (entry.name.startsWith(".")) continue; + + const fullPath = join(currentDir, entry.name); + const relativePath = relative(baseDir, fullPath); + + // Check if ignored + const pathToCheck = entry.isDirectory() ? relativePath + "/" : relativePath; + if (isIgnored(pathToCheck, combinedPatterns)) continue; + + if (entry.isDirectory()) { + // Check if dir matches query + if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) { + results.push({ path: relativePath + "/", isDirectory: true }); + } + + // Recurse + walk(fullPath, combinedPatterns); + } else { + // Check if file matches query + if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) { + results.push({ path: relativePath, isDirectory: false }); + } + } + } + } + + walk(baseDir, rootIgnorePatterns); + return results; } export interface AutocompleteItem { @@ -131,7 +153,6 @@ export interface AutocompleteProvider { export class CombinedAutocompleteProvider implements AutocompleteProvider { private commands: (SlashCommand | AutocompleteItem)[]; private basePath: string; - private fdCommand: string | null | undefined = undefined; // undefined = not checked yet constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) { this.commands = commands; @@ -398,82 +419,71 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { searchPrefix = file; } - const entries = readdirSync(searchDir); + const entries = readdirSync(searchDir, { withFileTypes: true }); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { - if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) { + if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) { continue; } - const fullPath = join(searchDir, entry); - let isDirectory: boolean; - try { - isDirectory = statSync(fullPath).isDirectory(); - } catch (e) { - // Skip files we can't stat (permission issues, broken symlinks, etc.) - continue; - } - - // For @ prefix, filter to only show directories and attachable files - if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) { - continue; - } + const isDirectory = entry.isDirectory(); let relativePath: string; + const name = entry.name; // Handle @ prefix path construction if (isAtPrefix) { const pathWithoutAt = expandedPrefix; if (pathWithoutAt.endsWith("/")) { - relativePath = "@" + pathWithoutAt + entry; + relativePath = "@" + pathWithoutAt + name; } else if (pathWithoutAt.includes("/")) { if (pathWithoutAt.startsWith("~/")) { const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/ const dir = dirname(homeRelativeDir); - relativePath = "@~/" + (dir === "." ? entry : join(dir, entry)); + relativePath = "@~/" + (dir === "." ? name : join(dir, name)); } else { - relativePath = "@" + join(dirname(pathWithoutAt), entry); + relativePath = "@" + join(dirname(pathWithoutAt), name); } } else { if (pathWithoutAt.startsWith("~")) { - relativePath = "@~/" + entry; + relativePath = "@~/" + name; } else { - relativePath = "@" + entry; + relativePath = "@" + name; } } } else if (prefix.endsWith("/")) { // If prefix ends with /, append entry to the prefix - relativePath = prefix + entry; + relativePath = prefix + name; } else if (prefix.includes("/")) { // Preserve ~/ format for home directory paths if (prefix.startsWith("~/")) { const homeRelativeDir = prefix.slice(2); // Remove ~/ const dir = dirname(homeRelativeDir); - relativePath = "~/" + (dir === "." ? entry : join(dir, entry)); + relativePath = "~/" + (dir === "." ? name : join(dir, name)); } else if (prefix.startsWith("/")) { // Absolute path - construct properly const dir = dirname(prefix); if (dir === "/") { - relativePath = "/" + entry; + relativePath = "/" + name; } else { - relativePath = dir + "/" + entry; + relativePath = dir + "/" + name; } } else { - relativePath = join(dirname(prefix), entry); + relativePath = join(dirname(prefix), name); } } else { // For standalone entries, preserve ~/ if original prefix was ~/ if (prefix.startsWith("~")) { - relativePath = "~/" + entry; + relativePath = "~/" + name; } else { - relativePath = entry; + relativePath = name; } } suggestions.push({ value: isDirectory ? relativePath + "/" : relativePath, - label: entry, + label: name, description: isDirectory ? "directory" : "file", }); } @@ -518,98 +528,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return score; } - // Fuzzy file search using fdfind, fd, or find (fallback) + // Fuzzy file search using pure Node.js directory walking (respects .gitignore) private getFuzzyFileSuggestions(query: string): AutocompleteItem[] { try { - let result: string; - const fdCommand = this.getFdCommand(); + const entries = walkDirectory(this.basePath, query, 100); - if (fdCommand) { - const args = ["--max-results", "100"]; - - if (query) { - args.push(query); - } - - result = execSync(`${fdCommand} ${args.join(" ")}`, { - cwd: this.basePath, - encoding: "utf-8", - timeout: 2000, - maxBuffer: 1024 * 1024, - stdio: ["pipe", "pipe", "pipe"], - }); - } else { - // Fallback to find - const pattern = query ? `*${query}*` : "*"; - - const cmd = [ - "find", - ".", - "-iname", - `'${pattern}'`, - "!", - "-path", - "'*/.git/*'", - "!", - "-path", - "'*/node_modules/*'", - "!", - "-path", - "'*/__pycache__/*'", - "!", - "-path", - "'*/.venv/*'", - "!", - "-path", - "'*/dist/*'", - "!", - "-path", - "'*/build/*'", - "2>/dev/null", - "|", - "head", - "-100", - ].join(" "); - - result = execSync(cmd, { - cwd: this.basePath, - encoding: "utf-8", - timeout: 3000, - maxBuffer: 1024 * 1024, - shell: "/bin/bash", - stdio: ["pipe", "pipe", "pipe"], - }); - } - - const entries = result - .trim() - .split("\n") - .filter((f) => f.length > 0) - .map((f) => (f.startsWith("./") ? f.slice(2) : f)); - - // Score and filter entries (files and directories) - const scoredEntries: { path: string; score: number; isDirectory: boolean }[] = []; - - for (const entryPath of entries) { - const fullPath = join(this.basePath, entryPath); - - let isDirectory: boolean; - try { - isDirectory = statSync(fullPath).isDirectory(); - } catch { - continue; // Skip if we can't stat - } - - // For files, check if attachable - if (!isDirectory && !isAttachableFile(fullPath)) { - continue; - } - - const score = query ? this.scoreEntry(entryPath, query, isDirectory) : 1; - if (score > 0) { - scoredEntries.push({ path: entryPath, score, isDirectory }); - } - } + // Score entries + const scoredEntries = entries + .map((entry) => ({ + ...entry, + score: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1, + })) + .filter((entry) => entry.score > 0); // Sort by score (descending) and take top 20 scoredEntries.sort((a, b) => b.score - a.score); @@ -618,8 +548,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Build suggestions const suggestions: AutocompleteItem[] = []; for (const { path: entryPath, isDirectory } of topEntries) { - const entryName = basename(entryPath); - // Normalize path - remove trailing slash if present, we'll add it back for dirs + const entryName = basename(entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath); const normalizedPath = entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath; const valuePath = isDirectory ? normalizedPath + "/" : normalizedPath; @@ -631,31 +560,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } return suggestions; - } catch (e) { - return []; - } - } - - // Check which fd command is available (fdfind on Debian/Ubuntu, fd elsewhere) - // Result is cached after first check - private getFdCommand(): string | null { - if (this.fdCommand !== undefined) { - return this.fdCommand; - } - - try { - execSync("fdfind --version", { encoding: "utf-8", timeout: 1000, stdio: "pipe" }); - this.fdCommand = "fdfind"; - return this.fdCommand; } catch { - try { - execSync("fd --version", { encoding: "utf-8", timeout: 1000, stdio: "pipe" }); - this.fdCommand = "fd"; - return this.fdCommand; - } catch { - this.fdCommand = null; - return null; - } + return []; } }