diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ae11f7e..d54ba22 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)); diff --git a/packages/backend/src/routes/editor.ts b/packages/backend/src/routes/editor.ts new file mode 100644 index 0000000..2e3d083 --- /dev/null +++ b/packages/backend/src/routes/editor.ts @@ -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; diff --git a/packages/backend/src/services/editor.service.ts b/packages/backend/src/services/editor.service.ts new file mode 100644 index 0000000..2b997d8 --- /dev/null +++ b/packages/backend/src/services/editor.service.ts @@ -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 { + 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 { + const fullPath = resolveRepoPath(repo, filePath); + return fs.readFile(fullPath, "utf-8"); +} + +export async function writeFile( + repo: string, + filePath: string, + content: string, +): Promise { + 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 { + 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; +} diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index fb4249d..701bc42 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,13 +1,54 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { useState, useCallback } from "react"; import { Dashboard } from "./pages/Dashboard"; 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() { + const editorState = useEditorState(); + const [selectedFilePath, setSelectedFilePath] = useState(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 = ( + + ); + return ( - } /> - } /> + }> + } /> + } + /> + ); diff --git a/packages/frontend/src/components/editor/FileExplorer.tsx b/packages/frontend/src/components/editor/FileExplorer.tsx new file mode 100644 index 0000000..5c372e5 --- /dev/null +++ b/packages/frontend/src/components/editor/FileExplorer.tsx @@ -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 ( +
+ + {node.type === "directory" && expanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FileExplorer({ + repo, + selectedPath, + onFileSelect, +}: FileExplorerProps) { + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ + Explorer + + +
+ +
+ {loading && ( +
+ Loading... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && tree.length === 0 && ( +
+ No files found +
+ )} + + {!loading && + !error && + tree.map((node) => ( + + ))} +
+
+ ); +} diff --git a/packages/frontend/src/layouts/IDELayout.tsx b/packages/frontend/src/layouts/IDELayout.tsx new file mode 100644 index 0000000..7ad3f42 --- /dev/null +++ b/packages/frontend/src/layouts/IDELayout.tsx @@ -0,0 +1,39 @@ +import { Outlet } from "react-router-dom"; + +export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { + return ( +
+
+

+ Layonara Forge +

+
+ +
+ {sidebar && ( + + )} +
+ +
+
+
+ ); +} diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx index bb9ed65..afccf0a 100644 --- a/packages/frontend/src/pages/Editor.tsx +++ b/packages/frontend/src/pages/Editor.tsx @@ -1,9 +1,18 @@ import { useCallback, useMemo } from "react"; import { MonacoEditor } from "../components/editor/MonacoEditor"; import { EditorTabs } from "../components/editor/EditorTabs"; -import { useEditorState } from "../hooks/useEditorState"; -export function Editor() { +interface EditorProps { + editorState: ReturnType; +} + +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 { openTabs, activeTab, @@ -12,7 +21,7 @@ export function Editor() { closeFile, updateContent, getContent, - } = useEditorState(); + } = editorState; const tabs = useMemo( () => @@ -24,6 +33,11 @@ export function Editor() { ); const activeContent = activeTab ? (getContent(activeTab) ?? "") : ""; + const activeFilePath = activeTab + ? activeTab.includes(":") + ? activeTab.split(":").slice(1).join(":") + : activeTab + : ""; const handleChange = useCallback( (value: string) => { @@ -35,7 +49,7 @@ export function Editor() { ); return ( -
+
diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 2a37263..9f00223 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -12,9 +12,29 @@ async function request(path: string, options?: RequestInit): Promise { return res.json(); } +export interface FileNode { + name: string; + path: string; + type: "file" | "directory"; + children?: FileNode[]; +} + export const api = { health: () => request<{ status: string; wsClients: number }>("/health"), + editor: { + tree: (repo: string) => request(`/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: { getConfig: () => request>("/workspace/config"), updateConfig: (data: Record) =>