600 lines
17 KiB
TypeScript
600 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 = 20 * 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 20MB 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 = (requestedRoot?: string): string => {
|
|
const workspaceRoots = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [];
|
|
const requested = requestedRoot ? path.resolve(requestedRoot) : '';
|
|
return workspaceRoots.find((root) => path.resolve(root) === requested)
|
|
|| workspaceRoots[0]
|
|
|| 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, requestedRoot?: string): Promise<FsReadPathResolution> => {
|
|
const trimmed = targetPath.trim();
|
|
if (!trimmed) {
|
|
return { ok: false, status: 400, error: 'Path is required' };
|
|
}
|
|
|
|
const baseRoot = getFsAccessRoot(requestedRoot);
|
|
const resolved = resolveUserPath(trimmed, baseRoot);
|
|
if (!resolved) {
|
|
return { ok: false, status: 400, error: 'Path is required' };
|
|
}
|
|
|
|
try {
|
|
const canonicalPath = await fs.promises.realpath(resolved);
|
|
if (!isPathInside(resolved, path.resolve(baseRoot))) {
|
|
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' };
|
|
}
|
|
};
|