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:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user