f39f1d818b
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.
769 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|