/** * Filesystem browser & search API — supports directory browsing and file search * for the DirectoryPicker component and @-triggered file search popup. */ 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' import { isSameOrInsidePathForPlatform, normalizeDriveRootPathForPlatform, } from '../services/windowsDrivePath.js' export type FilesystemEntry = { name: string path: string isDirectory: boolean relativePath?: string } type ScoredFilesystemEntry = FilesystemEntry & { score: number } const FILE_SEARCH_TIMEOUT_MS = 10_000 const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']) const IMAGE_MIME_TYPES: Record = { '.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 { return isSameOrInsidePathForPlatform(targetPath, rootPath) } function isVcsMetadataDirectoryName(name: string): boolean { return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase()) } export function isAllowedFilesystemPath(targetPath: string): boolean { const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) const homeDir = path.resolve(os.homedir()) if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) { return true } if (isWithinRegisteredFilesystemRoot(resolvedPath)) { return true } // macOS reports /tmp as /private/tmp via native folder pickers and realpath(). if (process.platform === 'darwin' && isWithinRoot(resolvedPath, '/private/tmp')) { return true } return false } export async function handleFilesystemRoute(pathname: string, url: URL): Promise { 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 { const filePath = url.searchParams.get('path') if (!filePath) { return json({ error: 'Missing path parameter' }, 400) } const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(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) } // Limit to 50MB 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 { const targetPath = url.searchParams.get('path') || os.homedir() || '/' const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(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 }) // Browse mode: show dot-prefixed project entries while keeping VCS internals hidden. const filtered = entries.filter((e) => { if (e.isDirectory()) return !isVcsMetadataDirectoryName(e.name) 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) } } export async function searchFilesystemEntries( rootPath: string, searchQuery: string, options: { includeFiles: boolean; includeDirectories?: boolean; maxResults: number }, ): Promise { const normalizedQuery = normalizeSearchText(searchQuery) if (!normalizedQuery) return [] const candidates = await getSearchCandidates( rootPath, options.includeFiles, options.includeDirectories ?? true, ) 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, includeDirectories: boolean, ): Promise { const files = await getProjectSearchFiles(rootPath) const entries = new Map() for (const filePath of files) { const normalizedFile = normalizeRelativePath(filePath) if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue if (includeDirectories) { 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, 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 { 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 { 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 { 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 | 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 { // Missing or unreadable ignore files should not break suggestions. } } 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' }, }) }