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:
@@ -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<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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/editor" element={<Editor />} />
|
||||
<Route element={<IDELayout sidebar={sidebar} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/editor"
|
||||
element={<Editor editorState={editorState} />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</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"
|
||||
>
|
||||
↻
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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<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 {
|
||||
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 (
|
||||
<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
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
@@ -46,7 +60,7 @@ export function Editor() {
|
||||
{activeTab ? (
|
||||
<MonacoEditor
|
||||
key={activeTab}
|
||||
filePath={activeTab}
|
||||
filePath={activeFilePath}
|
||||
content={activeContent}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -12,9 +12,29 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
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<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: {
|
||||
getConfig: () => request<Record<string, unknown>>("/workspace/config"),
|
||||
updateConfig: (data: Record<string, unknown>) =>
|
||||
|
||||
Reference in New Issue
Block a user