mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fe8aac6b83
Windows folder selection can surface drive roots as D:, which Node treats as drive-relative instead of the absolute D:\ root. Normalize drive-root inputs before resolving launch directories, filesystem access roots, transcript metadata, and workspace paths. Use path.relative containment checks so drive roots and child projects remain distinct in session grouping and file access. Constraint: Windows drive-root inputs may arrive as C:, D:, or any other single-letter drive prefix Rejected: Special-case only D: | every Windows drive letter has the same drive-relative semantics Confidence: high Scope-risk: moderate Directive: Keep Windows drive-root normalization centralized; do not reintroduce string-prefix containment checks for workspace roots Tested: bun test src/server/__tests__/windows-drive-path.test.ts src/server/__tests__/filesystem.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- --run src/components/layout/Sidebar.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Not-tested: Real Windows desktop smoke on a physical Windows machine Related: #601
454 lines
14 KiB
TypeScript
454 lines
14 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'
|
|
|
|
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())
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// 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' },
|
|
})
|
|
}
|