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.
This commit is contained in:
plenarius
2026-04-21 05:23:52 -04:00
parent cbe51a6e67
commit f39f1d818b
62 changed files with 9355 additions and 1137 deletions
+140 -277
View File
@@ -1,23 +1,12 @@
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,
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 {
@@ -56,6 +45,11 @@ function getLocStringText(value: unknown): string {
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] ?? "";
@@ -63,15 +57,15 @@ function getLocStringText(value: unknown): string {
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> {
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>)) {
@@ -82,28 +76,25 @@ function setFieldValue(
return updated;
}
function setLocStringValue(
data: Record<string, unknown>,
label: string,
text: string,
): Record<string, unknown> {
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>;
updated[label] = {
...ex,
value: { ...inner, strings: { ...((inner.strings as object) ?? {}), "0": text } },
};
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: { strings: { "0": text } } };
updated[label] = { type: "cexolocstring", value: { "0": text } };
}
return updated;
}
@@ -112,6 +103,31 @@ 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;
@@ -126,14 +142,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
if (field.type === GffFieldType.Void) {
const hex = typeof value === "string" ? value : "";
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<code
className="flex-1 rounded px-2 py-1.5 text-xs break-all"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
<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>
@@ -141,26 +152,7 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
}
if (field.type === GffFieldType.CExoLocString) {
const text = getLocStringText(value);
return (
<div className="flex items-start gap-3">
<label className="w-44 shrink-0 pt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={text}
readOnly={isReadonly}
onChange={(e) => onLocStringChange?.(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
);
return <LocStringField field={field} value={value} isReadonly={isReadonly} onLocStringChange={onLocStringChange} />;
}
if (field.type === GffFieldType.List) {
@@ -176,23 +168,9 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const num = typeof value === "number" ? value : 0;
const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{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))}
className="w-32 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<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>
);
}
@@ -201,54 +179,51 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
const str = typeof value === "string" ? value : "";
const valid = str.length <= 16;
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<div className="flex flex-1 items-center gap-2">
<input
type="text"
value={str}
readOnly={isReadonly}
maxLength={16}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)",
color: "var(--forge-text)",
}}
/>
<span
className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)" }}
>
{str.length}/16
</span>
<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>
);
}
// CExoString fallback
const str = typeof value === "string" ? value : String(value ?? "");
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<input
type="text"
value={str}
readOnly={isReadonly}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded border px-2 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<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>
);
}
@@ -257,44 +232,25 @@ function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<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 className="rounded px-1.5 py-0.5 text-xs" style={{ backgroundColor: "var(--forge-bg)" }}>
{items.length} {items.length === 1 ? "item" : "items"}
</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
className="ml-4 mt-1 space-y-2 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
<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} className="rounded p-2" style={{ backgroundColor: "var(--forge-bg)" }}>
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
[{i}]
</div>
<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 className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(item, null, 2)}
</pre>
<pre style={{ overflow: "auto", fontSize: "var(--text-xs)", color: "var(--forge-text)", margin: 0 }}>{JSON.stringify(item, null, 2)}</pre>
) : (
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{String(item)}
</span>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>{String(item)}</span>
)}
</div>
))}
{items.length === 0 && (
<div className="py-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
(empty list)
</div>
)}
{items.length === 0 && <div style={{ padding: "0.25rem 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>(empty list)</div>}
</div>
)}
</div>
@@ -305,23 +261,14 @@ function StructField({ field, value }: { field: GffFieldSchema; value?: Record<s
const [expanded, setExpanded] = useState(false);
return (
<div className="flex flex-col gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm"
style={{ color: "var(--forge-text-secondary)" }}
>
<span className="font-mono text-xs">{expanded ? "▼" : "▶"}</span>
<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
className="ml-4 mt-1 border-l-2 pl-4"
style={{ borderColor: "var(--forge-border)" }}
>
<pre className="overflow-auto text-xs" style={{ color: "var(--forge-text)" }}>
{JSON.stringify(value, null, 2)}
</pre>
<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>
@@ -345,89 +292,41 @@ export interface FieldOverrideProps {
onChange: (label: string, value: unknown) => void;
}
export function GffEditor({
repo,
filePath,
content,
onSave,
onSwitchToRaw,
fieldOverrides,
headerSlot,
}: GffEditorProps) {
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(() => { 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}`));
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) => {
const updated = setFieldValue(prev, label, value);
setDirty(true);
return updated;
});
}, []);
const handleLocStringChange = useCallback((label: string, text: string) => {
setData((prev) => {
const updated = setLocStringValue(prev, label, text);
setDirty(true);
return updated;
});
}, []);
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);
}
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(() => {
if (!schema) return [];
return schema.fields.filter((f) => f.category === activeCategory && !f.hidden);
}, [schema, activeCategory]);
const categoryFields = useMemo(() => schema ? schema.fields.filter((f) => f.category === activeCategory && !f.hidden) : [], [schema, activeCategory]);
if (error && !schema) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
<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}
className="mt-3 rounded px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-surface)", color: "var(--forge-text)" }}
>
<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>
)}
@@ -438,52 +337,29 @@ export function GffEditor({
if (!schema) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Loading schema...</p>
<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 className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<div style={{ display: "flex", flexDirection: "column", height: "100%", backgroundColor: "var(--forge-bg)" }}>
{/* Toolbar */}
<div
className="flex shrink-0 items-center justify-between border-b px-4 py-2"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{schema.displayName} Editor
</span>
{dirty && (
<span className="text-xs" style={{ color: "var(--forge-accent)" }}>
(unsaved changes)
</span>
)}
{error && (
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>{error}</span>
)}
<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 className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
className="rounded px-3 py-1 text-sm transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
>
Switch to Raw JSON
<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}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
}}
>
{saving ? "Saving..." : "Save"}
<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>
@@ -491,40 +367,29 @@ export function GffEditor({
{headerSlot}
{/* Category tabs */}
<div
className="flex shrink-0 gap-0 overflow-x-auto border-b"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
>
<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)}
className="shrink-0 px-4 py-2 text-sm transition-colors"
style={{
color: activeCategory === cat ? "var(--forge-text)" : "var(--forge-text-secondary)",
borderBottom: activeCategory === cat ? "2px solid var(--forge-accent)" : "2px solid transparent",
}}
>
<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 className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-2xl space-y-4">
<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}>
<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}>
<div key={field.label} title={field.description} style={{ borderBottom: "1px solid var(--forge-border)" }}>
<FieldRenderer
field={field}
value={getFieldValue(data, field.label)}
@@ -535,9 +400,7 @@ export function GffEditor({
);
})}
{categoryFields.length === 0 && (
<p className="py-8 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
No fields in this category
</p>
<p style={{ padding: "2rem 0", textAlign: "center", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>No fields in this category</p>
)}
</div>
</div>