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:
plenarius
2026-04-20 19:09:19 -04:00
parent eaca2d8a6c
commit 02ca134743
8 changed files with 473 additions and 7 deletions
@@ -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;
}