mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
/**
|
|
* 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<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 {
|
|
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<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(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<Response> {
|
|
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<FilesystemEntry[]> {
|
|
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<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
|
|
|
|
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<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 {
|
|
// 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' },
|
|
})
|
|
}
|