239 lines
6.6 KiB
JavaScript
239 lines
6.6 KiB
JavaScript
const FILE_SEARCH_MAX_CONCURRENCY = 5;
|
|||
|
|
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
||
|
|
'node_modules',
|
||
|
|
'.git',
|
||
|
|
'dist',
|
||
|
|
'build',
|
||
|
|
'.next',
|
||
|
|
'.turbo',
|
||
|
|
'.cache',
|
||
|
|
'coverage',
|
||
|
|
'tmp',
|
||
|
|
'logs',
|
||
|
|
]);
|
||
|
|
|
||
|
|
const normalizeRelativeSearchPath = (rootPath, targetPath, path) => {
|
||
|
|
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
|
||
|
|
return relative.split(path.sep).join('/') || targetPath;
|
||
|
|
};
|
||
|
|
|
||
|
|
const shouldSkipSearchDirectory = (name, includeHidden) => {
|
||
|
|
if (!name) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (!includeHidden && name.startsWith('.')) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
||
|
|
};
|
||
|
|
|
||
|
|
const listDirectoryEntries = async (dirPath, fsPromises) => {
|
||
|
|
try {
|
||
|
|
return await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => {
|
||
|
|
if (!normalizedQuery) return 0;
|
||
|
|
|
||
|
|
const q = normalizedQuery;
|
||
|
|
const c = candidate.toLowerCase();
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
let score = 0;
|
||
|
|
let lastIndex = -1;
|
||
|
|
let consecutive = 0;
|
||
|
|
|
||
|
|
for (let i = 0; i < q.length; i += 1) {
|
||
|
|
const ch = q[i];
|
||
|
|
if (!ch || ch === ' ') continue;
|
||
|
|
|
||
|
|
const idx = c.indexOf(ch, lastIndex + 1);
|
||
|
|
if (idx === -1) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const gap = idx - lastIndex - 1;
|
||
|
|
if (gap === 0) {
|
||
|
|
consecutive += 1;
|
||
|
|
} else {
|
||
|
|
consecutive = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
score += 10;
|
||
|
|
score += Math.max(0, 18 - idx);
|
||
|
|
score -= Math.min(gap, 10);
|
||
|
|
|
||
|
|
if (idx === 0) {
|
||
|
|
score += 12;
|
||
|
|
} else {
|
||
|
|
const prev = c[idx - 1];
|
||
|
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||
|
|
score += 10;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
score += consecutive > 0 ? 12 : 0;
|
||
|
|
lastIndex = idx;
|
||
|
|
}
|
||
|
|
|
||
|
|
score += Math.max(0, 24 - Math.floor(c.length / 3));
|
||
|
|
return score;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const createFsSearchRuntime = ({ fsPromises, path, spawn, resolveGitBinaryForSpawn }) => {
|
||
|
|
const searchFilesystemFiles = async (rootPath, options) => {
|
||
|
|
const { limit, query, includeHidden, respectGitignore } = options;
|
||
|
|
const includeHiddenEntries = Boolean(includeHidden);
|
||
|
|
const normalizedQuery = query.trim().toLowerCase();
|
||
|
|
const matchAll = normalizedQuery.length === 0;
|
||
|
|
const queue = [rootPath];
|
||
|
|
const visited = new Set([rootPath]);
|
||
|
|
const shouldRespectGitignore = respectGitignore !== false;
|
||
|
|
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
|
||
|
|
const candidates = [];
|
||
|
|
|
||
|
|
while (queue.length > 0 && candidates.length < collectLimit) {
|
||
|
|
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
|
||
|
|
|
||
|
|
const dirResults = await Promise.all(
|
||
|
|
batch.map(async (dir) => {
|
||
|
|
if (!shouldRespectGitignore) {
|
||
|
|
return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const dirents = await listDirectoryEntries(dir, fsPromises);
|
||
|
|
const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean);
|
||
|
|
if (pathsToCheck.length === 0) {
|
||
|
|
return { dir, dirents, ignoredPaths: new Set() };
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await new Promise((resolve) => {
|
||
|
|
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
|
||
|
|
cwd: dir,
|
||
|
|
windowsHide: true,
|
||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||
|
|
});
|
||
|
|
|
||
|
|
let stdout = '';
|
||
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||
|
|
child.on('close', () => resolve(stdout));
|
||
|
|
child.on('error', () => resolve(''));
|
||
|
|
});
|
||
|
|
|
||
|
|
const ignoredNames = new Set(
|
||
|
|
String(result)
|
||
|
|
.split('\n')
|
||
|
|
.map((name) => name.trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
);
|
||
|
|
|
||
|
|
return { dir, dirents, ignoredPaths: ignoredNames };
|
||
|
|
} catch {
|
||
|
|
return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() };
|
||
|
|
}
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) {
|
||
|
|
for (const dirent of dirents) {
|
||
|
|
const entryName = dirent.name;
|
||
|
|
if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (shouldRespectGitignore && ignoredPaths.has(entryName)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const entryPath = path.join(currentDir, entryName);
|
||
|
|
|
||
|
|
if (dirent.isDirectory()) {
|
||
|
|
if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (!visited.has(entryPath)) {
|
||
|
|
visited.add(entryPath);
|
||
|
|
queue.push(entryPath);
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!dirent.isFile()) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath, path);
|
||
|
|
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
||
|
|
|
||
|
|
if (matchAll) {
|
||
|
|
candidates.push({
|
||
|
|
name: entryName,
|
||
|
|
path: entryPath,
|
||
|
|
relativePath,
|
||
|
|
extension,
|
||
|
|
score: 0,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath);
|
||
|
|
if (score !== null) {
|
||
|
|
candidates.push({
|
||
|
|
name: entryName,
|
||
|
|
path: entryPath,
|
||
|
|
relativePath,
|
||
|
|
extension,
|
||
|
|
score,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (candidates.length >= collectLimit) {
|
||
|
|
queue.length = 0;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (candidates.length >= collectLimit) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
|
||
|
|
name,
|
||
|
|
path: filePath,
|
||
|
|
relativePath,
|
||
|
|
extension,
|
||
|
|
}));
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
searchFilesystemFiles,
|
||
|
|
};
|
||
|
|
};
|