feat: add file explorer with tree view and IDE layout
Backend: editor service for directory tree reading and file CRUD, editor routes at /api/editor with path traversal protection. Frontend: FileExplorer tree component with expand/collapse directories, IDELayout with sidebar + header + outlet, wired into App routing. Editor now receives state as props from App for cross-component file loading.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
const IGNORED_DIRS = new Set(["node_modules", ".git", ".nasher"]);
|
||||
|
||||
export async function getDirectoryTree(
|
||||
repo: string,
|
||||
subPath: string = "",
|
||||
): Promise<FileNode[]> {
|
||||
const fullPath = resolveRepoPath(repo, subPath);
|
||||
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
const nodes: FileNode[] = [];
|
||||
|
||||
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) {
|
||||
if (entry.name.startsWith(".") && entry.name !== ".gitignore") continue;
|
||||
|
||||
const relativePath = subPath ? `${subPath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name)) continue;
|
||||
const children = await getDirectoryTree(repo, relativePath);
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: "directory",
|
||||
children,
|
||||
});
|
||||
} else {
|
||||
nodes.push({ name: entry.name, path: relativePath, type: "file" });
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
const fullPath = resolveRepoPath(repo, filePath);
|
||||
return fs.readFile(fullPath, "utf-8");
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
repo: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fullPath = resolveRepoPath(repo, filePath);
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.writeFile(fullPath, content, "utf-8");
|
||||
}
|
||||
|
||||
export async function deleteFile(
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const fullPath = resolveRepoPath(repo, filePath);
|
||||
await fs.unlink(fullPath);
|
||||
}
|
||||
|
||||
function resolveRepoPath(repo: string, filePath: string): string {
|
||||
const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo));
|
||||
const resolved = path.resolve(repoRoot, filePath);
|
||||
if (!resolved.startsWith(repoRoot)) {
|
||||
throw new Error("Path traversal detected");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
Reference in New Issue
Block a user