diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index ecd6961..3adcfa1 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Dashboard } from "./pages/Dashboard"; import { Editor } from "./pages/Editor"; import { Build } from "./pages/Build"; import { Server } from "./pages/Server"; +import { Toolset } from "./pages/Toolset"; import { IDELayout } from "./layouts/IDELayout"; import { FileExplorer } from "./components/editor/FileExplorer"; import { api } from "./services/api"; @@ -52,6 +53,7 @@ export function App() { /> } /> } /> + } /> diff --git a/packages/frontend/src/pages/Toolset.tsx b/packages/frontend/src/pages/Toolset.tsx new file mode 100644 index 0000000..4ea46ea --- /dev/null +++ b/packages/frontend/src/pages/Toolset.tsx @@ -0,0 +1,457 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { DiffEditor } from "@monaco-editor/react"; +import { api } from "../services/api"; +import { useWebSocket } from "../hooks/useWebSocket"; + +interface ChangeEntry { + filename: string; + gffType: string; + repoPath: string | null; + timestamp: number; +} + +interface DiffData { + original: string; + modified: string; + filename: string; +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? "Active" : "Inactive"} + + ); +} + +function ActionButton({ + label, + onClick, + disabled, + variant = "default", +}: { + label: string; + onClick: () => void; + disabled?: boolean; + variant?: "default" | "primary" | "danger"; +}) { + const styles = { + default: { + backgroundColor: "var(--forge-surface)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }, + primary: { + backgroundColor: "var(--forge-accent)", + borderColor: "var(--forge-accent)", + color: "#fff", + }, + danger: { + backgroundColor: "#7f1d1d", + borderColor: "#991b1b", + color: "#fca5a5", + }, + }; + + return ( + + ); +} + +function formatTimestamp(ts: number): string { + return new Date(ts).toLocaleTimeString(); +} + +export function Toolset() { + const { subscribe } = useWebSocket(); + const [active, setActive] = useState(false); + const [changes, setChanges] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [diffData, setDiffData] = useState(null); + const [loading, setLoading] = useState(false); + const [lastChange, setLastChange] = useState(null); + const diffContainerRef = useRef(null); + + const refresh = useCallback(async () => { + try { + const status = await api.toolset.status(); + setActive(status.active); + const list = await api.toolset.changes(); + setChanges(list); + if (list.length > 0) { + setLastChange(Math.max(...list.map((c) => c.timestamp))); + } + } catch { + // backend may not be reachable yet + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + const unsubs = [ + subscribe("toolset:change", (event) => { + const data = event.data as ChangeEntry; + setChanges((prev) => { + const idx = prev.findIndex((c) => c.filename === data.filename); + if (idx >= 0) { + const next = [...prev]; + next[idx] = data; + return next; + } + return [...prev, data]; + }); + setLastChange(data.timestamp); + }), + subscribe("toolset:applied", (event) => { + const { filename } = event.data as { filename: string }; + setChanges((prev) => prev.filter((c) => c.filename !== filename)); + setSelected((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + if (diffData?.filename === filename) setDiffData(null); + }), + subscribe("toolset:discarded", (event) => { + const { filename } = event.data as { filename: string }; + setChanges((prev) => prev.filter((c) => c.filename !== filename)); + setSelected((prev) => { + const next = new Set(prev); + next.delete(filename); + return next; + }); + if (diffData?.filename === filename) setDiffData(null); + }), + subscribe("toolset:discarded-all", () => { + setChanges([]); + setSelected(new Set()); + setDiffData(null); + }), + subscribe("toolset:watcher:started", () => setActive(true)), + subscribe("toolset:watcher:stopped", () => setActive(false)), + ]; + return () => unsubs.forEach((u) => u()); + }, [subscribe, diffData?.filename]); + + const toggleSelect = (filename: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(filename)) next.delete(filename); + else next.add(filename); + return next; + }); + }; + + const toggleAll = () => { + if (selected.size === changes.length) { + setSelected(new Set()); + } else { + setSelected(new Set(changes.map((c) => c.filename))); + } + }; + + const viewDiff = async (change: ChangeEntry) => { + setLoading(true); + try { + const full = await api.toolset.getChange(change.filename); + let original = ""; + if (change.repoPath) { + try { + const { content } = await api.editor.readFile( + "nwn-module", + change.repoPath, + ); + original = content; + } catch { + // file doesn't exist in repo yet + } + } + setDiffData({ + original, + modified: full.jsonContent ?? "", + filename: change.filename, + }); + } catch (err) { + console.error("Failed to load diff:", err); + } finally { + setLoading(false); + } + }; + + const handleStart = async () => { + await api.toolset.start(); + setActive(true); + }; + + const handleStop = async () => { + await api.toolset.stop(); + setActive(false); + }; + + const handleApplySelected = async () => { + if (selected.size === 0) return; + await api.toolset.apply(Array.from(selected)); + refresh(); + }; + + const handleApplyAll = async () => { + await api.toolset.applyAll(); + refresh(); + }; + + const handleDiscardSelected = async () => { + if (selected.size === 0) return; + await api.toolset.discard(Array.from(selected)); + refresh(); + }; + + const handleDiscardAll = async () => { + await api.toolset.discardAll(); + refresh(); + }; + + return ( +
+ {/* Status bar */} +
+
+

+ Toolset +

+ + + {changes.length} pending + + {lastChange && ( + + Last: {formatTimestamp(lastChange)} + + )} +
+
+ {active ? ( + + ) : ( + + )} +
+
+ + {/* Action bar */} + {changes.length > 0 && ( +
+ + + + + + {selected.size} selected + +
+ )} + + {/* Main content: table + diff */} +
+ {/* Changes table */} +
+ {changes.length === 0 ? ( +
+ {active + ? "Watching for changes in temp0/..." + : "Start the watcher to detect Toolset changes"} +
+ ) : ( + + + + + + + + + + + + {changes.map((change) => ( + viewDiff(change)} + > + + + + + + + ))} + +
+ + FilenameType + Repo Path + Time
+ { + e.stopPropagation(); + toggleSelect(change.filename); + }} + onClick={(e) => e.stopPropagation()} + className="cursor-pointer" + /> + {change.filename} + + {change.gffType} + + + {change.repoPath ?? "—"} + + {formatTimestamp(change.timestamp)} +
+ )} +
+ + {/* Diff panel */} + {diffData && ( +
+
+ + Diff: {diffData.filename} + {loading && ( + + {" "} + (loading...) + + )} + + +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index b2e8b06..04eae9a 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -79,6 +79,42 @@ export const api = { }), }, + toolset: { + status: () => + request<{ active: boolean; pendingCount: number }>("/toolset/status"), + start: () => request("/toolset/start", { method: "POST" }), + stop: () => request("/toolset/stop", { method: "POST" }), + changes: () => + request< + Array<{ + filename: string; + gffType: string; + repoPath: string | null; + timestamp: number; + }> + >("/toolset/changes"), + getChange: (filename: string) => + request<{ + filename: string; + gffType: string; + repoPath: string | null; + timestamp: number; + jsonContent?: string; + }>(`/toolset/changes/${filename}`), + apply: (files: string[]) => + request("/toolset/apply", { + method: "POST", + body: JSON.stringify({ files }), + }), + applyAll: () => request("/toolset/apply-all", { method: "POST" }), + discard: (files: string[]) => + request("/toolset/discard", { + method: "POST", + body: JSON.stringify({ files }), + }), + discardAll: () => request("/toolset/discard-all", { method: "POST" }), + }, + docker: { containers: () => request>>("/docker/containers"), pull: (image: string) =>