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.
This commit is contained in:
Mario Zechner 2025-11-27 00:59:12 +01:00
parent 4de46fbab3
commit 384e4a3a7d
5 changed files with 137 additions and 224 deletions

3
package-lock.json generated
View file

@ -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": {

View file

@ -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.

View file

@ -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

View file

@ -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": {

View file

@ -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 [];
}
}