feat: add Toolset page with live change detection and diff viewer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user