Avoid gitignore filtering during folder browsing Show folder load errors instead of empty folders Add timeout fallback for gitignore checks
598 lines
17 KiB
TypeScript
598 lines
17 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as vscode from 'vscode';
|
|
import { execGit } from './bridge-git-process-runtime';
|
|
|
|
const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024;
|
|
|
|
const createGitCheckIgnoreTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
return 2500;
|
|
};
|
|
|
|
const GIT_CHECK_IGNORE_TIMEOUT_MS = createGitCheckIgnoreTimeoutMs();
|
|
|
|
const guessMimeTypeFromExtension = (ext: string) => {
|
|
switch (ext) {
|
|
case '.png':
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
case '.gif':
|
|
case '.bmp':
|
|
case '.webp':
|
|
return `image/${ext.replace('.', '')}`;
|
|
case '.pdf':
|
|
return 'application/pdf';
|
|
case '.txt':
|
|
case '.log':
|
|
return 'text/plain';
|
|
case '.json':
|
|
return 'application/json';
|
|
case '.md':
|
|
case '.markdown':
|
|
return 'text/markdown';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
};
|
|
|
|
const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
|
|
|
|
export const parseDroppedFileReference = (rawReference: string):
|
|
| { uri: vscode.Uri }
|
|
| { skipped: { name: string; reason: string } } => {
|
|
const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, '');
|
|
if (!trimmed) {
|
|
return { skipped: { name: rawReference, reason: 'Empty drop reference' } };
|
|
}
|
|
|
|
if (hasUriScheme(trimmed)) {
|
|
try {
|
|
const parsed = vscode.Uri.parse(trimmed, true);
|
|
if (parsed.scheme !== 'file') {
|
|
return {
|
|
skipped: {
|
|
name: trimmed,
|
|
reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`,
|
|
},
|
|
};
|
|
}
|
|
return { uri: parsed };
|
|
} catch (error) {
|
|
return {
|
|
skipped: {
|
|
name: trimmed,
|
|
reason: error instanceof Error ? error.message : 'Invalid URI',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!path.isAbsolute(trimmed)) {
|
|
return {
|
|
skipped: {
|
|
name: trimmed,
|
|
reason: 'Drop reference is not an absolute file path',
|
|
},
|
|
};
|
|
}
|
|
|
|
return { uri: vscode.Uri.file(trimmed) };
|
|
};
|
|
|
|
export const readUriAsAttachment = async (
|
|
uri: vscode.Uri,
|
|
fallbackName?: string,
|
|
): Promise<
|
|
| { file: { name: string; mimeType: string; size: number; dataUrl: string } }
|
|
| { skipped: { name: string; reason: string } }
|
|
> => {
|
|
const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file');
|
|
|
|
try {
|
|
const stat = await vscode.workspace.fs.stat(uri);
|
|
if ((stat.type & vscode.FileType.Directory) !== 0) {
|
|
return { skipped: { name, reason: 'Folders are not supported' } };
|
|
}
|
|
|
|
const size = stat.size ?? 0;
|
|
if (size > MAX_FILE_ATTACH_SIZE_BYTES) {
|
|
return { skipped: { name, reason: 'File exceeds 10MB limit' } };
|
|
}
|
|
|
|
const bytes = await vscode.workspace.fs.readFile(uri);
|
|
const ext = path.extname(name).toLowerCase();
|
|
const mimeType = guessMimeTypeFromExtension(ext);
|
|
const base64 = Buffer.from(bytes).toString('base64');
|
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
|
|
|
return { file: { name, mimeType, size, dataUrl } };
|
|
} catch (error) {
|
|
return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } };
|
|
}
|
|
};
|
|
|
|
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
|
const normalizedCandidate = path.resolve(candidatePath);
|
|
const normalizedParent = path.resolve(parentPath);
|
|
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
|
};
|
|
|
|
export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
|
|
|
const execGitCheckIgnore = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number } | null> => {
|
|
if (GIT_CHECK_IGNORE_TIMEOUT_MS <= 0) {
|
|
return execGit(args, cwd);
|
|
}
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
execGit(args, cwd),
|
|
new Promise<null>((resolve) => {
|
|
timeout = setTimeout(() => resolve(null), GIT_CHECK_IGNORE_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
};
|
|
|
|
const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise<Set<string>> => {
|
|
if (names.length === 0) {
|
|
return new Set();
|
|
}
|
|
|
|
const result = await execGitCheckIgnore(['check-ignore', '--', ...names], cwd);
|
|
if (!result) {
|
|
return new Set();
|
|
}
|
|
if (result.exitCode !== 0 || !result.stdout) {
|
|
return new Set();
|
|
}
|
|
|
|
return new Set(
|
|
result.stdout
|
|
.split('\n')
|
|
.map((name: string) => name.trim())
|
|
.filter(Boolean),
|
|
);
|
|
};
|
|
|
|
const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise<Set<string>> => {
|
|
if (paths.length === 0) {
|
|
return new Set();
|
|
}
|
|
|
|
const result = await execGitCheckIgnore(['check-ignore', '--', ...paths], cwd);
|
|
if (!result) {
|
|
return new Set();
|
|
}
|
|
if (result.exitCode !== 0 || !result.stdout) {
|
|
return new Set();
|
|
}
|
|
|
|
return new Set(
|
|
result.stdout
|
|
.split('\n')
|
|
.map((name: string) => name.trim())
|
|
.filter(Boolean),
|
|
);
|
|
};
|
|
|
|
const expandTildePath = (value: string) => {
|
|
const trimmed = (value || '').trim();
|
|
if (!trimmed) {
|
|
return trimmed;
|
|
}
|
|
|
|
if (trimmed === '~') {
|
|
return os.homedir();
|
|
}
|
|
|
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
return path.join(os.homedir(), trimmed.slice(2));
|
|
}
|
|
|
|
return trimmed;
|
|
};
|
|
|
|
export const resolveUserPath = (value: string, baseDirectory: string) => {
|
|
const expanded = expandTildePath(value);
|
|
if (!expanded) {
|
|
return expanded;
|
|
}
|
|
if (path.isAbsolute(expanded)) {
|
|
return expanded;
|
|
}
|
|
return path.resolve(baseDirectory, expanded);
|
|
};
|
|
|
|
export const listDirectoryEntries = async (dirPath: string) => {
|
|
const uri = vscode.Uri.file(dirPath);
|
|
const entries = await vscode.workspace.fs.readDirectory(uri);
|
|
return entries.map(([name, fileType]) => ({
|
|
name,
|
|
path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath),
|
|
isDirectory: fileType === vscode.FileType.Directory,
|
|
}));
|
|
};
|
|
|
|
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
|
'node_modules',
|
|
'.git',
|
|
'dist',
|
|
'build',
|
|
'.next',
|
|
'.turbo',
|
|
'.cache',
|
|
'coverage',
|
|
'tmp',
|
|
'logs',
|
|
]);
|
|
|
|
const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => {
|
|
if (!name) {
|
|
return false;
|
|
}
|
|
if (!includeHidden && name.startsWith('.')) {
|
|
return true;
|
|
}
|
|
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
|
};
|
|
|
|
const fuzzyMatchScore = (query: string, candidate: string): number | null => {
|
|
if (!query) return 0;
|
|
|
|
const q = query.toLowerCase();
|
|
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++) {
|
|
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++;
|
|
} 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;
|
|
};
|
|
|
|
const searchFilesystemFiles = async (
|
|
rootPath: string,
|
|
query: string,
|
|
limit: number,
|
|
includeHidden: boolean,
|
|
respectGitignore: boolean,
|
|
timeBudgetMs?: number,
|
|
) => {
|
|
const normalizedQuery = (query || '').trim().toLowerCase();
|
|
const matchAll = normalizedQuery.length === 0;
|
|
const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null;
|
|
|
|
const rootUri = vscode.Uri.file(rootPath);
|
|
const queue: vscode.Uri[] = [rootUri];
|
|
const visited = new Set<string>([normalizeFsPath(rootUri.fsPath)]);
|
|
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 = 10;
|
|
|
|
while (queue.length > 0 && candidates.length < collectLimit) {
|
|
if (deadline && Date.now() > deadline) {
|
|
break;
|
|
}
|
|
const batch = queue.splice(0, MAX_CONCURRENCY);
|
|
const dirLists = await Promise.all(
|
|
batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])),
|
|
);
|
|
|
|
for (let index = 0; index < batch.length; index += 1) {
|
|
if (deadline && Date.now() > deadline) {
|
|
break;
|
|
}
|
|
const currentDir = batch[index];
|
|
const dirents = dirLists[index];
|
|
|
|
const ignoredNames = respectGitignore
|
|
? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name))
|
|
: new Set<string>();
|
|
|
|
for (const [entryName, entryType] of dirents) {
|
|
if (!entryName || (!includeHidden && entryName.startsWith('.'))) {
|
|
continue;
|
|
}
|
|
|
|
if (respectGitignore && ignoredNames.has(entryName)) {
|
|
continue;
|
|
}
|
|
|
|
const entryUri = vscode.Uri.joinPath(currentDir, entryName);
|
|
const absolute = normalizeFsPath(entryUri.fsPath);
|
|
|
|
if (entryType === vscode.FileType.Directory) {
|
|
if (shouldSkipSearchDirectory(entryName, includeHidden)) {
|
|
continue;
|
|
}
|
|
if (!visited.has(absolute)) {
|
|
visited.add(absolute);
|
|
queue.push(entryUri);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (entryType !== vscode.FileType.File) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute));
|
|
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
|
|
|
if (matchAll) {
|
|
candidates.push({
|
|
name: entryName,
|
|
path: absolute,
|
|
relativePath,
|
|
extension,
|
|
score: 0,
|
|
});
|
|
} else {
|
|
const score = fuzzyMatchScore(normalizedQuery, relativePath);
|
|
if (score !== null) {
|
|
candidates.push({
|
|
name: entryName,
|
|
path: absolute,
|
|
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,
|
|
}));
|
|
};
|
|
|
|
export const searchDirectory = async (
|
|
directory: string,
|
|
query: string,
|
|
limit = 60,
|
|
includeHidden = false,
|
|
respectGitignore = true,
|
|
) => {
|
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
const rootPath = directory
|
|
? resolveUserPath(directory, workspaceRoot)
|
|
: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
|
if (!rootPath) return [];
|
|
|
|
const sanitizedQuery = query?.trim() || '';
|
|
if (!sanitizedQuery) {
|
|
return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore);
|
|
}
|
|
|
|
const escapeGlob = (value: string) => value
|
|
.replace(/[\\{}()?*]/g, '\\$&')
|
|
.replace(/\[/g, '\\[')
|
|
.replace(/\]/g, '\\]');
|
|
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
|
const mapResults = (results: vscode.Uri[]) => results.map((file) => {
|
|
const absolute = normalizeFsPath(file.fsPath);
|
|
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
|
const name = path.basename(absolute);
|
|
return {
|
|
name,
|
|
path: absolute,
|
|
relativePath: relative || name,
|
|
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
|
};
|
|
});
|
|
const filterGitIgnored = async (results: vscode.Uri[]) => {
|
|
if (!respectGitignore || results.length === 0) {
|
|
return results;
|
|
}
|
|
|
|
const relativePaths = results.map((file) => {
|
|
const relative = normalizeFsPath(path.relative(rootPath, file.fsPath));
|
|
return relative || path.basename(file.fsPath);
|
|
});
|
|
|
|
const ignored = await gitCheckIgnorePaths(rootPath, relativePaths);
|
|
if (ignored.size === 0) {
|
|
return results;
|
|
}
|
|
|
|
return results.filter((_, index) => !ignored.has(relativePaths[index]));
|
|
};
|
|
|
|
try {
|
|
const escapedQuery = escapeGlob(sanitizedQuery);
|
|
const pattern = `**/*${escapedQuery}*`;
|
|
const results = await vscode.workspace.findFiles(
|
|
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
|
exclude,
|
|
limit,
|
|
);
|
|
|
|
if (Array.isArray(results) && results.length > 0) {
|
|
const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.'));
|
|
const filtered = await filterGitIgnored(visible);
|
|
if (filtered.length > 0) {
|
|
return mapResults(filtered);
|
|
}
|
|
}
|
|
|
|
if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) {
|
|
const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`;
|
|
const fuzzyResults = await vscode.workspace.findFiles(
|
|
new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern),
|
|
exclude,
|
|
limit,
|
|
);
|
|
|
|
if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) {
|
|
const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.'));
|
|
const filtered = await filterGitIgnored(visible);
|
|
if (filtered.length > 0) {
|
|
return mapResults(filtered);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to filesystem traversal.
|
|
}
|
|
|
|
return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500);
|
|
};
|
|
|
|
export const fetchModelsMetadata = async () => {
|
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
|
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
|
try {
|
|
const response = await fetch('https://models.dev/api.json', {
|
|
signal: controller?.signal,
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`models.dev responded with ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} finally {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
|
|
export const getFsMimeType = (filePath: string): string => {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mimeMap: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.md': 'text/markdown; charset=utf-8',
|
|
'.markdown': 'text/markdown; charset=utf-8',
|
|
'.mmd': 'text/plain; charset=utf-8',
|
|
'.mermaid': 'text/plain; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.pdf': 'application/pdf',
|
|
};
|
|
return mimeMap[ext] || 'application/octet-stream';
|
|
};
|
|
|
|
export type FsReadPathResolution =
|
|
| { ok: true; resolvedPath: string }
|
|
| { ok: false; status: number; error: string };
|
|
|
|
export const resolveFileReadPath = async (targetPath: string): Promise<FsReadPathResolution> => {
|
|
const trimmed = targetPath.trim();
|
|
if (!trimmed) {
|
|
return { ok: false, status: 400, error: 'Path is required' };
|
|
}
|
|
|
|
const baseRoot = getFsAccessRoot();
|
|
const resolved = resolveUserPath(trimmed, baseRoot);
|
|
if (!resolved) {
|
|
return { ok: false, status: 400, error: 'Path is required' };
|
|
}
|
|
|
|
try {
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|
fs.promises.realpath(resolved),
|
|
fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)),
|
|
]);
|
|
|
|
if (!isPathInside(canonicalPath, canonicalBase)) {
|
|
return { ok: false, status: 403, error: 'Access to file denied' };
|
|
}
|
|
|
|
return { ok: true, resolvedPath: canonicalPath };
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err?.code === 'ENOENT') {
|
|
return { ok: false, status: 404, error: 'File not found' };
|
|
}
|
|
return { ok: false, status: 500, error: 'Failed to resolve file path' };
|
|
}
|
|
};
|