diff --git a/packages/frontend/src/components/gff/GffEditor.tsx b/packages/frontend/src/components/gff/GffEditor.tsx new file mode 100644 index 0000000..b00cbff --- /dev/null +++ b/packages/frontend/src/components/gff/GffEditor.tsx @@ -0,0 +1,548 @@ +import { useState, useEffect, useCallback, useMemo } from "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 (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); + } + } + 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; + 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 } } }; + } + return updated; +} + +function isNumericType(type: GffFieldType): boolean { + return type <= GffFieldType.Double; +} + +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) { + const text = getLocStringText(value); + return ( +
+ + 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)", + }} + /> +
+ ); + } + + 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))} + 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)", + }} + /> +
+ ); + } + + if (field.type === GffFieldType.ResRef) { + const str = typeof value === "string" ? value : ""; + const valid = str.length <= 16; + return ( +
+ +
+ 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)" : "#ef4444", + color: "var(--forge-text)", + }} + /> + + {str.length}/16 + +
+
+ ); + } + + // CExoString fallback + const str = typeof value === "string" ? value : String(value ?? ""); + return ( +
+ + 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)", + }} + /> +
+ ); +} + +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) => { + 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 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(() => { + if (!schema) return []; + return 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} Editor + + {dirty && ( + + (unsaved changes) + + )} + {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 }; diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx index afccf0a..2af5a9c 100644 --- a/packages/frontend/src/pages/Editor.tsx +++ b/packages/frontend/src/pages/Editor.tsx @@ -1,17 +1,28 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { MonacoEditor } from "../components/editor/MonacoEditor"; import { EditorTabs } from "../components/editor/EditorTabs"; +import { GffEditor } from "../components/gff/GffEditor"; + +const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"]; + +function isGffFile(tabKey: string): boolean { + return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext)); +} + +function repoFromTabKey(tabKey: string): string { + const idx = tabKey.indexOf(":"); + return idx > 0 ? tabKey.slice(0, idx) : ""; +} + +function filePathFromTabKey(tabKey: string): string { + const idx = tabKey.indexOf(":"); + return idx > 0 ? tabKey.slice(idx + 1) : tabKey; +} interface EditorProps { editorState: ReturnType; } -function displayName(tabKey: string): string { - const parts = tabKey.split(":"); - const filePath = parts.length > 1 ? parts.slice(1).join(":") : tabKey; - return filePath.split("/").pop() ?? filePath; -} - export function Editor({ editorState }: EditorProps) { const { openTabs, @@ -21,8 +32,12 @@ export function Editor({ editorState }: EditorProps) { closeFile, updateContent, getContent, + markClean, } = editorState; + // Track per-tab editor mode: "visual" or "raw". GFF files default to visual. + const [editorModes, setEditorModes] = useState>({}); + const tabs = useMemo( () => openTabs.map((path: string) => ({ @@ -33,11 +48,13 @@ export function Editor({ editorState }: EditorProps) { ); const activeContent = activeTab ? (getContent(activeTab) ?? "") : ""; - const activeFilePath = activeTab - ? activeTab.includes(":") - ? activeTab.split(":").slice(1).join(":") - : activeTab - : ""; + const activeFilePath = activeTab ? filePathFromTabKey(activeTab) : ""; + const activeRepo = activeTab ? repoFromTabKey(activeTab) : ""; + + const isActiveGff = activeTab ? isGffFile(activeTab) : false; + const activeMode = activeTab + ? editorModes[activeTab] ?? (isActiveGff ? "visual" : "raw") + : "raw"; const handleChange = useCallback( (value: string) => { @@ -48,6 +65,80 @@ export function Editor({ editorState }: EditorProps) { [activeTab, updateContent], ); + const handleSwitchToRaw = useCallback(() => { + if (activeTab) { + setEditorModes((prev) => ({ ...prev, [activeTab]: "raw" })); + } + }, [activeTab]); + + const handleSwitchToVisual = useCallback(() => { + if (activeTab) { + setEditorModes((prev) => ({ ...prev, [activeTab]: "visual" })); + } + }, [activeTab]); + + const handleGffSave = useCallback( + (newContent: string) => { + if (activeTab) { + updateContent(activeTab, newContent); + markClean(activeTab); + } + }, + [activeTab, updateContent, markClean], + ); + + const renderEditor = () => { + if (!activeTab) { + return ( +
+

+ Open a file from the File Explorer to start editing +

+
+ ); + } + + if (isActiveGff && activeMode === "visual") { + return ( + + ); + } + + return ( +
+ {isActiveGff && activeMode === "raw" && ( +
+ +
+ )} +
+ +
+
+ ); + }; + return (
- {activeTab ? ( - - ) : ( -
-

- Open a file from the File Explorer to start editing -

-
- )} + {renderEditor()}
); diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 04eae9a..c01bdbd 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -39,6 +39,8 @@ export const api = { totalMatches: number; filesSearched: number; }>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }), + gffSchema: (type: string) => + request(`/editor/gff-schema/${type}`), }, workspace: {