mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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:
parent
4de46fbab3
commit
384e4a3a7d
5 changed files with 137 additions and 224 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue