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
+2
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
import { initWebSocket, getClientCount } from "./services/ws.service.js";
import workspaceRouter from "./routes/workspace.js";
import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -26,6 +27,7 @@ app.get("/api/health", (_req, res) => {
app.use("/api/workspace", workspaceRouter);
app.use("/api/docker", dockerRouter);
app.use("/api/editor", editorRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist));
+51
View File
@@ -0,0 +1,51 @@
import { Router } from "express";
import {
getDirectoryTree,
readFile,
writeFile,
deleteFile,
} from "../services/editor.service.js";
const router = Router();
router.get("/tree/:repo", async (req, res) => {
try {
const tree = await getDirectoryTree(req.params.repo);
res.json(tree);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
});
router.get("/file/:repo/*path", async (req, res) => {
try {
const content = await readFile(req.params.repo, req.params.path);
res.json({ content });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(404).json({ error: message });
}
});
router.put("/file/:repo/*path", async (req, res) => {
try {
await writeFile(req.params.repo, req.params.path, req.body.content);
res.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
});
router.delete("/file/:repo/*path", async (req, res) => {
try {
await deleteFile(req.params.repo, req.params.path);
res.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
});
export default router;
@@ -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;
}