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.
412 lines
19 KiB
TypeScript
412 lines
19 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Code2, Save, ChevronRight, ChevronDown } from "lucide-react";
|
|
import { api } from "../../services/api";
|
|
|
|
export enum GffFieldType {
|
|
Byte = 0, Char = 1, Word = 2, Short = 3, Dword = 4, Int = 5,
|
|
Dword64 = 6, Int64 = 7, Float = 8, Double = 9,
|
|
CExoString = 10, ResRef = 11, CExoLocString = 12, Void = 13,
|
|
Struct = 14, List = 15,
|
|
}
|
|
|
|
export interface GffFieldSchema {
|
|
label: string;
|
|
displayName: string;
|
|
type: GffFieldType;
|
|
description?: string;
|
|
category?: string;
|
|
editable?: boolean;
|
|
hidden?: boolean;
|
|
fields?: GffFieldSchema[];
|
|
}
|
|
|
|
export interface GffTypeSchema {
|
|
fileType: string;
|
|
displayName: string;
|
|
categories: string[];
|
|
fields: GffFieldSchema[];
|
|
}
|
|
|
|
function gffTypeFromPath(filePath: string): string | null {
|
|
const match = filePath.match(/\.(\w+)\.json$/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function getFieldValue(data: Record<string, unknown>, label: string): unknown {
|
|
const field = data[label];
|
|
if (field && typeof field === "object" && "value" in (field as Record<string, unknown>)) {
|
|
return (field as Record<string, unknown>).value;
|
|
}
|
|
return field;
|
|
}
|
|
|
|
function getLocStringText(value: unknown): string {
|
|
if (value == null) return "";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "object") {
|
|
const v = value as Record<string, unknown>;
|
|
if (typeof v["0"] === "string") return v["0"];
|
|
for (const [key, val] of Object.entries(v)) {
|
|
if (key === "id") continue;
|
|
if (/^\d+$/.test(key) && typeof val === "string") return val;
|
|
}
|
|
if (v.strings && typeof v.strings === "object") {
|
|
const strings = v.strings as Record<string, string>;
|
|
return strings["0"] ?? Object.values(strings)[0] ?? "";
|
|
}
|
|
if (v.value && typeof v.value === "object") {
|
|
return getLocStringText(v.value);
|
|
}
|
|
if (typeof v.id === "number") {
|
|
const tlkRow = v.id >= 16777216 ? v.id - 16777216 : v.id;
|
|
return `(TLK #${tlkRow})`;
|
|
}
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function setFieldValue(data: Record<string, unknown>, label: string, newValue: unknown): Record<string, unknown> {
|
|
const updated = { ...data };
|
|
const existing = data[label];
|
|
if (existing && typeof existing === "object" && "value" in (existing as Record<string, unknown>)) {
|
|
updated[label] = { ...(existing as Record<string, unknown>), value: newValue };
|
|
} else {
|
|
updated[label] = newValue;
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
function setLocStringValue(data: Record<string, unknown>, label: string, text: string): Record<string, unknown> {
|
|
const updated = { ...data };
|
|
const existing = data[label];
|
|
if (existing && typeof existing === "object") {
|
|
const ex = existing as Record<string, unknown>;
|
|
if ("value" in ex && ex.value && typeof ex.value === "object") {
|
|
const inner = ex.value as Record<string, unknown>;
|
|
if (typeof inner["0"] === "string") {
|
|
updated[label] = { ...ex, value: { ...inner, "0": text } };
|
|
} else {
|
|
updated[label] = { ...ex, value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } } };
|
|
}
|
|
} else if ("strings" in ex) {
|
|
updated[label] = { ...ex, strings: { ...((ex.strings as object) ?? {}), "0": text } };
|
|
} else {
|
|
updated[label] = { ...ex, value: { strings: { "0": text } } };
|
|
}
|
|
} else {
|
|
updated[label] = { type: "cexolocstring", value: { "0": text } };
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
function isNumericType(type: GffFieldType): boolean {
|
|
return type <= GffFieldType.Double;
|
|
}
|
|
|
|
const fieldRow: React.CSSProperties = {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
padding: "0.5rem 0",
|
|
};
|
|
|
|
const fieldLabel: React.CSSProperties = {
|
|
width: "11rem",
|
|
flexShrink: 0,
|
|
fontSize: "var(--text-sm)",
|
|
color: "var(--forge-text-secondary)",
|
|
};
|
|
|
|
const fieldInput: React.CSSProperties = {
|
|
flex: 1,
|
|
backgroundColor: "var(--forge-bg)",
|
|
border: "1px solid var(--forge-border)",
|
|
borderRadius: "0.375rem",
|
|
padding: "0.5rem 0.75rem",
|
|
fontSize: "var(--text-sm)",
|
|
color: "var(--forge-text)",
|
|
fontFamily: "inherit",
|
|
};
|
|
|
|
interface FieldRendererProps {
|
|
field: GffFieldSchema;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
onLocStringChange?: (text: string) => void;
|
|
}
|
|
|
|
function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRendererProps) {
|
|
if (field.hidden) return null;
|
|
const isReadonly = field.editable === false;
|
|
|
|
if (field.type === GffFieldType.Void) {
|
|
const hex = typeof value === "string" ? value : "";
|
|
return (
|
|
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
|
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
|
<code style={{ flex: 1, borderRadius: "0.375rem", padding: "0.5rem 0.75rem", fontSize: "var(--text-xs)", wordBreak: "break-all", backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
|
|
{hex || "(empty)"}
|
|
</code>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.type === GffFieldType.CExoLocString) {
|
|
return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
|
|
}
|
|
|
|
if (field.type === GffFieldType.List) {
|
|
const list = Array.isArray(value) ? value : [];
|
|
return <ListField field={field} items={list} />;
|
|
}
|
|
|
|
if (field.type === GffFieldType.Struct) {
|
|
return <StructField field={field} value={value as Record<string, unknown> | undefined} />;
|
|
}
|
|
|
|
if (isNumericType(field.type)) {
|
|
const num = typeof value === "number" ? value : 0;
|
|
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
|
|
return (
|
|
<div style={fieldRow}>
|
|
<label style={fieldLabel}>{field.displayName}</label>
|
|
<input type="number" value={num} readOnly={isReadonly} step={isFloat ? "0.01" : "1"} onChange={(e) => onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} style={{ ...fieldInput, flex: "none", width: "8rem" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.type === GffFieldType.ResRef) {
|
|
const str = typeof value === "string" ? value : "";
|
|
const valid = str.length <= 16;
|
|
return (
|
|
<div style={fieldRow}>
|
|
<label style={fieldLabel}>{field.displayName}</label>
|
|
<div style={{ display: "flex", flex: 1, alignItems: "center", gap: "0.5rem" }}>
|
|
<input type="text" value={str} readOnly={isReadonly} maxLength={16} onChange={(e) => onChange(e.target.value)} style={{ ...fieldInput, fontFamily: "var(--font-mono)", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)" }} />
|
|
<span style={{ fontSize: "var(--text-xs)", color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)", flexShrink: 0 }}>{str.length}/16</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const str = typeof value === "string" ? value : String(value ?? "");
|
|
return (
|
|
<div style={fieldRow}>
|
|
<label style={fieldLabel}>{field.displayName}</label>
|
|
<input type="text" value={str} readOnly={isReadonly} onChange={(e) => onChange(e.target.value)} style={fieldInput} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LocStringField({ field, value, isReadonly, onLocStringChange }: { field: GffFieldSchema; value: unknown; isReadonly: boolean; onLocStringChange?: (text: string) => void }) {
|
|
const text = getLocStringText(value);
|
|
const isTlkRef = text.startsWith("(TLK #");
|
|
const [resolved, setResolved] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isTlkRef || !value || typeof value !== "object") return;
|
|
const v = value as Record<string, unknown>;
|
|
const id = typeof v.id === "number" ? v.id : undefined;
|
|
if (id === undefined) return;
|
|
api.editor.tlkLookup?.(id).then((r) => { if (r) setResolved(r); }).catch(() => {});
|
|
}, [value, isTlkRef]);
|
|
|
|
const displayText = resolved ?? text;
|
|
|
|
return (
|
|
<div style={{ ...fieldRow, alignItems: "flex-start" }}>
|
|
<label style={{ ...fieldLabel, paddingTop: "0.5rem" }}>{field.displayName}</label>
|
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
<input type="text" value={displayText} readOnly={isReadonly || isTlkRef} onChange={(e) => onLocStringChange?.(e.target.value)} style={{ ...fieldInput, ...(isTlkRef && !resolved ? { fontStyle: "italic", color: "var(--forge-text-secondary)" } : {}) }} />
|
|
{isTlkRef && (
|
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
|
{resolved ? text : "TLK reference — name stored in talk table"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
|
<span style={{ fontSize: "var(--text-xs)", backgroundColor: "var(--forge-surface-raised)", borderRadius: "0.25rem", padding: "0.125rem 0.5rem" }}>{items.length} {items.length === 1 ? "item" : "items"}</span>
|
|
</button>
|
|
{expanded && (
|
|
<div style={{ marginLeft: "1rem", marginTop: "0.25rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
{items.map((item, i) => (
|
|
<div key={i} style={{ borderRadius: "0.375rem", padding: "0.5rem 0.75rem", backgroundColor: "var(--forge-bg)" }}>
|
|
<div style={{ fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--forge-text-secondary)", marginBottom: "0.25rem" }}>[{i}]</div>
|
|
{typeof item === "object" && item !== null ? (
|
|
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
|
|
) : (
|
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StructField({ field, value }: { field: GffFieldSchema; value?: Record<string, unknown> }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
<button onClick={() => setExpanded(!expanded)} style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", background: "none", border: "none", cursor: "pointer", padding: "0.25rem 0", textAlign: "left" }}>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span style={{ color: "var(--forge-text)" }}>{field.displayName}</span>
|
|
</button>
|
|
{expanded && value && (
|
|
<div style={{ marginLeft: "1rem", paddingLeft: "1rem", borderLeft: "2px solid var(--forge-border)" }}>
|
|
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(value, null, 2)}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface GffEditorProps {
|
|
repo: string;
|
|
filePath: string;
|
|
content: string;
|
|
onSave?: (content: string) => void;
|
|
onSwitchToRaw?: () => void;
|
|
fieldOverrides?: Map<string, (props: FieldOverrideProps) => React.ReactNode>;
|
|
headerSlot?: React.ReactNode;
|
|
}
|
|
|
|
export interface FieldOverrideProps {
|
|
field: GffFieldSchema;
|
|
value: unknown;
|
|
data: Record<string, unknown>;
|
|
onChange: (label: string, value: unknown) => void;
|
|
}
|
|
|
|
export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) {
|
|
const [schema, setSchema] = useState<GffTypeSchema | null>(null);
|
|
const [data, setData] = useState<Record<string, unknown>>({});
|
|
const [activeCategory, setActiveCategory] = useState<string>("");
|
|
const [dirty, setDirty] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]);
|
|
|
|
useEffect(() => { try { setData(JSON.parse(content)); } catch { setError("Failed to parse JSON content"); } }, [content]);
|
|
|
|
useEffect(() => {
|
|
if (!gffType) return;
|
|
api.editor.gffSchema(gffType).then((s) => { setSchema(s); if (s.categories.length > 0) setActiveCategory(s.categories[0]); }).catch(() => setError(`Failed to load schema for .${gffType}`));
|
|
}, [gffType]);
|
|
|
|
const handleFieldChange = useCallback((label: string, value: unknown) => { setData((prev) => { setDirty(true); return setFieldValue(prev, label, value); }); }, []);
|
|
const handleLocStringChange = useCallback((label: string, text: string) => { setData((prev) => { setDirty(true); return setLocStringValue(prev, label, text); }); }, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true);
|
|
try { const newContent = JSON.stringify(data, null, 4) + "\n"; await api.editor.writeFile(repo, filePath, newContent); setDirty(false); onSave?.(newContent); }
|
|
catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
|
|
finally { setSaving(false); }
|
|
}, [data, repo, filePath, onSave]);
|
|
|
|
const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
|
|
|
|
if (error && !schema) {
|
|
return (
|
|
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
|
<div style={{ textAlign: "center" }}>
|
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-danger)" }}>{error}</p>
|
|
{onSwitchToRaw && (
|
|
<button onClick={onSwitchToRaw} style={{ marginTop: "0.75rem", borderRadius: "0.375rem", padding: "0.5rem 1rem", fontSize: "var(--text-sm)", backgroundColor: "var(--forge-surface)", color: "var(--forge-text)", border: "1px solid var(--forge-border)", cursor: "pointer" }}>
|
|
Open as Raw JSON
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!schema) {
|
|
return (
|
|
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
|
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Loading schema...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", backgroundColor: "var(--forge-bg)" }}>
|
|
{/* Toolbar */}
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "0.625rem 1.25rem", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
<span style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-lg)", fontWeight: 600, color: "var(--forge-text)" }}>{schema.displayName}</span>
|
|
{dirty && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-accent)", fontWeight: 500 }}>unsaved</span>}
|
|
{error && <span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>{error}</span>}
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
{onSwitchToRaw && (
|
|
<button onClick={onSwitchToRaw} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "1px solid var(--forge-border)", backgroundColor: "transparent", color: "var(--forge-text-secondary)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
|
<Code2 size={13} /> Raw JSON
|
|
</button>
|
|
)}
|
|
<button onClick={handleSave} disabled={!dirty || saving} style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem", padding: "0.375rem 0.875rem", borderRadius: "0.375rem", border: "none", backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)", fontSize: "var(--text-xs)", fontWeight: 600, cursor: dirty && !saving ? "pointer" : "not-allowed", opacity: dirty && !saving ? 1 : 0.4 }}>
|
|
<Save size={13} /> {saving ? "Saving..." : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{headerSlot}
|
|
|
|
{/* Category tabs */}
|
|
<div style={{ display: "flex", flexShrink: 0, overflowX: "auto", borderBottom: "1px solid var(--forge-border)", backgroundColor: "var(--forge-surface)" }}>
|
|
{schema.categories.map((cat) => (
|
|
<button key={cat} onClick={() => setActiveCategory(cat)} style={{ flexShrink: 0, padding: "0.625rem 1.25rem", fontSize: "var(--text-sm)", fontWeight: activeCategory === cat ? 600 : 400, color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)", borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent", background: "none", border: "none", borderBottomStyle: "solid", cursor: "pointer", transition: "color 150ms ease-out" }}>
|
|
{cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Fields */}
|
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
|
|
<div style={{ maxWidth: "42rem", margin: "0 auto", display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
{categoryFields.map((field) => {
|
|
const override = fieldOverrides?.get(field.label);
|
|
if (override) {
|
|
return (
|
|
<div key={field.label} style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--forge-border)" }}>
|
|
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
|
|
<FieldRenderer
|
|
field={field}
|
|
value={getFieldValue(data, field.label)}
|
|
onChange={(val) => handleFieldChange(field.label, val)}
|
|
onLocStringChange={(text) => handleLocStringChange(field.label, text)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{categoryFields.length === 0 && (
|
|
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>No fields in this category</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { gffTypeFromPath, getFieldValue, getLocStringText, setFieldValue, setLocStringValue };
|