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, label: string): unknown { const field = data[label]; if (field && typeof field === "object" && "value" in (field as Record)) { return (field as Record).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; 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; 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, label: string, newValue: unknown): Record { const updated = { ...data }; const existing = data[label]; if (existing && typeof existing === "object" && "value" in (existing as Record)) { updated[label] = { ...(existing as Record), value: newValue }; } else { updated[label] = newValue; } return updated; } function setLocStringValue(data: Record, label: string, text: string): Record { const updated = { ...data }; const existing = data[label]; if (existing && typeof existing === "object") { const ex = existing as Record; if ("value" in ex && ex.value && typeof ex.value === "object") { const inner = ex.value as Record; 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 (
{hex || "(empty)"}
); } if (field.type === GffFieldType.CExoLocString) { return ; } if (field.type === GffFieldType.List) { const list = Array.isArray(value) ? value : []; return ; } if (field.type === GffFieldType.Struct) { return | undefined} />; } if (isNumericType(field.type)) { const num = typeof value === "number" ? value : 0; const isFloat = field.type === GffFieldType.Float || field.type === GffFieldType.Double; return (
onChange(isFloat ? parseFloat(e.target.value) : parseInt(e.target.value, 10))} style={{ ...fieldInput, flex: "none", width: "8rem" }} />
); } if (field.type === GffFieldType.ResRef) { const str = typeof value === "string" ? value : ""; const valid = str.length <= 16; return (
onChange(e.target.value)} style={{ ...fieldInput, fontFamily: "var(--font-mono)", borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)" }} /> {str.length}/16
); } const str = typeof value === "string" ? value : String(value ?? ""); return (
onChange(e.target.value)} style={fieldInput} />
); } 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(null); useEffect(() => { if (!isTlkRef || !value || typeof value !== "object") return; const v = value as Record; 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 (
onLocStringChange?.(e.target.value)} style={{ ...fieldInput, ...(isTlkRef && !resolved ? { fontStyle: "italic", color: "var(--forge-text-secondary)" } : {}) }} /> {isTlkRef && ( {resolved ? text : "TLK reference — name stored in talk table"} )}
); } function ListField({ field, items }: { field: GffFieldSchema; items: unknown[] }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{items.map((item, i) => (
[{i}]
{typeof item === "object" && item !== null ? (
{JSON.stringify(item, null, 2)}
) : ( {String(item)} )}
))} {items.length === 0 &&
(empty list)
}
)}
); } function StructField({ field, value }: { field: GffFieldSchema; value?: Record }) { const [expanded, setExpanded] = useState(false); return (
{expanded && value && (
{JSON.stringify(value, null, 2)}
)}
); } interface GffEditorProps { repo: string; filePath: string; content: string; onSave?: (content: string) => void; onSwitchToRaw?: () => void; fieldOverrides?: Map React.ReactNode>; headerSlot?: React.ReactNode; } export interface FieldOverrideProps { field: GffFieldSchema; value: unknown; data: Record; onChange: (label: string, value: unknown) => void; } export function GffEditor({ repo, filePath, content, onSave, onSwitchToRaw, fieldOverrides, headerSlot }: GffEditorProps) { const [schema, setSchema] = useState(null); const [data, setData] = useState>({}); const [activeCategory, setActiveCategory] = useState(""); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(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 (

{error}

{onSwitchToRaw && ( )}
); } if (!schema) { return (

Loading schema...

); } return (
{/* Toolbar */}
{schema.displayName} {dirty && unsaved} {error && {error}}
{onSwitchToRaw && ( )}
{headerSlot} {/* Category tabs */}
{schema.categories.map((cat) => ( ))}
{/* Fields */}
{categoryFields.map((field) => { const override = fieldOverrides?.get(field.label); if (override) { return (
{override({ field, value: getFieldValue(data, field.label), data, onChange: handleFieldChange })}
); } return (
handleFieldChange(field.label, val)} onLocStringChange={(text) => handleLocStringChange(field.label, text)} />
); })} {categoryFields.length === 0 && (

No fields in this category

)}
); } export { gffTypeFromPath, getFieldValue, getLocStringText, setFieldValue, setLocStringValue };