Files
layonara-forge/packages/frontend/src/pages/Toolset.tsx
T
plenarius f39f1d818b feat: integrate monaco-languageclient v10 with NWScript LSP
Replace hand-rolled LSP client (lspClient.ts, useLspClient.ts) with
monaco-languageclient v10 extended mode using @typefox/monaco-editor-react.
NWScript TextMate grammar from the LSP submodule provides syntax highlighting.
Full LSP features: completion, hover, diagnostics, go-to-definition, signature
help — all wired through WebSocket to the nwscript-language-server.

LSP server patches: fix workspaceFolders null assertion crash, handle missing
workspace/configuration gracefully, derive rootPath from rootUri when null,
guard tokenizer getRawTokenContent against undefined tokens.

Backend fixes: WebSocket routing changed to noServer mode so /ws, /ws/lsp,
and /ws/terminal/* don't conflict. TLK index loaded at startup (41,927 entries
from nwn-haks/layonara.tlk.json). Workspace routes get proper try/catch.
writeConfig creates parent directories. setupClone ensures workspace structure.

Frontend: GffEditor and AreaEditor rewritten with inline styles and TLK
resolution for CExoLocString fields. EditorTabs rewritten with lucide icons.
Tab content hydrates from API on refresh. Setup wizard gets friendly error
messages. SimpleEditor/SimpleDiffEditor for non-LSP editor uses. Vite config
updated for monaco-vscode-api compatibility.
2026-04-21 05:23:52 -04:00

769 lines
24 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { SimpleDiffEditor } from "../components/editor/SimpleEditor";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry {
filename: string;
gffType: string;
repoPath: string | null;
timestamp: number;
}
interface DiffData {
original: string;
modified: string;
filename: string;
}
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 () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll();
refresh();
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
>
{/* Page heading */}
<div style={{ padding: "24px 28px 0" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Toolset
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "4px 0 0",
}}
>
Watch for NWN Toolset changes and apply them to the repository
</p>
</div>
{/* Watcher status card */}
<div style={{ padding: "16px 28px" }}>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
padding: "16px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{active ? (
<Eye size={16} style={{ color: "var(--forge-success)" }} />
) : (
<EyeOff
size={16}
style={{ color: "var(--forge-text-secondary)" }}
/>
)}
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "3px 10px",
borderRadius: 999,
fontSize: "var(--text-xs)",
fontWeight: 600,
backgroundColor: active
? "var(--forge-success-bg)"
: "var(--forge-surface-raised)",
color: active
? "var(--forge-success)"
: "var(--forge-text-secondary)",
}}
>
{active ? "Active" : "Inactive"}
</span>
</div>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{changes.length} pending change{changes.length !== 1 && "s"}
</span>
{lastChange && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Last change: {formatTimestamp(lastChange)}
</span>
)}
</div>
<button
onClick={active ? handleStop : handleStart}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 14px",
borderRadius: 6,
border: "1px solid",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: active
? "var(--forge-surface)"
: "var(--forge-accent)",
borderColor: active
? "var(--forge-border)"
: "var(--forge-accent)",
color: active
? "var(--forge-text)"
: "var(--forge-accent-text)",
}}
>
{active ? (
<>
<EyeOff size={14} />
Stop Watcher
</>
) : (
<>
<Eye size={14} />
Start Watcher
</>
)}
</button>
</div>
</div>
{/* Main content area */}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
{/* Changes card */}
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
display: "flex",
flexDirection: "column",
flex: diffData ? "0 0 auto" : 1,
maxHeight: diffData ? "40%" : undefined,
}}
>
{/* Card header with action bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<FileCode
size={16}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
color: "var(--forge-text)",
}}
>
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
marginLeft: 4,
}}
>
{selected.size} of {changes.length} selected
</span>
)}
</div>
{changes.length > 0 && (
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<button
onClick={handleApplySelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
}}
>
<Check size={12} />
Apply Selected
</button>
<button
onClick={handleApplyAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-accent)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-accent)",
}}
>
<ArrowUpCircle size={12} />
Apply All
</button>
<button
onClick={handleDiscardSelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
}}
>
<Trash2 size={12} />
Discard Selected
</button>
<button
onClick={handleDiscardAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-danger-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-danger)",
}}
>
<X size={12} />
Discard All
</button>
</div>
)}
</div>
{/* Table or empty state */}
<div style={{ overflow: "auto", flex: 1 }}>
{changes.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--forge-text-secondary)",
}}
>
{active ? (
<>
<RefreshCw
size={28}
style={{
marginBottom: 12,
opacity: 0.4,
animation: "spin 3s linear infinite",
}}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Watching for changes in temp0/...
</span>
</>
) : (
<>
<EyeOff
size={28}
style={{ marginBottom: 12, opacity: 0.4 }}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Start the watcher to detect Toolset changes
</span>
</>
)}
</div>
) : (
<table
style={{
width: "100%",
fontSize: "var(--text-sm)",
borderCollapse: "collapse",
}}
>
<thead>
<tr
style={{
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
}}
>
<th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input
type="checkbox"
checked={
selected.size === changes.length &&
changes.length > 0
}
onChange={toggleAll}
style={{ cursor: "pointer" }}
/>
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Filename
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Type
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Repo Path
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Time
</th>
</tr>
</thead>
<tbody>
{changes.map((change) => (
<tr
key={change.filename}
onClick={() => viewDiff(change)}
style={{
borderBottom: "1px solid var(--forge-border)",
cursor: "pointer",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-accent-subtle)"
: undefined,
}}
>
<td style={{ padding: "8px 16px" }}>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer" }}
/>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{change.filename}
</td>
<td style={{ padding: "8px 10px" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 4,
fontSize: "var(--text-xs)",
fontWeight: 500,
backgroundColor: "var(--forge-accent-subtle)",
color: "var(--forge-accent)",
}}
>
{change.gffType}
</span>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{change.repoPath ?? "—"}
</td>
<td
style={{
padding: "8px 10px",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{formatTimestamp(change.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Diff viewer panel */}
{diffData && (
<div
ref={diffContainerRef}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
marginTop: 16,
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Diff header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "var(--forge-surface-raised)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<FileCode
size={14}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{diffData.filename}
</span>
{loading && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span>
)}
</div>
<button
onClick={() => setDiffData(null)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 5,
border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
}}
>
<X size={12} />
Close
</button>
</div>
{/* Diff content */}
<div
style={{
flex: 1,
minHeight: 0,
backgroundColor: "var(--forge-log-bg)",
}}
>
<SimpleDiffEditor
original={diffData.original}
modified={diffData.modified}
language="json"
options={{
minimap: { enabled: false },
fontSize: 13,
padding: { top: 4 },
}}
/>
</div>
</div>
)}
</div>
</div>
);
}