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 { initWebSocket, getClientCount } from "./services/ws.service.js";
import workspaceRouter from "./routes/workspace.js"; import workspaceRouter from "./routes/workspace.js";
import dockerRouter from "./routes/docker.js"; import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@@ -26,6 +27,7 @@ app.get("/api/health", (_req, res) => {
app.use("/api/workspace", workspaceRouter); app.use("/api/workspace", workspaceRouter);
app.use("/api/docker", dockerRouter); app.use("/api/docker", dockerRouter);
app.use("/api/editor", editorRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist"); const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist)); 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;
}
+42 -1
View File
@@ -1,13 +1,54 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useState, useCallback } from "react";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor"; import { Editor } from "./pages/Editor";
import { IDELayout } from "./layouts/IDELayout";
import { FileExplorer } from "./components/editor/FileExplorer";
import { api } from "./services/api";
import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module";
export function App() { export function App() {
const editorState = useEditorState();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const handleFileSelect = useCallback(
async (repo: string, filePath: string) => {
setSelectedFilePath(filePath);
const tabKey = `${repo}:${filePath}`;
if (editorState.getContent(tabKey) !== undefined) {
editorState.selectTab(tabKey);
return;
}
try {
const { content } = await api.editor.readFile(repo, filePath);
editorState.openFile(tabKey, content);
} catch (err) {
console.error("Failed to load file:", err);
}
},
[editorState],
);
const sidebar = (
<FileExplorer
repo={DEFAULT_REPO}
selectedPath={selectedFilePath}
onFileSelect={handleFileSelect}
/>
);
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<IDELayout sidebar={sidebar} />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/editor" element={<Editor />} /> <Route
path="/editor"
element={<Editor editorState={editorState} />}
/>
</Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );
@@ -0,0 +1,215 @@
import { useState, useEffect, useCallback } from "react";
import { api, type FileNode } from "../../services/api";
interface FileExplorerProps {
repo: string;
selectedPath: string | null;
onFileSelect: (repo: string, filePath: string) => void;
}
function getFileIcon(name: string): string {
const ext = name.split(".").pop()?.toLowerCase();
switch (ext) {
case "nss":
return "S";
case "json":
return "J";
case "xml":
case "html":
return "<>";
case "md":
return "M";
case "yml":
case "yaml":
return "Y";
case "png":
case "jpg":
case "jpeg":
case "gif":
case "bmp":
case "tga":
return "I";
case "2da":
return "2";
case "ncs":
return "C";
case "git":
case "are":
case "ifo":
case "utc":
case "uti":
case "utp":
case "utd":
case "ute":
case "utm":
case "utt":
case "uts":
case "utw":
case "dlg":
case "jrl":
case "fac":
return "N";
default:
return "F";
}
}
function FileTreeNode({
node,
depth,
selectedPath,
onFileSelect,
repo,
}: {
node: FileNode;
depth: number;
selectedPath: string | null;
onFileSelect: (repo: string, filePath: string) => void;
repo: string;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const isSelected = selectedPath === node.path;
const handleClick = useCallback(() => {
if (node.type === "directory") {
setExpanded((prev) => !prev);
} else {
onFileSelect(repo, node.path);
}
}, [node, repo, onFileSelect]);
return (
<div>
<button
onClick={handleClick}
className="flex w-full items-center gap-1 px-1 py-0.5 text-left text-sm transition-colors hover:bg-white/5"
style={{
paddingLeft: `${depth * 16 + 8}px`,
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "13px",
}}
>
{node.type === "directory" ? (
<span
className="inline-block w-4 text-center text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{expanded ? "\u25BC" : "\u25B6"}
</span>
) : (
<span
className="inline-block w-4 text-center text-xs font-bold"
style={{ color: "var(--forge-accent)", fontSize: "10px" }}
>
{getFileIcon(node.name)}
</span>
)}
<span className="truncate">{node.name}</span>
</button>
{node.type === "directory" && expanded && node.children && (
<div>
{node.children.map((child) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onFileSelect={onFileSelect}
repo={repo}
/>
))}
</div>
)}
</div>
);
}
export function FileExplorer({
repo,
selectedPath,
onFileSelect,
}: FileExplorerProps) {
const [tree, setTree] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTree = useCallback(async () => {
if (!repo) return;
setLoading(true);
setError(null);
try {
const data = await api.editor.tree(repo);
setTree(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load files");
} finally {
setLoading(false);
}
}, [repo]);
useEffect(() => {
loadTree();
}, [loadTree]);
return (
<div
className="flex h-full flex-col overflow-hidden"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div
className="flex items-center justify-between px-3 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--forge-text-secondary)" }}
>
Explorer
</span>
<button
onClick={loadTree}
className="rounded p-1 text-xs transition-colors hover:bg-white/10"
style={{ color: "var(--forge-text-secondary)" }}
title="Refresh"
>
&#x21bb;
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
{loading && (
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Loading...
</div>
)}
{error && (
<div className="px-3 py-4 text-sm text-red-400">
{error}
</div>
)}
{!loading && !error && tree.length === 0 && (
<div className="px-3 py-4 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
No files found
</div>
)}
{!loading &&
!error &&
tree.map((node) => (
<FileTreeNode
key={node.path}
node={node}
depth={0}
selectedPath={selectedPath}
onFileSelect={onFileSelect}
repo={repo}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { Outlet } from "react-router-dom";
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
return (
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
<header
className="flex shrink-0 items-center px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<h1
className="text-lg font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</h1>
</header>
<div className="flex flex-1 overflow-hidden">
{sidebar && (
<aside
className="shrink-0 overflow-hidden"
style={{
width: "250px",
borderRight: "1px solid var(--forge-border)",
}}
>
{sidebar}
</aside>
)}
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
</div>
);
}
+19 -5
View File
@@ -1,9 +1,18 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { MonacoEditor } from "../components/editor/MonacoEditor"; import { MonacoEditor } from "../components/editor/MonacoEditor";
import { EditorTabs } from "../components/editor/EditorTabs"; import { EditorTabs } from "../components/editor/EditorTabs";
import { useEditorState } from "../hooks/useEditorState";
export function Editor() { interface EditorProps {
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
}
function displayName(tabKey: string): string {
const parts = tabKey.split(":");
const filePath = parts.length > 1 ? parts.slice(1).join(":") : tabKey;
return filePath.split("/").pop() ?? filePath;
}
export function Editor({ editorState }: EditorProps) {
const { const {
openTabs, openTabs,
activeTab, activeTab,
@@ -12,7 +21,7 @@ export function Editor() {
closeFile, closeFile,
updateContent, updateContent,
getContent, getContent,
} = useEditorState(); } = editorState;
const tabs = useMemo( const tabs = useMemo(
() => () =>
@@ -24,6 +33,11 @@ export function Editor() {
); );
const activeContent = activeTab ? (getContent(activeTab) ?? "") : ""; const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
const activeFilePath = activeTab
? activeTab.includes(":")
? activeTab.split(":").slice(1).join(":")
: activeTab
: "";
const handleChange = useCallback( const handleChange = useCallback(
(value: string) => { (value: string) => {
@@ -35,7 +49,7 @@ export function Editor() {
); );
return ( return (
<div className="flex h-screen flex-col" style={{ backgroundColor: "var(--forge-bg)" }}> <div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<EditorTabs <EditorTabs
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
@@ -46,7 +60,7 @@ export function Editor() {
{activeTab ? ( {activeTab ? (
<MonacoEditor <MonacoEditor
key={activeTab} key={activeTab}
filePath={activeTab} filePath={activeFilePath}
content={activeContent} content={activeContent}
onChange={handleChange} onChange={handleChange}
/> />
+20
View File
@@ -12,9 +12,29 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json(); return res.json();
} }
export interface FileNode {
name: string;
path: string;
type: "file" | "directory";
children?: FileNode[];
}
export const api = { export const api = {
health: () => request<{ status: string; wsClients: number }>("/health"), health: () => request<{ status: string; wsClients: number }>("/health"),
editor: {
tree: (repo: string) => request<FileNode[]>(`/editor/tree/${repo}`),
readFile: (repo: string, filePath: string) =>
request<{ content: string }>(`/editor/file/${repo}/${filePath}`),
writeFile: (repo: string, filePath: string, content: string) =>
request(`/editor/file/${repo}/${filePath}`, {
method: "PUT",
body: JSON.stringify({ content }),
}),
deleteFile: (repo: string, filePath: string) =>
request(`/editor/file/${repo}/${filePath}`, { method: "DELETE" }),
},
workspace: { workspace: {
getConfig: () => request<Record<string, unknown>>("/workspace/config"), getConfig: () => request<Record<string, unknown>>("/workspace/config"),
updateConfig: (data: Record<string, unknown>) => updateConfig: (data: Record<string, unknown>) =>