mirror of https://github.com/buster-so/buster.git
filesearch working
This commit is contained in:
parent
46a98a1a02
commit
3821f29caf
|
@ -8,7 +8,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "[ \"$SKIP_ENV_CHECK\" = \"true\" ] || tsx scripts/validate-env.ts",
|
"prebuild": "[ \"$SKIP_ENV_CHECK\" = \"true\" ] || tsx scripts/validate-env.ts",
|
||||||
"build": "bun build src/index.tsx --outdir dist --target node",
|
"build": "bun build src/index.tsx --outdir dist --target bun",
|
||||||
"build:dry-run": "tsc --noEmit",
|
"build:dry-run": "tsc --noEmit",
|
||||||
"build:standalone": "bun build src/index.tsx --compile --outfile dist/buster",
|
"build:standalone": "bun build src/index.tsx --compile --outfile dist/buster",
|
||||||
"start": "bun run dist/index.js",
|
"start": "bun run dist/index.js",
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MultiLineTextInput } from './multi-line-text-input';
|
import { type FileSearchResult, searchFiles } from '../utils/file-search';
|
||||||
|
import { FileAutocompleteDisplay } from './file-autocomplete-display';
|
||||||
|
import { MultiLineTextInput, replaceMention } from './multi-line-text-input';
|
||||||
import { SimpleBigText } from './simple-big-text';
|
import { SimpleBigText } from './simple-big-text';
|
||||||
|
|
||||||
export function ChatTitle() {
|
export function ChatTitle() {
|
||||||
|
@ -66,26 +68,112 @@ interface ChatInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ value, placeholder, onChange, onSubmit }: ChatInputProps) {
|
export function ChatInput({ value, placeholder, onChange, onSubmit }: ChatInputProps) {
|
||||||
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
|
const [mentionStart, setMentionStart] = useState<number>(-1);
|
||||||
|
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Handle mention changes from the input
|
||||||
|
const handleMentionChange = (query: string | null, position: number) => {
|
||||||
|
// Only reset selection if the query actually changed
|
||||||
|
if (query !== mentionQuery) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
setMentionQuery(query);
|
||||||
|
setMentionStart(position);
|
||||||
|
setShowAutocomplete(query !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search for files when mention query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (mentionQuery !== null) {
|
||||||
|
searchFiles(mentionQuery, { maxResults: 20 })
|
||||||
|
.then((results) => {
|
||||||
|
setSearchResults(results);
|
||||||
|
// Adjust selection if it's out of bounds
|
||||||
|
setSelectedIndex((currentIndex) => {
|
||||||
|
if (currentIndex >= results.length && results.length > 0) {
|
||||||
|
return results.length - 1;
|
||||||
|
}
|
||||||
|
return currentIndex;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('File search failed:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}, [mentionQuery]);
|
||||||
|
|
||||||
|
// Handle autocomplete navigation
|
||||||
|
const handleAutocompleteNavigate = (direction: 'up' | 'down' | 'select' | 'close') => {
|
||||||
|
const displayItems = searchResults.slice(0, 10);
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case 'up':
|
||||||
|
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
break;
|
||||||
|
case 'down':
|
||||||
|
setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1));
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
if (displayItems[selectedIndex]) {
|
||||||
|
const file = displayItems[selectedIndex];
|
||||||
|
if (mentionStart !== -1 && mentionQuery !== null) {
|
||||||
|
const mentionEnd = mentionStart + mentionQuery.length + 1; // +1 for the @ symbol
|
||||||
|
const replacement = `@${file.relativePath} `; // Always add space
|
||||||
|
const newValue = replaceMention(value, mentionStart, mentionEnd, replacement);
|
||||||
|
onChange(newValue);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setMentionQuery(null);
|
||||||
|
setMentionStart(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setMentionQuery(null);
|
||||||
|
setMentionStart(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box flexDirection="column">
|
||||||
borderStyle="single"
|
<Box
|
||||||
borderColor="#4c1d95"
|
borderStyle="single"
|
||||||
paddingX={1}
|
borderColor="#4c1d95"
|
||||||
width="100%"
|
paddingX={1}
|
||||||
marginTop={1}
|
width="100%"
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
>
|
>
|
||||||
<Text color="#a855f7" bold>
|
<Text color="#a855f7" bold>
|
||||||
❯{' '}
|
❯{' '}
|
||||||
</Text>
|
</Text>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
<MultiLineTextInput
|
<MultiLineTextInput
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
placeholder={placeholder}
|
onMentionChange={handleMentionChange}
|
||||||
/>
|
onAutocompleteNavigate={handleAutocompleteNavigate}
|
||||||
|
placeholder={placeholder}
|
||||||
|
isAutocompleteOpen={showAutocomplete}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
{showAutocomplete && (
|
||||||
|
<Box marginTop={0} paddingLeft={2}>
|
||||||
|
<FileAutocompleteDisplay
|
||||||
|
items={searchResults}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
maxDisplay={10}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { FileSearchResult } from '../utils/file-search';
|
||||||
|
|
||||||
|
interface FileAutocompleteDisplayProps {
|
||||||
|
items: FileSearchResult[];
|
||||||
|
selectedIndex: number;
|
||||||
|
maxDisplay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileAutocompleteDisplay({
|
||||||
|
items,
|
||||||
|
selectedIndex,
|
||||||
|
maxDisplay = 10,
|
||||||
|
}: FileAutocompleteDisplayProps) {
|
||||||
|
const displayItems = items.slice(0, maxDisplay);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null; // Don't show anything when no matches
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{displayItems.map((item, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const { relativePath } = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={relativePath}>
|
||||||
|
<Text color={isSelected ? '#ffffff' : '#6b7280'}>
|
||||||
|
{isSelected ? '> ' : ' '}
|
||||||
|
{relativePath}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FileSearchResult } from '../utils/file-search';
|
||||||
|
|
||||||
|
interface FileAutocompleteProps {
|
||||||
|
items: FileSearchResult[];
|
||||||
|
onSelect: (item: FileSearchResult, addSpace: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
maxDisplay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileAutocomplete({
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
isActive = false, // Changed default to false - parent controls this
|
||||||
|
maxDisplay = 10,
|
||||||
|
}: FileAutocompleteProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const displayItems = items.slice(0, maxDisplay);
|
||||||
|
|
||||||
|
// Reset selection when items change
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (displayItems[selectedIndex]) {
|
||||||
|
// Enter adds the file with a space
|
||||||
|
onSelect(displayItems[selectedIndex], true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.tab) {
|
||||||
|
// Tab also selects the current item with a space
|
||||||
|
if (displayItems[selectedIndex]) {
|
||||||
|
onSelect(displayItems[selectedIndex], true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null; // Don't show anything when no matches
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{displayItems.map((item, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const { relativePath } = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={relativePath}>
|
||||||
|
<Text color={isSelected ? '#ffffff' : '#6b7280'}>
|
||||||
|
{isSelected ? '> ' : ' '}
|
||||||
|
{relativePath}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,16 +5,22 @@ interface MultiLineTextInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
onMentionChange?: (mention: string | null, cursorPosition: number) => void;
|
||||||
|
onAutocompleteNavigate?: (direction: 'up' | 'down' | 'select' | 'close') => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
|
isAutocompleteOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiLineTextInput({
|
export function MultiLineTextInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onMentionChange,
|
||||||
|
onAutocompleteNavigate,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
focus = true,
|
focus = true,
|
||||||
|
isAutocompleteOpen = false,
|
||||||
}: MultiLineTextInputProps) {
|
}: MultiLineTextInputProps) {
|
||||||
const [cursorPosition, setCursorPosition] = useState(value.length);
|
const [cursorPosition, setCursorPosition] = useState(value.length);
|
||||||
const [showCursor, setShowCursor] = useState(true);
|
const [showCursor, setShowCursor] = useState(true);
|
||||||
|
@ -43,6 +49,43 @@ export function MultiLineTextInput({
|
||||||
setCursorPosition(value.length);
|
setCursorPosition(value.length);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// Detect @ mentions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onMentionChange) return;
|
||||||
|
|
||||||
|
// Find the last @ before cursor position
|
||||||
|
let mentionStart = -1;
|
||||||
|
for (let i = cursorPosition - 1; i >= 0; i--) {
|
||||||
|
if (value[i] === '@') {
|
||||||
|
mentionStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Stop if we hit whitespace or newline (mention ended)
|
||||||
|
if (value[i] === ' ' || value[i] === '\n' || value[i] === '\t') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentionStart !== -1) {
|
||||||
|
// Check if there's a space or newline before @ (or it's at the start)
|
||||||
|
const charBefore = mentionStart > 0 ? value[mentionStart - 1] : ' ';
|
||||||
|
if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t' || mentionStart === 0) {
|
||||||
|
// Extract the mention query (text after @)
|
||||||
|
const mentionEnd = cursorPosition;
|
||||||
|
const mentionQuery = value.substring(mentionStart + 1, mentionEnd);
|
||||||
|
|
||||||
|
// Only trigger if we're still in the mention (no spaces)
|
||||||
|
if (!mentionQuery.includes(' ') && !mentionQuery.includes('\n')) {
|
||||||
|
onMentionChange(mentionQuery, mentionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active mention
|
||||||
|
onMentionChange(null, -1);
|
||||||
|
}, [value, cursorPosition, onMentionChange]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
if (!focus) return;
|
if (!focus) return;
|
||||||
|
@ -50,6 +93,26 @@ export function MultiLineTextInput({
|
||||||
// Debug: Log what we're receiving
|
// Debug: Log what we're receiving
|
||||||
// console.log('Input:', input, 'Key:', key);
|
// console.log('Input:', input, 'Key:', key);
|
||||||
|
|
||||||
|
// Handle autocomplete navigation when it's open
|
||||||
|
if (isAutocompleteOpen && onAutocompleteNavigate) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
onAutocompleteNavigate('up');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
onAutocompleteNavigate('down');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onAutocompleteNavigate('close');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return || key.tab) {
|
||||||
|
onAutocompleteNavigate('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle backslash + n sequence for newline
|
// Handle backslash + n sequence for newline
|
||||||
if (expectingNewline) {
|
if (expectingNewline) {
|
||||||
setExpectingNewline(false);
|
setExpectingNewline(false);
|
||||||
|
@ -120,8 +183,9 @@ export function MultiLineTextInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Enter - submit (only if not modified)
|
// Handle Enter - submit (only if not modified and autocomplete is not open)
|
||||||
if (key.return && !key.meta && !key.shift && !key.ctrl) {
|
// This is now handled above in autocomplete navigation section
|
||||||
|
if (key.return && !key.meta && !key.shift && !key.ctrl && !isAutocompleteOpen) {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -145,8 +209,8 @@ export function MultiLineTextInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.upArrow) {
|
if (key.upArrow && !isAutocompleteOpen) {
|
||||||
// Move cursor to previous line
|
// Move cursor to previous line (only when autocomplete is closed)
|
||||||
const lines = value.split('\n');
|
const lines = value.split('\n');
|
||||||
let currentLineStart = 0;
|
let currentLineStart = 0;
|
||||||
let currentLineIndex = 0;
|
let currentLineIndex = 0;
|
||||||
|
@ -173,8 +237,8 @@ export function MultiLineTextInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.downArrow) {
|
if (key.downArrow && !isAutocompleteOpen) {
|
||||||
// Move cursor to next line
|
// Move cursor to next line (only when autocomplete is closed)
|
||||||
const lines = value.split('\n');
|
const lines = value.split('\n');
|
||||||
let currentLineStart = 0;
|
let currentLineStart = 0;
|
||||||
let currentLineIndex = 0;
|
let currentLineIndex = 0;
|
||||||
|
@ -303,3 +367,13 @@ export function MultiLineTextInput({
|
||||||
|
|
||||||
return renderTextWithCursor();
|
return renderTextWithCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to replace a mention with a file path
|
||||||
|
export function replaceMention(
|
||||||
|
text: string,
|
||||||
|
mentionStart: number,
|
||||||
|
mentionEnd: number,
|
||||||
|
replacement: string
|
||||||
|
): string {
|
||||||
|
return text.slice(0, mentionStart) + replacement + text.slice(mentionEnd);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Glob } from 'bun';
|
||||||
|
import micromatch from 'micromatch';
|
||||||
|
|
||||||
|
interface FileSearchOptions {
|
||||||
|
cwd?: string;
|
||||||
|
maxResults?: number;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSearchResult {
|
||||||
|
path: string;
|
||||||
|
relativePath: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileSearcher {
|
||||||
|
private cwd: string;
|
||||||
|
private fileCache: string[] | null = null;
|
||||||
|
private gitignorePatterns: string[] | null = null;
|
||||||
|
private lastCacheTime: number = 0;
|
||||||
|
private readonly CACHE_TTL = 5000; // 5 seconds cache
|
||||||
|
|
||||||
|
constructor(cwd: string = process.cwd()) {
|
||||||
|
this.cwd = resolve(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGitignore(content: string): string[] {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith('#'))
|
||||||
|
.map((pattern) => {
|
||||||
|
// Convert gitignore patterns to glob patterns
|
||||||
|
if (pattern.startsWith('!')) {
|
||||||
|
return pattern; // Keep negation patterns as-is
|
||||||
|
}
|
||||||
|
if (pattern.startsWith('/')) {
|
||||||
|
return pattern.slice(1); // Remove leading slash for relative patterns
|
||||||
|
}
|
||||||
|
if (!pattern.includes('/')) {
|
||||||
|
// Match file/folder anywhere in tree
|
||||||
|
return `**/${pattern}`;
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadGitignorePatterns(): string[] {
|
||||||
|
if (this.gitignorePatterns !== null) {
|
||||||
|
return this.gitignorePatterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns: string[] = [
|
||||||
|
// Default exclusions
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/.git/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/build/**',
|
||||||
|
'**/.turbo/**',
|
||||||
|
'**/.next/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'**/.cache/**',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load .gitignore if it exists
|
||||||
|
const gitignorePath = join(this.cwd, '.gitignore');
|
||||||
|
if (existsSync(gitignorePath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(gitignorePath, 'utf-8');
|
||||||
|
const gitignorePatterns = this.parseGitignore(content);
|
||||||
|
patterns.push(...gitignorePatterns);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read .gitignore:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .gitignore from parent directories up to root
|
||||||
|
let currentDir = resolve(this.cwd, '..');
|
||||||
|
const root = resolve('/');
|
||||||
|
while (currentDir !== root) {
|
||||||
|
const parentGitignorePath = join(currentDir, '.gitignore');
|
||||||
|
if (existsSync(parentGitignorePath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(parentGitignorePath, 'utf-8');
|
||||||
|
const gitignorePatterns = this.parseGitignore(content);
|
||||||
|
// Adjust patterns relative to our cwd
|
||||||
|
const adjustedPatterns = gitignorePatterns.map((pattern) => {
|
||||||
|
if (pattern.startsWith('!') || pattern.startsWith('**/')) {
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
return `**/${pattern}`;
|
||||||
|
});
|
||||||
|
patterns.push(...adjustedPatterns);
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentDir = resolve(currentDir, '..');
|
||||||
|
if (parentDir === currentDir) break; // Reached root
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gitignorePatterns = patterns;
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllFiles(includeHidden: boolean = false): Promise<string[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached files if still valid
|
||||||
|
if (this.fileCache && now - this.lastCacheTime < this.CACHE_TTL) {
|
||||||
|
return this.fileCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignorePatterns = this.loadGitignorePatterns();
|
||||||
|
|
||||||
|
// Use Bun's Glob to find all files
|
||||||
|
const glob = new Glob('**/*', {
|
||||||
|
cwd: this.cwd,
|
||||||
|
onlyFiles: true,
|
||||||
|
dot: includeHidden, // Include hidden files if requested
|
||||||
|
});
|
||||||
|
|
||||||
|
const allFiles: string[] = [];
|
||||||
|
|
||||||
|
for await (const file of glob.scan()) {
|
||||||
|
// Check if file should be ignored
|
||||||
|
const shouldIgnore = micromatch.isMatch(file, ignorePatterns, {
|
||||||
|
dot: true,
|
||||||
|
contains: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldIgnore) {
|
||||||
|
allFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this.fileCache = allFiles.sort();
|
||||||
|
this.lastCacheTime = now;
|
||||||
|
|
||||||
|
return this.fileCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchFiles(query: string, options: FileSearchOptions = {}): Promise<FileSearchResult[]> {
|
||||||
|
const { cwd = this.cwd, maxResults = 10, includeHidden = false } = options;
|
||||||
|
|
||||||
|
// Update cwd if different
|
||||||
|
if (cwd !== this.cwd) {
|
||||||
|
this.cwd = resolve(cwd);
|
||||||
|
this.fileCache = null; // Clear cache when directory changes
|
||||||
|
this.gitignorePatterns = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await this.getAllFiles(includeHidden);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
// Return first N files if no query
|
||||||
|
return files.slice(0, maxResults).map((file) => ({
|
||||||
|
path: join(this.cwd, file),
|
||||||
|
relativePath: file,
|
||||||
|
name: file.split('/').pop() || file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy search - split query into parts and check if all parts are in the path
|
||||||
|
const queryParts = query.toLowerCase().split('').filter(Boolean);
|
||||||
|
|
||||||
|
const scoredFiles = files
|
||||||
|
.map((file) => {
|
||||||
|
const lowerFile = file.toLowerCase();
|
||||||
|
const fileName = file.split('/').pop() || '';
|
||||||
|
const lowerFileName = fileName.toLowerCase();
|
||||||
|
|
||||||
|
// Check if all query parts appear in order in the file path
|
||||||
|
let score = 0;
|
||||||
|
let lastIndex = -1;
|
||||||
|
let allPartsFound = true;
|
||||||
|
|
||||||
|
for (const part of queryParts) {
|
||||||
|
const indexInPath = lowerFile.indexOf(part, lastIndex + 1);
|
||||||
|
const indexInName = lowerFileName.indexOf(part, lastIndex + 1);
|
||||||
|
|
||||||
|
if (indexInPath === -1) {
|
||||||
|
allPartsFound = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer matches in filename over path
|
||||||
|
if (indexInName !== -1) {
|
||||||
|
score += 2;
|
||||||
|
lastIndex = indexInName;
|
||||||
|
} else {
|
||||||
|
score += 1;
|
||||||
|
lastIndex = indexInPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for consecutive matches
|
||||||
|
if (lastIndex === indexInPath - 1) {
|
||||||
|
score += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allPartsFound) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for exact substring match
|
||||||
|
if (lowerFile.includes(query.toLowerCase())) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for matching at start of filename
|
||||||
|
if (lowerFileName.startsWith(query.toLowerCase())) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty for depth
|
||||||
|
const depth = file.split('/').length;
|
||||||
|
score -= depth * 0.1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { file: string; score: number } => item !== null)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, maxResults);
|
||||||
|
|
||||||
|
return scoredFiles.map(({ file }) => ({
|
||||||
|
path: join(this.cwd, file),
|
||||||
|
relativePath: file,
|
||||||
|
name: file.split('/').pop() || file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.fileCache = null;
|
||||||
|
this.gitignorePatterns = null;
|
||||||
|
this.lastCacheTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
let searcherInstance: FileSearcher | null = null;
|
||||||
|
|
||||||
|
export function getFileSearcher(cwd?: string): FileSearcher {
|
||||||
|
if (!searcherInstance || (cwd && resolve(cwd) !== searcherInstance.cwd)) {
|
||||||
|
searcherInstance = new FileSearcher(cwd);
|
||||||
|
}
|
||||||
|
return searcherInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchFiles(
|
||||||
|
query: string,
|
||||||
|
options?: FileSearchOptions
|
||||||
|
): Promise<FileSearchResult[]> {
|
||||||
|
const searcher = getFileSearcher(options?.cwd);
|
||||||
|
return searcher.searchFiles(query, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFileSearchCache(): void {
|
||||||
|
if (searcherInstance) {
|
||||||
|
searcherInstance.clearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FileSearchResult, FileSearchOptions };
|
Loading…
Reference in New Issue