feat: add Toolset page with live change detection and diff viewer
This commit is contained in:
@@ -4,6 +4,7 @@ import { Dashboard } from "./pages/Dashboard";
|
|||||||
import { Editor } from "./pages/Editor";
|
import { Editor } from "./pages/Editor";
|
||||||
import { Build } from "./pages/Build";
|
import { Build } from "./pages/Build";
|
||||||
import { Server } from "./pages/Server";
|
import { Server } from "./pages/Server";
|
||||||
|
import { Toolset } from "./pages/Toolset";
|
||||||
import { IDELayout } from "./layouts/IDELayout";
|
import { IDELayout } from "./layouts/IDELayout";
|
||||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
@@ -52,6 +53,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="build" element={<Build />} />
|
<Route path="build" element={<Build />} />
|
||||||
<Route path="server" element={<Server />} />
|
<Route path="server" element={<Server />} />
|
||||||
|
<Route path="toolset" element={<Toolset />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: {
|
docker: {
|
||||||
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
||||||
pull: (image: string) =>
|
pull: (image: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user