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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user