| |
| |
| |
| |
|
|
| import * as path from 'path' |
| import * as fs from 'fs' |
| import * as os from 'os' |
| import ignore from 'ignore' |
| import { getGlobalConfig } from '../../utils/config.js' |
| import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' |
| import { findGitRoot, gitExe } from '../../utils/git.js' |
| import { ripGrep } from '../../utils/ripgrep.js' |
| import { getInitialSettings } from '../../utils/settings/settings.js' |
| import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js' |
|
|
| type FilesystemEntry = { |
| name: string |
| path: string |
| isDirectory: boolean |
| relativePath?: string |
| } |
|
|
| type ScoredFilesystemEntry = FilesystemEntry & { |
| score: number |
| } |
|
|
| const FILE_SEARCH_TIMEOUT_MS = 10_000 |
|
|
| const IMAGE_MIME_TYPES: Record<string, string> = { |
| '.png': 'image/png', |
| '.jpg': 'image/jpeg', |
| '.jpeg': 'image/jpeg', |
| '.gif': 'image/gif', |
| '.webp': 'image/webp', |
| '.svg': 'image/svg+xml', |
| '.bmp': 'image/bmp', |
| '.ico': 'image/x-icon', |
| '.avif': 'image/avif', |
| } |
|
|
| function isWithinRoot(targetPath: string, rootPath: string): boolean { |
| const target = normalizeComparablePath(targetPath) |
| const root = normalizeComparablePath(rootPath) |
| return target === root || target.startsWith(`${root}${path.sep}`) |
| } |
|
|
| function normalizeComparablePath(filePath: string): string { |
| const resolved = path.resolve(filePath) |
| return process.platform === 'win32' ? resolved.toLowerCase() : resolved |
| } |
|
|
| function isAllowedFilesystemPath(targetPath: string): boolean { |
| const resolvedPath = path.resolve(targetPath) |
| const homeDir = path.resolve(os.homedir()) |
|
|
| if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) { |
| return true |
| } |
|
|
| if (isWithinRegisteredFilesystemRoot(resolvedPath)) { |
| return true |
| } |
|
|
| |
| if (process.platform === 'darwin' && isWithinRoot(resolvedPath, '/private/tmp')) { |
| return true |
| } |
|
|
| return false |
| } |
|
|
| export async function handleFilesystemRoute(pathname: string, url: URL): Promise<Response> { |
| if (pathname === '/api/filesystem/browse') { |
| return handleBrowse(url) |
| } |
|
|
| if (pathname === '/api/filesystem/file') { |
| return handleServeFile(url) |
| } |
|
|
| return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 }) |
| } |
|
|
| async function handleServeFile(url: URL): Promise<Response> { |
| const filePath = url.searchParams.get('path') |
| if (!filePath) { |
| return json({ error: 'Missing path parameter' }, 400) |
| } |
|
|
| const resolvedPath = path.resolve(filePath) |
|
|
| if (!isAllowedFilesystemPath(resolvedPath)) { |
| return json({ error: 'Access denied: path outside allowed directory' }, 403) |
| } |
|
|
| const ext = path.extname(resolvedPath).toLowerCase() |
| const mimeType = IMAGE_MIME_TYPES[ext] |
|
|
| if (!mimeType) { |
| return json({ error: 'Unsupported file type' }, 400) |
| } |
|
|
| try { |
| const stat = fs.statSync(resolvedPath) |
| if (!stat.isFile()) { |
| return json({ error: 'Not a file' }, 400) |
| } |
| |
| if (stat.size > 50 * 1024 * 1024) { |
| return json({ error: 'File too large' }, 400) |
| } |
|
|
| const data = fs.readFileSync(resolvedPath) |
| return new Response(data, { |
| status: 200, |
| headers: { |
| 'Content-Type': mimeType, |
| 'Content-Length': String(stat.size), |
| 'Cache-Control': 'private, max-age=3600', |
| }, |
| }) |
| } catch { |
| return json({ error: 'File not found' }, 404) |
| } |
| } |
|
|
| async function handleBrowse(url: URL): Promise<Response> { |
| const targetPath = url.searchParams.get('path') || os.homedir() || '/' |
| const resolvedPath = path.resolve(targetPath) |
|
|
| if (!isAllowedFilesystemPath(resolvedPath)) { |
| return json({ error: 'Access denied: path outside allowed directory' }, 403) |
| } |
|
|
| const searchQuery = url.searchParams.get('search') || '' |
| const includeFiles = url.searchParams.get('includeFiles') === 'true' |
| const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200) |
|
|
| try { |
| const stat = fs.statSync(resolvedPath) |
| if (!stat.isDirectory()) { |
| return json({ error: 'Not a directory', path: resolvedPath }, 400) |
| } |
|
|
| if (searchQuery) { |
| const results = await searchFilesystemEntries(resolvedPath, searchQuery, { |
| includeFiles, |
| maxResults, |
| }) |
|
|
| return json({ |
| currentPath: resolvedPath, |
| parentPath: path.dirname(resolvedPath), |
| entries: results, |
| query: searchQuery, |
| }) |
| } |
|
|
| const entries = fs.readdirSync(resolvedPath, { withFileTypes: true }) |
|
|
| |
| const filtered = entries.filter((e) => { |
| if (e.name.startsWith('.')) return false |
| if (e.isDirectory()) return true |
| return includeFiles |
| }) |
|
|
| const entries_list = filtered |
| .map((e) => ({ |
| name: e.name, |
| path: path.join(resolvedPath, e.name), |
| isDirectory: e.isDirectory(), |
| relativePath: e.name, |
| })) |
| .sort((a, b) => { |
| if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 |
| return a.name.localeCompare(b.name) |
| }) |
|
|
| return json({ |
| currentPath: resolvedPath, |
| parentPath: path.dirname(resolvedPath), |
| entries: entries_list, |
| }) |
| } catch (err) { |
| return json({ error: `Cannot read directory: ${err}`, path: resolvedPath }, 500) |
| } |
| } |
|
|
| async function searchFilesystemEntries( |
| rootPath: string, |
| searchQuery: string, |
| options: { includeFiles: boolean; maxResults: number }, |
| ): Promise<FilesystemEntry[]> { |
| const normalizedQuery = normalizeSearchText(searchQuery) |
| if (!normalizedQuery) return [] |
|
|
| const candidates = await getSearchCandidates(rootPath, options.includeFiles) |
| const results = candidates |
| .map((entry): ScoredFilesystemEntry | null => { |
| const relativePath = entry.relativePath ?? entry.name |
| const score = scoreFilesystemEntry(entry.name, relativePath, normalizedQuery, entry.isDirectory) |
| return score > 0 ? { ...entry, score } : null |
| }) |
| .filter((entry): entry is ScoredFilesystemEntry => entry !== null) |
|
|
| return results |
| .sort((a, b) => { |
| if (a.score !== b.score) return b.score - a.score |
| if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 |
| const aPath = a.relativePath ?? a.name |
| const bPath = b.relativePath ?? b.name |
| const aDepth = pathDepth(aPath) |
| const bDepth = pathDepth(bPath) |
| if (aDepth !== bDepth) return aDepth - bDepth |
| return (a.relativePath ?? a.name).localeCompare(b.relativePath ?? b.name) |
| }) |
| .slice(0, options.maxResults) |
| .map(({ score: _score, ...entry }) => entry) |
| } |
|
|
| async function getSearchCandidates(rootPath: string, includeFiles: boolean): Promise<FilesystemEntry[]> { |
| const files = await getProjectSearchFiles(rootPath) |
| const entries = new Map<string, FilesystemEntry>() |
|
|
| for (const filePath of files) { |
| const normalizedFile = normalizeRelativePath(filePath) |
| if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue |
|
|
| let currentDir = path.posix.dirname(normalizedFile) |
| while (currentDir !== '.') { |
| addCandidate(entries, rootPath, currentDir, true) |
| const parent = path.posix.dirname(currentDir) |
| if (parent === currentDir) break |
| currentDir = parent |
| } |
|
|
| if (includeFiles) { |
| addCandidate(entries, rootPath, normalizedFile, false) |
| } |
| } |
|
|
| return [...entries.values()] |
| } |
|
|
| function addCandidate(entries: Map<string, FilesystemEntry>, rootPath: string, relativePath: string, isDirectory: boolean): void { |
| if (entries.has(relativePath)) return |
| entries.set(relativePath, { |
| name: path.posix.basename(relativePath), |
| path: path.join(rootPath, ...relativePath.split('/')), |
| isDirectory, |
| relativePath, |
| }) |
| } |
|
|
| async function getProjectSearchFiles(rootPath: string): Promise<string[]> { |
| const respectGitignore = shouldRespectGitignore() |
| const gitFiles = await getFilesUsingGit(rootPath, respectGitignore) |
| if (gitFiles !== null) { |
| return gitFiles |
| } |
|
|
| return getFilesUsingRipgrep(rootPath, respectGitignore) |
| } |
|
|
| function shouldRespectGitignore(): boolean { |
| const projectSettings = getInitialSettings() |
| const globalConfig = getGlobalConfig() |
| return projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true |
| } |
|
|
| async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Promise<string[] | null> { |
| const repoRoot = findGitRoot(rootPath) |
| if (!repoRoot) return null |
|
|
| const trackedResult = await execFileNoThrowWithCwd( |
| gitExe(), |
| ['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'], |
| { timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot }, |
| ) |
| if (trackedResult.code !== 0) return null |
|
|
| const untrackedArgs = respectGitignore |
| ? ['-c', 'core.quotepath=false', 'ls-files', '--others', '--exclude-standard'] |
| : ['-c', 'core.quotepath=false', 'ls-files', '--others'] |
| const untrackedResult = await execFileNoThrowWithCwd(gitExe(), untrackedArgs, { |
| timeout: FILE_SEARCH_TIMEOUT_MS, |
| cwd: repoRoot, |
| }) |
|
|
| const files = [ |
| ...lines(trackedResult.stdout), |
| ...(untrackedResult.code === 0 ? lines(untrackedResult.stdout) : []), |
| ] |
| let normalized = files |
| .map(filePath => normalizeGitPath(filePath, repoRoot, rootPath)) |
| .filter((filePath): filePath is string => filePath !== null) |
|
|
| const ignorePatterns = loadSearchIgnorePatterns(repoRoot, rootPath, false) |
| if (ignorePatterns) { |
| normalized = ignorePatterns.filter(normalized) |
| } |
|
|
| return [...new Set(normalized)] |
| } |
|
|
| async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean): Promise<string[]> { |
| const rgArgs = [ |
| '--files', |
| '--follow', |
| '--hidden', |
| '--glob', |
| '!.git/', |
| '--glob', |
| '!.svn/', |
| '--glob', |
| '!.hg/', |
| '--glob', |
| '!.bzr/', |
| '--glob', |
| '!.jj/', |
| '--glob', |
| '!.sl/', |
| ] |
| if (!respectGitignore) { |
| rgArgs.push('--no-ignore-vcs') |
| } |
|
|
| const files = await ripGrep(rgArgs, rootPath, AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS)) |
| let normalized = files |
| .map(filePath => normalizeRipgrepPath(filePath, rootPath)) |
| .filter((filePath): filePath is string => filePath !== null) |
|
|
| const ignorePatterns = loadSearchIgnorePatterns(rootPath, rootPath, true) |
| if (ignorePatterns) { |
| normalized = ignorePatterns.filter(normalized) |
| } |
|
|
| return normalized |
| } |
|
|
| function normalizeGitPath(filePath: string, repoRoot: string, rootPath: string): string | null { |
| const relativePath = path.relative(rootPath, path.join(repoRoot, filePath)) |
| const normalized = normalizeRelativePath(relativePath) |
| return isRelativeInsideRoot(normalized) ? normalized : null |
| } |
|
|
| function normalizeRipgrepPath(filePath: string, rootPath: string): string | null { |
| const relativePath = path.isAbsolute(filePath) ? path.relative(rootPath, filePath) : filePath |
| const normalized = normalizeRelativePath(relativePath) |
| return isRelativeInsideRoot(normalized) ? normalized : null |
| } |
|
|
| function normalizeRelativePath(filePath: string): string { |
| return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '') |
| } |
|
|
| function isRelativeInsideRoot(filePath: string): boolean { |
| return !!filePath && filePath !== '.' && !filePath.startsWith('../') && !path.isAbsolute(filePath) |
| } |
|
|
| function lines(output: string): string[] { |
| return output.trim().split('\n').map(line => line.trim()).filter(Boolean) |
| } |
|
|
| function loadSearchIgnorePatterns(repoRoot: string, rootPath: string, includeGitignore: boolean): ReturnType<typeof ignore> | null { |
| const ig = ignore() |
| let hasPatterns = false |
| const ignoreFiles = includeGitignore ? ['.gitignore', '.ignore', '.rgignore'] : ['.ignore', '.rgignore'] |
| const paths = [...new Set([repoRoot, rootPath])].flatMap(dir => ignoreFiles.map(fileName => path.join(dir, fileName))) |
|
|
| for (const ignorePath of paths) { |
| try { |
| ig.add(fs.readFileSync(ignorePath, 'utf8')) |
| hasPatterns = true |
| } catch { |
| |
| } |
| } |
|
|
| return hasPatterns ? ig : null |
| } |
|
|
| function normalizeSearchText(value: string): string { |
| return value |
| .trim() |
| .replace(/\\/g, '/') |
| .replace(/^@+/, '') |
| .replace(/^\.\/+/, '') |
| .replace(/\/+$/, '') |
| .toLowerCase() |
| } |
|
|
| function scoreFilesystemEntry(name: string, relativePath: string, query: string, isDirectory: boolean): number { |
| const normalizedName = normalizeSearchText(name) |
| const normalizedPath = normalizeSearchText(relativePath) |
| const pathNoExtension = normalizedPath.replace(/\.[^/.]+$/, '') |
| const pathPrefix = `${query}/` |
| const baseBoost = isDirectory ? 4 : 0 |
| const depthPenalty = Math.min(relativePath.split('/').length - 1, 8) * 2 |
|
|
| if (normalizedPath === query) return 150 + baseBoost - depthPenalty |
| if (pathNoExtension === query) return 144 + baseBoost - depthPenalty |
| if (normalizedPath.startsWith(pathPrefix)) return 136 + baseBoost - depthPenalty |
| if (normalizedPath.startsWith(query)) return 112 + baseBoost - depthPenalty |
| if (normalizedName === query) return 96 + baseBoost - depthPenalty |
| if (normalizedName.startsWith(query)) return 88 + baseBoost - depthPenalty |
| if (normalizedName.includes(query)) return 72 + baseBoost - depthPenalty |
| if (normalizedPath.includes(query)) return 60 + baseBoost - depthPenalty |
|
|
| const nameFuzzy = fuzzyScore(normalizedName, query) |
| if (nameFuzzy > 0) return 44 + nameFuzzy + baseBoost - depthPenalty |
|
|
| const pathFuzzy = fuzzyScore(normalizedPath, query) |
| if (pathFuzzy > 0) return 28 + pathFuzzy + baseBoost - depthPenalty |
|
|
| return 0 |
| } |
|
|
| function pathDepth(relativePath: string): number { |
| return relativePath.split('/').length |
| } |
|
|
| function fuzzyScore(value: string, query: string): number { |
| let queryIndex = 0 |
| let runLength = 0 |
| let score = 0 |
|
|
| for (let valueIndex = 0; valueIndex < value.length && queryIndex < query.length; valueIndex += 1) { |
| if (value[valueIndex] !== query[queryIndex]) { |
| runLength = 0 |
| continue |
| } |
|
|
| const boundaryBoost = valueIndex === 0 || ['/', '-', '_', '.', ' '].includes(value[valueIndex - 1] ?? '') |
| ? 3 |
| : 0 |
| runLength += 1 |
| score += 2 + boundaryBoost + Math.min(runLength, 4) |
| queryIndex += 1 |
| } |
|
|
| return queryIndex === query.length ? score : 0 |
| } |
|
|
| function json(data: unknown, status = 200): Response { |
| return new Response(JSON.stringify(data), { |
| status, |
| headers: { 'Content-Type': 'application/json' }, |
| }) |
| } |
|
|