feat: add fuzzy matching to file search
This commit is contained in:
@@ -211,6 +211,11 @@ pub async fn list_directory(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ScoredFileHit {
|
||||||
|
hit: FileSearchHit,
|
||||||
|
score: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn search_files(
|
pub async fn search_files(
|
||||||
directory: Option<String>,
|
directory: Option<String>,
|
||||||
@@ -227,14 +232,17 @@ pub async fn search_files(
|
|||||||
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
|
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
|
||||||
let match_all = normalized_query.is_empty();
|
let match_all = normalized_query.is_empty();
|
||||||
|
|
||||||
let mut files = Vec::new();
|
// Collect more candidates for fuzzy matching, then sort and trim
|
||||||
|
let collect_limit = if match_all { limit } else { (limit * 3).max(200) };
|
||||||
|
|
||||||
|
let mut candidates: Vec<ScoredFileHit> = Vec::new();
|
||||||
let mut queue = VecDeque::new();
|
let mut queue = VecDeque::new();
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
|
|
||||||
queue.push_back(resolved_root.clone());
|
queue.push_back(resolved_root.clone());
|
||||||
visited.insert(resolved_root.clone());
|
visited.insert(resolved_root.clone());
|
||||||
|
|
||||||
while !queue.is_empty() && files.len() < limit {
|
while !queue.is_empty() && candidates.len() < collect_limit {
|
||||||
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
|
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
|
||||||
let Some(dir) = queue.pop_front() else {
|
let Some(dir) = queue.pop_front() else {
|
||||||
break;
|
break;
|
||||||
@@ -261,7 +269,7 @@ pub async fn search_files(
|
|||||||
if should_skip_directory(&name_str) {
|
if should_skip_directory(&name_str) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if visited.insert(entry_path.clone()) && files.len() < limit {
|
if visited.insert(entry_path.clone()) && candidates.len() < collect_limit {
|
||||||
queue.push_back(entry_path);
|
queue.push_back(entry_path);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -272,39 +280,59 @@ pub async fn search_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relative_path = relative_path(&resolved_root, &entry_path);
|
let relative_path = relative_path(&resolved_root, &entry_path);
|
||||||
if !match_all {
|
|
||||||
let lowercase_name = name_str.to_lowercase();
|
|
||||||
let lowercase_path = relative_path.to_lowercase();
|
|
||||||
if !lowercase_name.contains(&normalized_query)
|
|
||||||
&& !lowercase_path.contains(&normalized_query)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let extension = entry_path
|
let extension = entry_path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|ext| ext.to_str())
|
.and_then(|ext| ext.to_str())
|
||||||
.map(|ext| ext.to_lowercase());
|
.map(|ext| ext.to_lowercase());
|
||||||
|
|
||||||
files.push(FileSearchHit {
|
let hit = FileSearchHit {
|
||||||
name: name_str.to_string(),
|
name: name_str.to_string(),
|
||||||
path: normalize_path(&entry_path),
|
path: normalize_path(&entry_path),
|
||||||
relative_path: relative_path.replace('\\', "/"),
|
relative_path: relative_path.replace('\\', "/"),
|
||||||
extension,
|
extension,
|
||||||
});
|
};
|
||||||
|
|
||||||
if files.len() >= limit {
|
if match_all {
|
||||||
|
candidates.push(ScoredFileHit { hit, score: 0 });
|
||||||
|
} else {
|
||||||
|
// Try fuzzy match against relative path (includes filename)
|
||||||
|
if let Some(score) = fuzzy_match_score(&normalized_query, &relative_path) {
|
||||||
|
candidates.push(ScoredFileHit { hit, score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates.len() >= collect_limit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if files.len() >= limit {
|
if candidates.len() >= collect_limit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by score descending, then by path length, then alphabetically
|
||||||
|
if !match_all {
|
||||||
|
candidates.sort_by(|a, b| {
|
||||||
|
match b.score.cmp(&a.score) {
|
||||||
|
std::cmp::Ordering::Equal => {
|
||||||
|
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
|
||||||
|
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: Vec<FileSearchHit> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|scored| scored.hit)
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(SearchFilesResponse {
|
Ok(SearchFilesResponse {
|
||||||
root: normalize_path(&resolved_root),
|
root: normalize_path(&resolved_root),
|
||||||
count: files.len(),
|
count: files.len(),
|
||||||
@@ -432,6 +460,83 @@ fn should_skip_directory(name: &str) -> bool {
|
|||||||
.any(|dir| dir.eq_ignore_ascii_case(name))
|
.any(|dir| dir.eq_ignore_ascii_case(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuzzy match scoring function.
|
||||||
|
/// Returns Some(score) if the query fuzzy-matches the candidate, None otherwise.
|
||||||
|
/// Higher scores indicate better matches.
|
||||||
|
fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let q: Vec<char> = query.to_lowercase().chars().collect();
|
||||||
|
let c: Vec<char> = candidate.to_lowercase().chars().collect();
|
||||||
|
let c_str = candidate.to_lowercase();
|
||||||
|
|
||||||
|
// Fast path: exact substring match gets high score
|
||||||
|
if c_str.contains(query) {
|
||||||
|
if let Some(idx) = c_str.find(query) {
|
||||||
|
let mut bonus: i32 = 0;
|
||||||
|
if idx == 0 {
|
||||||
|
bonus = 20;
|
||||||
|
} else if let Some(prev) = c.get(idx.saturating_sub(1)) {
|
||||||
|
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' {
|
||||||
|
bonus = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Some(100 + bonus - (idx.min(20) as i32) - (c.len() as i32 / 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match: all query chars must appear in order
|
||||||
|
let mut score: i32 = 0;
|
||||||
|
let mut last_index: i32 = -1;
|
||||||
|
let mut consecutive: i32 = 0;
|
||||||
|
|
||||||
|
for ch in &q {
|
||||||
|
if *ch == ' ' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize };
|
||||||
|
let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
|
||||||
|
|
||||||
|
match idx {
|
||||||
|
None => return None, // No match
|
||||||
|
Some(relative_idx) => {
|
||||||
|
let idx = search_start + relative_idx;
|
||||||
|
let gap = idx as i32 - last_index - 1;
|
||||||
|
|
||||||
|
if gap == 0 {
|
||||||
|
consecutive += 1;
|
||||||
|
} else {
|
||||||
|
consecutive = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 10;
|
||||||
|
score += (18 - idx as i32).max(0); // Prefer matches near start
|
||||||
|
score -= gap.min(10); // Penalize gaps
|
||||||
|
|
||||||
|
// Bonus for word boundary matches
|
||||||
|
if idx == 0 {
|
||||||
|
score += 12;
|
||||||
|
} else if let Some(prev) = c.get(idx - 1) {
|
||||||
|
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += if consecutive > 0 { 12 } else { 0 }; // Bonus for consecutive matches
|
||||||
|
last_index = idx as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer shorter paths
|
||||||
|
score += (24 - c.len() as i32 / 3).max(0);
|
||||||
|
|
||||||
|
Some(score)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_path(path: &Path) -> String {
|
fn normalize_path(path: &Path) -> String {
|
||||||
path.to_string_lossy().replace('\\', "/")
|
path.to_string_lossy().replace('\\', "/")
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-17
@@ -140,6 +140,77 @@ const shouldSkipSearchDirectory = (name: string) => {
|
|||||||
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match scoring function.
|
||||||
|
* Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise.
|
||||||
|
* Higher scores indicate better matches.
|
||||||
|
*/
|
||||||
|
const fuzzyMatchScore = (query: string, candidate: string): number | null => {
|
||||||
|
if (!query) return 0;
|
||||||
|
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const c = candidate.toLowerCase();
|
||||||
|
|
||||||
|
// Fast path: exact substring match gets high score
|
||||||
|
if (c.includes(q)) {
|
||||||
|
const idx = c.indexOf(q);
|
||||||
|
let bonus = 0;
|
||||||
|
if (idx === 0) {
|
||||||
|
bonus = 20;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
bonus = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match: all query chars must appear in order
|
||||||
|
let score = 0;
|
||||||
|
let lastIndex = -1;
|
||||||
|
let consecutive = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < q.length; i++) {
|
||||||
|
const ch = q[i];
|
||||||
|
if (!ch || ch === ' ') continue;
|
||||||
|
|
||||||
|
const idx = c.indexOf(ch, lastIndex + 1);
|
||||||
|
if (idx === -1) {
|
||||||
|
return null; // No match
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = idx - lastIndex - 1;
|
||||||
|
if (gap === 0) {
|
||||||
|
consecutive++;
|
||||||
|
} else {
|
||||||
|
consecutive = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 10;
|
||||||
|
score += Math.max(0, 18 - idx); // Prefer matches near start
|
||||||
|
score -= Math.min(gap, 10); // Penalize gaps
|
||||||
|
|
||||||
|
// Bonus for word boundary matches
|
||||||
|
if (idx === 0) {
|
||||||
|
score += 12;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer shorter paths
|
||||||
|
score += Math.max(0, 24 - Math.floor(c.length / 3));
|
||||||
|
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
|
||||||
const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => {
|
const searchFilesystemFiles = async (rootPath: string, query: string, limit: number) => {
|
||||||
const normalizedQuery = (query || '').trim().toLowerCase();
|
const normalizedQuery = (query || '').trim().toLowerCase();
|
||||||
const matchAll = normalizedQuery.length === 0;
|
const matchAll = normalizedQuery.length === 0;
|
||||||
@@ -147,10 +218,12 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num
|
|||||||
const rootUri = vscode.Uri.file(rootPath);
|
const rootUri = vscode.Uri.file(rootPath);
|
||||||
const queue: vscode.Uri[] = [rootUri];
|
const queue: vscode.Uri[] = [rootUri];
|
||||||
const visited = new Set<string>([normalizeFsPath(rootUri.fsPath)]);
|
const visited = new Set<string>([normalizeFsPath(rootUri.fsPath)]);
|
||||||
const results: Array<{ name: string; path: string; relativePath: string; extension?: string }> = [];
|
// Collect more candidates for fuzzy matching, then sort and trim
|
||||||
|
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
|
||||||
|
const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = [];
|
||||||
const MAX_CONCURRENCY = 5;
|
const MAX_CONCURRENCY = 5;
|
||||||
|
|
||||||
while (queue.length > 0 && results.length < limit) {
|
while (queue.length > 0 && candidates.length < collectLimit) {
|
||||||
const batch = queue.splice(0, MAX_CONCURRENCY);
|
const batch = queue.splice(0, MAX_CONCURRENCY);
|
||||||
const dirLists = await Promise.all(
|
const dirLists = await Promise.all(
|
||||||
batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][]))
|
batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][]))
|
||||||
@@ -184,34 +257,60 @@ const searchFilesystemFiles = async (rootPath: string, query: string, limit: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute));
|
const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute));
|
||||||
if (!matchAll) {
|
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
||||||
const lowercaseName = entryName.toLowerCase();
|
|
||||||
const lowercasePath = relativePath.toLowerCase();
|
if (matchAll) {
|
||||||
if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) {
|
candidates.push({
|
||||||
continue;
|
name: entryName,
|
||||||
|
path: absolute,
|
||||||
|
relativePath,
|
||||||
|
extension,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try fuzzy match against relative path (includes filename)
|
||||||
|
const score = fuzzyMatchScore(normalizedQuery, relativePath);
|
||||||
|
if (score !== null) {
|
||||||
|
candidates.push({
|
||||||
|
name: entryName,
|
||||||
|
path: absolute,
|
||||||
|
relativePath,
|
||||||
|
extension,
|
||||||
|
score,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
if (candidates.length >= collectLimit) {
|
||||||
name: entryName,
|
|
||||||
path: absolute,
|
|
||||||
relativePath,
|
|
||||||
extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length >= limit) {
|
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length >= limit) {
|
if (candidates.length >= collectLimit) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// Sort by score descending, then by path length, then alphabetically
|
||||||
|
if (!matchAll) {
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
if (a.relativePath.length !== b.relativePath.length) {
|
||||||
|
return a.relativePath.length - b.relativePath.length;
|
||||||
|
}
|
||||||
|
return a.relativePath.localeCompare(b.relativePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return top results without the score field
|
||||||
|
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
|
||||||
|
name,
|
||||||
|
path: filePath,
|
||||||
|
relativePath,
|
||||||
|
extension,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
||||||
|
|||||||
+117
-17
@@ -81,15 +81,89 @@ const listDirectoryEntries = async (dirPath) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match scoring function.
|
||||||
|
* Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise.
|
||||||
|
* Higher scores indicate better matches.
|
||||||
|
*/
|
||||||
|
const fuzzyMatchScore = (query, candidate) => {
|
||||||
|
if (!query) return 0;
|
||||||
|
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const c = candidate.toLowerCase();
|
||||||
|
|
||||||
|
// Fast path: exact substring match gets high score
|
||||||
|
if (c.includes(q)) {
|
||||||
|
const idx = c.indexOf(q);
|
||||||
|
// Bonus for match at start or after word boundary
|
||||||
|
let bonus = 0;
|
||||||
|
if (idx === 0) {
|
||||||
|
bonus = 20;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
bonus = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match: all query chars must appear in order
|
||||||
|
let score = 0;
|
||||||
|
let lastIndex = -1;
|
||||||
|
let consecutive = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < q.length; i++) {
|
||||||
|
const ch = q[i];
|
||||||
|
if (!ch || ch === ' ') continue;
|
||||||
|
|
||||||
|
const idx = c.indexOf(ch, lastIndex + 1);
|
||||||
|
if (idx === -1) {
|
||||||
|
return null; // No match
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = idx - lastIndex - 1;
|
||||||
|
if (gap === 0) {
|
||||||
|
consecutive++;
|
||||||
|
} else {
|
||||||
|
consecutive = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 10;
|
||||||
|
score += Math.max(0, 18 - idx); // Prefer matches near start
|
||||||
|
score -= Math.min(gap, 10); // Penalize gaps
|
||||||
|
|
||||||
|
// Bonus for word boundary matches
|
||||||
|
if (idx === 0) {
|
||||||
|
score += 12;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer shorter paths
|
||||||
|
score += Math.max(0, 24 - Math.floor(c.length / 3));
|
||||||
|
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
|
||||||
const searchFilesystemFiles = async (rootPath, options) => {
|
const searchFilesystemFiles = async (rootPath, options) => {
|
||||||
const { limit, query } = options;
|
const { limit, query } = options;
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const matchAll = normalizedQuery.length === 0;
|
const matchAll = normalizedQuery.length === 0;
|
||||||
const queue = [rootPath];
|
const queue = [rootPath];
|
||||||
const visited = new Set([rootPath]);
|
const visited = new Set([rootPath]);
|
||||||
const results = [];
|
// Collect more candidates for fuzzy matching, then sort and trim
|
||||||
|
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
while (queue.length > 0 && results.length < limit) {
|
while (queue.length > 0 && candidates.length < collectLimit) {
|
||||||
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
|
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
|
||||||
const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir)));
|
const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir)));
|
||||||
|
|
||||||
@@ -121,34 +195,60 @@ const searchFilesystemFiles = async (rootPath, options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath);
|
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath);
|
||||||
if (!matchAll) {
|
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
||||||
const lowercaseName = entryName.toLowerCase();
|
|
||||||
const lowercasePath = relativePath.toLowerCase();
|
if (matchAll) {
|
||||||
if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) {
|
candidates.push({
|
||||||
continue;
|
name: entryName,
|
||||||
|
path: entryPath,
|
||||||
|
relativePath,
|
||||||
|
extension,
|
||||||
|
score: 0
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try fuzzy match against relative path (includes filename)
|
||||||
|
const score = fuzzyMatchScore(normalizedQuery, relativePath);
|
||||||
|
if (score !== null) {
|
||||||
|
candidates.push({
|
||||||
|
name: entryName,
|
||||||
|
path: entryPath,
|
||||||
|
relativePath,
|
||||||
|
extension,
|
||||||
|
score
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
if (candidates.length >= collectLimit) {
|
||||||
name: entryName,
|
|
||||||
path: entryPath,
|
|
||||||
relativePath,
|
|
||||||
extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.length >= limit) {
|
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length >= limit) {
|
if (candidates.length >= collectLimit) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// Sort by score descending, then by path length, then alphabetically
|
||||||
|
if (!matchAll) {
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
if (a.relativePath.length !== b.relativePath.length) {
|
||||||
|
return a.relativePath.length - b.relativePath.length;
|
||||||
|
}
|
||||||
|
return a.relativePath.localeCompare(b.relativePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return top results without the score field
|
||||||
|
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
|
||||||
|
name,
|
||||||
|
path: filePath,
|
||||||
|
relativePath,
|
||||||
|
extension
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTimeoutSignal = (timeoutMs) => {
|
const createTimeoutSignal = (timeoutMs) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user