Files
CodePilot/src/lib/files.ts
T
7418andClaude Opus 4.6 d446da9717 fix: allow file tree access on Windows cross-drive project paths
FileTree component was not passing baseDir to the file API, causing
it to fall back to restricting access to the home directory. On
Windows, projects on a different drive (e.g., D:\) were blocked
with 403.

Changes:
- FileTree now passes workingDirectory as baseDir parameter
- File API routes no longer require baseDir to be under homeDir
  (allows cross-drive access on Windows)
- Still prevents using filesystem root as baseDir for safety
- Add isRootPath() utility for root path detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:33:06 +08:00

187 lines
4.3 KiB
TypeScript

import fs from 'fs/promises';
import path from 'path';
import type { FileTreeNode, FilePreview } from '@/types';
const IGNORED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'.next',
'__pycache__',
'.cache',
'.turbo',
'coverage',
'.output',
'build',
]);
const LANGUAGE_MAP: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
py: 'python',
rb: 'ruby',
rs: 'rust',
go: 'go',
java: 'java',
kt: 'kotlin',
swift: 'swift',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cs: 'csharp',
css: 'css',
scss: 'scss',
less: 'less',
html: 'html',
xml: 'xml',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
md: 'markdown',
mdx: 'markdown',
sql: 'sql',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
fish: 'fish',
ps1: 'powershell',
dockerfile: 'dockerfile',
graphql: 'graphql',
gql: 'graphql',
vue: 'vue',
svelte: 'svelte',
prisma: 'prisma',
env: 'dotenv',
lua: 'lua',
r: 'r',
php: 'php',
dart: 'dart',
zig: 'zig',
};
export function getFileLanguage(ext: string): string {
const normalized = ext.replace(/^\./, '').toLowerCase();
return LANGUAGE_MAP[normalized] || 'plaintext';
}
export function isPathSafe(basePath: string, targetPath: string): boolean {
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget.startsWith(resolvedBase + path.sep) || resolvedTarget === resolvedBase;
}
/**
* Check if a path is a filesystem root (e.g., `/`, `C:\`, `D:\`).
* Used to prevent using root as a baseDir for file browsing.
*/
export function isRootPath(p: string): boolean {
const resolved = path.resolve(p);
return resolved === path.parse(resolved).root;
}
export async function scanDirectory(dir: string, depth: number = 3): Promise<FileTreeNode[]> {
const resolvedDir = path.resolve(dir);
try {
await fs.access(resolvedDir);
} catch {
return [];
}
return scanDirectoryRecursive(resolvedDir, depth);
}
async function scanDirectoryRecursive(dir: string, depth: number): Promise<FileTreeNode[]> {
if (depth <= 0) return [];
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const nodes: FileTreeNode[] = [];
// Sort: directories first, then files, both alphabetically
const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
// Skip hidden files/dirs (except common config files)
if (entry.name.startsWith('.') && !entry.name.startsWith('.env')) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
const children = await scanDirectoryRecursive(fullPath, depth - 1);
nodes.push({
name: entry.name,
path: fullPath,
type: 'directory',
children,
});
} else if (entry.isFile()) {
const ext = path.extname(entry.name).replace(/^\./, '');
let size: number | undefined;
try {
const stat = await fs.stat(fullPath);
size = stat.size;
} catch {
// Skip files we can't stat
}
nodes.push({
name: entry.name,
path: fullPath,
type: 'file',
size,
extension: ext || undefined,
});
}
}
return nodes;
}
export async function readFilePreview(filePath: string, maxLines: number = 200): Promise<FilePreview> {
const resolvedPath = path.resolve(filePath);
try {
await fs.access(resolvedPath);
} catch {
throw new Error(`File not found: ${filePath}`);
}
const stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
throw new Error(`Not a file: ${filePath}`);
}
// Read the file content, limiting to maxLines
const content = await fs.readFile(resolvedPath, 'utf-8');
const lines = content.split('\n');
const truncated = lines.slice(0, maxLines).join('\n');
const ext = path.extname(resolvedPath).replace(/^\./, '');
const language = getFileLanguage(ext);
return {
path: resolvedPath,
content: truncated,
language,
line_count: lines.length,
};
}