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>
187 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
}
|