feat: add Toolset page with live change detection and diff viewer

This commit is contained in:
plenarius
2026-04-20 20:01:27 -04:00
parent 269ce1178c
commit 3b91c0312d
3 changed files with 495 additions and 0 deletions
+2
View File
@@ -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() {
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
</Route>
</Routes>
</BrowserRouter>
+457
View File
@@ -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 (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
active
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{active ? "Active" : "Inactive"}
</span>
);
}
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 (
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
>
{label}
</button>
);
}
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<ChangeEntry[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [diffData, setDiffData] = useState<DiffData | null>(null);
const [loading, setLoading] = useState(false);
const [lastChange, setLastChange] = useState<number | null>(null);
const diffContainerRef = useRef<HTMLDivElement>(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 (
<div
className="flex h-full flex-col overflow-hidden"
style={{ color: "var(--forge-text)" }}
>
{/* Status bar */}
<div
className="flex shrink-0 items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<div className="flex items-center gap-4">
<h2
className="text-xl font-bold"
style={{ color: "var(--forge-accent)" }}
>
Toolset
</h2>
<StatusBadge active={active} />
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{changes.length} pending
</span>
{lastChange && (
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
Last: {formatTimestamp(lastChange)}
</span>
)}
</div>
<div className="flex gap-2">
{active ? (
<ActionButton label="Stop Watcher" onClick={handleStop} />
) : (
<ActionButton
label="Start Watcher"
onClick={handleStart}
variant="primary"
/>
)}
</div>
</div>
{/* Action bar */}
{changes.length > 0 && (
<div
className="flex shrink-0 items-center gap-2 px-6 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<ActionButton
label="Apply Selected"
variant="primary"
disabled={selected.size === 0}
onClick={handleApplySelected}
/>
<ActionButton
label="Apply All"
variant="primary"
onClick={handleApplyAll}
/>
<ActionButton
label="Discard Selected"
variant="danger"
disabled={selected.size === 0}
onClick={handleDiscardSelected}
/>
<ActionButton
label="Discard All"
variant="danger"
onClick={handleDiscardAll}
/>
<span
className="ml-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{selected.size} selected
</span>
</div>
)}
{/* Main content: table + diff */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Changes table */}
<div
className="shrink-0 overflow-auto"
style={{ maxHeight: diffData ? "40%" : "100%" }}
>
{changes.length === 0 ? (
<div
className="flex h-40 items-center justify-center text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
{active
? "Watching for changes in temp0/..."
: "Start the watcher to detect Toolset changes"}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr
style={{
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
>
<th className="px-6 py-2 text-left font-medium">
<input
type="checkbox"
checked={selected.size === changes.length}
onChange={toggleAll}
className="cursor-pointer"
/>
</th>
<th className="px-2 py-2 text-left font-medium">Filename</th>
<th className="px-2 py-2 text-left font-medium">Type</th>
<th className="px-2 py-2 text-left font-medium">
Repo Path
</th>
<th className="px-2 py-2 text-left font-medium">Time</th>
</tr>
</thead>
<tbody>
{changes.map((change) => (
<tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-surface)"
: undefined,
}}
onClick={() => viewDiff(change)}
>
<td className="px-6 py-2">
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
/>
</td>
<td className="px-2 py-2 font-mono">{change.filename}</td>
<td className="px-2 py-2">
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
{change.gffType}
</span>
</td>
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{change.repoPath ?? "—"}
</td>
<td
className="px-2 py-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{formatTimestamp(change.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Diff panel */}
{diffData && (
<div
ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
<div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="text-xs font-medium">
Diff: {diffData.filename}
{loading && (
<span style={{ color: "var(--forge-text-secondary)" }}>
{" "}
(loading...)
</span>
)}
</span>
<button
onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
>
Close
</button>
</div>
<div className="min-h-0 flex-1">
<DiffEditor
original={diffData.original}
modified={diffData.modified}
language="json"
theme="vs-dark"
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
renderSideBySide: true,
padding: { top: 4 },
}}
/>
</div>
</div>
)}
</div>
</div>
);
}
+36
View File
@@ -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<Array<Record<string, string>>>("/docker/containers"),
pull: (image: string) =>