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
+4 -5
View File
@@ -12,18 +12,17 @@
"@fontsource-variable/alegreya": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@typefox/monaco-editor-react": "^7.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1",
"monaco-languageclient": "^10.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-ws-jsonrpc": "^3.5.0"
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@codingame/esbuild-import-meta-url-plugin": "^1.0.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
+34 -2
View File
@@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect, lazy, Suspense } from "react";
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
@@ -55,6 +55,38 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
export function App() {
const editorState = useEditorState();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [workspacePath, setWorkspacePath] = useState<string>("");
const hydratedRef = useRef(false);
useEffect(() => {
api.workspace.getConfig().then((cfg) => {
const wp = (cfg.workspacePath as string) || "";
if (wp) setWorkspacePath(wp);
}).catch(() => {});
}, []);
useEffect(() => {
if (hydratedRef.current) return;
hydratedRef.current = true;
const tabs = editorState.openTabs;
if (tabs.length === 0) return;
const stale = tabs.filter((t) => editorState.getContent(t) === undefined);
if (stale.length === 0) return;
Promise.allSettled(
stale.map(async (tabKey) => {
const idx = tabKey.indexOf(":");
if (idx <= 0) return;
const repo = tabKey.slice(0, idx);
const filePath = tabKey.slice(idx + 1);
try {
const { content } = await api.editor.readFile(repo, filePath);
editorState.openFile(tabKey, content);
} catch {
editorState.closeFile(tabKey);
}
}),
);
}, []);
const handleFileSelect = useCallback(
async (repo: string, filePath: string) => {
@@ -101,7 +133,7 @@ export function App() {
<Route path="/" element={<Dashboard />} />
<Route
path="/editor"
element={<Editor editorState={editorState} />}
element={<Editor editorState={editorState} workspacePath={workspacePath} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
@@ -1,3 +1,6 @@
import { X } from "lucide-react";
import { useState } from "react";
interface Tab {
path: string;
dirty: boolean;
@@ -11,7 +14,93 @@ interface EditorTabsProps {
}
function filename(path: string): string {
return path.split("/").pop() ?? path;
const parts = path.split(":");
const filePart = parts.length > 1 ? parts[1] : path;
return filePart.split("/").pop() ?? filePart;
}
function TabButton({
tab,
isActive,
onSelect,
onClose,
}: {
tab: Tab;
isActive: boolean;
onSelect: () => void;
onClose: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
title={tab.path}
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-mono)",
color: isActive ? "var(--forge-text)" : "var(--forge-text-secondary)",
backgroundColor: isActive ? "var(--forge-bg)" : "transparent",
borderBottom: isActive ? "2px solid var(--forge-accent)" : "2px solid transparent",
border: "none",
borderRight: "1px solid var(--forge-border)",
cursor: "pointer",
flexShrink: 0,
whiteSpace: "nowrap",
transition: "color 150ms ease-out, background-color 150ms ease-out",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}>
{filename(tab.path)}
{tab.dirty && (
<span
style={{
display: "inline-block",
width: "0.5rem",
height: "0.5rem",
borderRadius: "50%",
backgroundColor: "var(--forge-accent)",
flexShrink: 0,
}}
/>
)}
</span>
<button
type="button"
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "1.125rem",
height: "1.125rem",
borderRadius: "0.25rem",
border: "none",
background: "transparent",
cursor: "pointer",
color: "var(--forge-text-secondary)",
opacity: hovered || isActive ? 1 : 0,
transition: "opacity 150ms ease-out, background-color 150ms ease-out",
padding: 0,
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }}
>
<X size={12} />
</button>
</button>
);
}
export function EditorTabs({
@@ -24,53 +113,23 @@ export function EditorTabs({
return (
<div
className="flex overflow-x-auto"
style={{
display: "flex",
overflowX: "auto",
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
{tabs.map((tab) => {
const isActive = tab.path === activeTab;
return (
<button
key={tab.path}
title={tab.path}
onClick={() => onSelect(tab.path)}
className="group relative flex shrink-0 items-center gap-1.5 px-3 py-2 text-sm transition-colors"
style={{
color: isActive
? "var(--forge-text)"
: "var(--forge-text-secondary)",
borderBottom: isActive
? "2px solid var(--forge-accent)"
: "2px solid transparent",
}}
>
<span className="flex items-center gap-1">
{filename(tab.path)}
{tab.dirty && (
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: "var(--forge-accent)" }}
/>
)}
</span>
<button
type="button"
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onClose(tab.path);
}}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
style={{ appearance: "none", border: "none", background: "transparent", cursor: "pointer", color: "var(--forge-text-secondary)" }}
>
×
</button>
</button>
);
})}
{tabs.map((tab) => (
<TabButton
key={tab.path}
tab={tab}
isActive={tab.path === activeTab}
onSelect={() => onSelect(tab.path)}
onClose={() => onClose(tab.path)}
/>
))}
</div>
);
}
@@ -1,209 +1,42 @@
import { useRef, useCallback, useState } from "react";
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { useLspClient, useLspDocument } from "../../hooks/useLspClient.js";
import { useCallback, useMemo } from "react";
import { LogLevel } from "@codingame/monaco-vscode-api";
import {
MonacoEditorReactComp,
} from "@typefox/monaco-editor-react";
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
import type { LanguageClientConfig } from "monaco-languageclient/lcwrapper";
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
import "../../nwscript-extension/index.js";
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
}
let nwscriptRegistered = false;
function registerNWScript(monaco: Parameters<OnMount>[1]) {
if (nwscriptRegistered) return;
nwscriptRegistered = true;
monaco.languages.register({ id: "nwscript", extensions: [".nss"] });
monaco.languages.setMonarchTokensProvider("nwscript", {
keywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
"if",
"else",
"while",
"for",
"do",
"switch",
"case",
"default",
"break",
"continue",
"return",
"const",
],
constants: ["TRUE", "FALSE", "OBJECT_SELF", "OBJECT_INVALID"],
typeKeywords: [
"void",
"int",
"float",
"string",
"object",
"effect",
"itemproperty",
"location",
"vector",
"action",
"talent",
"event",
"struct",
],
operators: [
"=",
">",
"<",
"!",
"~",
"?",
":",
"==",
"<=",
">=",
"!=",
"&&",
"||",
"++",
"--",
"+",
"-",
"*",
"/",
"&",
"|",
"^",
"%",
"<<",
">>",
"+=",
"-=",
"*=",
"/=",
"&=",
"|=",
"^=",
"%=",
],
symbols: /[=><!~?:&|+\-*/^%]+/,
tokenizer: {
root: [
[/#include\b/, "keyword.preprocessor"],
[/#define\b/, "keyword.preprocessor"],
[
/[a-zA-Z_]\w*/,
{
cases: {
"@constants": "constant",
"@typeKeywords": "type",
"@keywords": "keyword",
"@default": "identifier",
},
},
],
{ include: "@whitespace" },
[/[{}()[\]]/, "@brackets"],
[/[;,.]/, "delimiter"],
[
/@symbols/,
{
cases: {
"@operators": "operator",
"@default": "",
},
},
],
[/\d*\.\d+([eE][-+]?\d+)?[fF]?/, "number.float"],
[/0[xX][0-9a-fA-F]+/, "number.hex"],
[/\d+/, "number"],
[/"([^"\\]|\\.)*$/, "string.invalid"],
[/"/, { token: "string.quote", next: "@string" }],
],
string: [
[/\b(SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|SET|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|IN|IS|NULL|AS|ORDER|BY|GROUP|HAVING|LIMIT|COUNT|SUM|AVG|MAX|MIN|DISTINCT|CREATE|TABLE|ALTER|DROP|INDEX|PRIMARY|KEY|LIKE|BETWEEN)\b/i, "keyword.sql"],
[/[^\\"]+/, "string"],
[/\\./, "string.escape"],
[/"/, { token: "string.quote", next: "@pop" }],
],
whitespace: [
[/[ \t\r\n]+/, "white"],
[/\/\*/, "comment", "@comment"],
[/\/\/.*$/, "comment"],
],
comment: [
[/[^/*]+/, "comment"],
[/\*\//, "comment", "@pop"],
[/[/*]/, "comment"],
],
function getVscodeApiConfig(): MonacoVscodeApiConfig {
return {
$type: "extended",
viewsConfig: {
$type: "EditorService",
},
});
}
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#1f1a14";
const surface = style.getPropertyValue("--forge-surface").trim() || "#2a2419";
const accent = style.getPropertyValue("--forge-accent").trim() || "#b07a1a";
const text = style.getPropertyValue("--forge-text").trim() || "#ede8e0";
const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#9a9080";
const border = style.getPropertyValue("--forge-border").trim() || "#3d3528";
const accentSubtle = style.getPropertyValue("--forge-accent-subtle").trim() || "#2e2818";
monaco.editor.defineTheme("forge-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "C586C0" },
{ token: "keyword.preprocessor", foreground: "569CD6" },
{ token: "type", foreground: "4EC9B0" },
{ token: "constant", foreground: "4FC1FF" },
{ token: "string", foreground: "CE9178" },
{ token: "comment", foreground: "6A9955" },
{ token: "number", foreground: "B5CEA8" },
{ token: "operator", foreground: "D4D4D4" },
{ token: "keyword.sql", foreground: "4ec9b0" },
],
colors: {
"editor.background": bg,
"editor.foreground": text,
"editorCursor.foreground": accent,
"editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text,
"editor.selectionBackground": accentSubtle,
"editorWidget.background": surface,
"editorWidget.border": border,
"editorSuggestWidget.background": surface,
"editorSuggestWidget.border": border,
userConfiguration: {
json: JSON.stringify({
"workbench.colorTheme": "Default Dark Modern",
"editor.fontSize": 14,
"editor.fontFamily": "'JetBrains Mono Variable', 'Fira Code', monospace",
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.minimap.enabled": false,
"editor.scrollBeyondLastLine": false,
"editor.wordWrap": "off",
"editor.renderWhitespace": "selection",
"editor.bracketPairColorization.enabled": true,
"editor.padding.top": 8,
"editor.lineNumbers": "on",
"editor.guides.bracketPairsHorizontal": "active",
"editor.wordBasedSuggestions": "off",
"editor.quickSuggestions": true,
"editor.parameterHints.enabled": true,
}),
},
});
monacoWorkerFactory: configureDefaultWorkerFactory,
};
}
function languageFromPath(filePath: string): string {
@@ -220,68 +53,104 @@ function languageFromPath(filePath: string): string {
md: "markdown",
txt: "plaintext",
"2da": "plaintext",
cfg: "ini",
sh: "shellscript",
};
return map[ext ?? ""] ?? "plaintext";
}
interface MonacoEditorProps {
filePath: string;
content: string;
language?: string;
onChange?: (value: string) => void;
workspacePath?: string;
}
export function MonacoEditor({
filePath,
content,
language,
onChange,
workspacePath,
}: MonacoEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const [monacoRef, setMonacoRef] = useState<typeof import("monaco-editor") | null>(null);
const resolvedLang = language ?? languageFromPath(filePath);
useLspClient(monacoRef);
useLspDocument(editorRef.current, filePath, resolvedLang);
const isNwscript = resolvedLang === "nwscript";
const handleMount: OnMount = useCallback(
(editorInstance, monaco) => {
editorRef.current = editorInstance;
setMonacoRef(monaco as unknown as typeof import("monaco-editor"));
registerNWScript(monaco);
defineForgeTheme(monaco);
monaco.editor.setTheme("forge-dark");
const fileUri = workspacePath
? `file://${workspacePath}/repos/nwn-module/${filePath}`
: `file:///workspace/repos/nwn-module/${filePath}`;
const model = editorInstance.getModel();
if (model) {
const lang = language ?? languageFromPath(filePath);
monaco.editor.setModelLanguage(model, lang);
}
},
[filePath, language],
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
codeResources: {
modified: {
text: content,
uri: fileUri,
enforceLanguageId: resolvedLang,
},
},
editorOptions: {
automaticLayout: true,
},
}),
[filePath],
);
const handleChange = useCallback(
(value: string | undefined) => {
if (value !== undefined) {
onChange?.(value);
const languageClientConfig = useMemo<LanguageClientConfig | undefined>(() => {
if (!isNwscript) return undefined;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return {
languageId: "nwscript",
connection: {
options: {
$type: "WebSocketUrl" as const,
url: `${protocol}//${window.location.host}/ws/lsp`,
},
},
clientOptions: {
documentSelector: ["nwscript"],
workspaceFolder: workspacePath
? {
index: 0,
name: "nwn-module",
uri: { scheme: "file", path: `${workspacePath}/repos/nwn-module` } as any,
}
: undefined,
},
};
}, [isNwscript, workspacePath]);
const handleTextChanged = useCallback(
(textChanges: TextContents) => {
if (textChanges.modified !== undefined) {
onChange?.(textChanges.modified);
}
},
[onChange],
);
const handleError = useCallback((error: Error) => {
console.error("[MonacoEditor]", error.message, error.stack);
}, []);
return (
<ReactMonacoEditor
value={content}
language={language ?? languageFromPath(filePath)}
theme="vs-dark"
onChange={handleChange}
onMount={handleMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
wordWrap: "off",
renderWhitespace: "selection",
bracketPairColorization: { enabled: true },
padding: { top: 8 },
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
languageClientConfig={languageClientConfig}
onTextChanged={handleTextChanged}
onError={handleError}
logLevel={LogLevel.Debug}
onLanguageClientsStartDone={(lcsManager) => {
console.log("[MonacoEditor] Language clients started:", lcsManager);
}}
onEditorStartDone={(editorApp) => {
console.log("[MonacoEditor] Editor started:", editorApp ? "ok" : "no app");
}}
onVscodeApiInitDone={() => {
console.log("[MonacoEditor] VSCode API initialized");
}}
/>
);
@@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import { MonacoEditorReactComp } from "@typefox/monaco-editor-react";
import type { MonacoVscodeApiConfig } from "monaco-languageclient/vscodeApiWrapper";
import type { EditorAppConfig, TextContents } from "monaco-languageclient/editorApp";
import { configureDefaultWorkerFactory } from "monaco-languageclient/workerFactory";
function getVscodeApiConfig(): MonacoVscodeApiConfig {
return {
$type: "extended",
viewsConfig: { $type: "EditorService" },
userConfiguration: {
json: JSON.stringify({
"workbench.colorTheme": "Default Dark Modern",
}),
},
monacoWorkerFactory: configureDefaultWorkerFactory,
};
}
interface SimpleEditorProps {
value: string;
language?: string;
onChange?: (value: string) => void;
options?: Record<string, unknown>;
readOnly?: boolean;
}
export function SimpleEditor({
value,
language = "plaintext",
onChange,
readOnly,
}: SimpleEditorProps) {
const uri = useMemo(
() => `inmemory://simple-${Date.now()}-${Math.random().toString(36).slice(2)}`,
[],
);
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
codeResources: {
modified: {
text: value,
uri,
enforceLanguageId: language,
},
},
editorOptions: {
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
readOnly,
},
}),
[],
);
const handleTextChanged = useCallback(
(textChanges: TextContents) => {
if (textChanges.modified !== undefined) {
onChange?.(textChanges.modified);
}
},
[onChange],
);
return (
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
onTextChanged={handleTextChanged}
/>
);
}
interface SimpleDiffEditorProps {
original: string;
modified: string;
language?: string;
options?: Record<string, unknown>;
}
export function SimpleDiffEditor({
original,
modified,
language = "plaintext",
}: SimpleDiffEditorProps) {
const baseUri = useMemo(
() => `inmemory://diff-${Date.now()}-${Math.random().toString(36).slice(2)}`,
[],
);
const editorAppConfig = useMemo<EditorAppConfig>(
() => ({
useDiffEditor: true,
codeResources: {
original: {
text: original,
uri: `${baseUri}-original`,
enforceLanguageId: language,
},
modified: {
text: modified,
uri: `${baseUri}-modified`,
enforceLanguageId: language,
},
},
diffEditorOptions: {
automaticLayout: true,
readOnly: true,
renderSideBySide: true,
},
}),
[original, modified, language],
);
return (
<MonacoEditorReactComp
style={{ width: "100%", height: "100%" }}
vscodeApiConfig={getVscodeApiConfig()}
editorAppConfig={editorAppConfig}
/>
);
}
@@ -39,15 +39,24 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
];
return (
<div className="space-y-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Area Flags
</label>
<div className="flex items-center gap-6">
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
{bits.map(({ bit, label }) => {
const checked = (flags & (1 << bit)) !== 0;
return (
<label key={bit} className="flex cursor-pointer items-center gap-2 text-sm">
<label
key={bit}
style={{
display: "flex",
cursor: "pointer",
alignItems: "center",
gap: "0.5rem",
fontSize: "var(--text-sm)",
}}
>
<input
type="checkbox"
checked={checked}
@@ -57,15 +66,19 @@ function FlagsOverride({ data, onChange }: FieldOverrideProps) {
: flags & ~(1 << bit);
onChange("Flags", newFlags);
}}
className="h-4 w-4 rounded"
style={{ accentColor: "var(--forge-accent)" }}
style={{
height: "1rem",
width: "1rem",
borderRadius: "0.25rem",
accentColor: "var(--forge-accent)",
}}
/>
<span style={{ color: "var(--forge-text)" }}>{label}</span>
</label>
);
})}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
Raw value: {flags}
</span>
</div>
@@ -77,19 +90,25 @@ function ColorOverride({ field, value, onChange }: FieldOverrideProps) {
const hex = intToHexColor(num);
return (
<div className="flex items-center gap-3">
<label className="w-44 shrink-0 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label style={{ width: "11rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{field.displayName}
</label>
<div className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="color"
value={hex}
onChange={(e) => onChange(field.label, hexColorToInt(e.target.value))}
className="h-8 w-10 cursor-pointer rounded border-0"
style={{ backgroundColor: "var(--forge-bg)" }}
style={{
height: "2rem",
width: "2.5rem",
cursor: "pointer",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--forge-bg)",
}}
/>
<span className="font-mono text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{hex}
</span>
</div>
@@ -106,54 +125,64 @@ function DimensionsOverride({ data, onChange }: FieldOverrideProps) {
const ts = typeof tileset === "string" ? tileset : "";
return (
<div className="space-y-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Width</label>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Width</label>
<input
type="number"
value={w}
min={1}
max={32}
onChange={(e) => onChange("Width", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{
width: "5rem",
borderRadius: "0.25rem",
border: "1px solid var(--forge-border)",
padding: "0.375rem 0.5rem",
fontSize: "var(--text-sm)",
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
<span style={{ color: "var(--forge-text-secondary)" }}>×</span>
<div className="flex items-center gap-2">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Height</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Height</label>
<input
type="number"
value={h}
min={1}
max={32}
onChange={(e) => onChange("Height", parseInt(e.target.value, 10))}
className="w-20 rounded border px-2 py-1.5 text-sm"
style={{
width: "5rem",
borderRadius: "0.25rem",
border: "1px solid var(--forge-border)",
padding: "0.375rem 0.5rem",
fontSize: "var(--text-sm)",
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>tiles</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>tiles</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm" style={{ color: "var(--forge-text-secondary)" }}>Tileset</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Tileset</label>
<input
type="text"
value={ts}
maxLength={16}
onChange={(e) => onChange("Tileset", e.target.value)}
className="w-44 rounded border px-2 py-1.5 font-mono text-sm"
style={{
width: "11rem",
borderRadius: "0.25rem",
border: "1px solid var(--forge-border)",
padding: "0.375rem 0.5rem",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
+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>
@@ -1,66 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { getLspClient, type LspStatus } from "../lib/lspClient.js";
import type { editor } from "monaco-editor";
export function useLspClient(monaco: typeof import("monaco-editor") | null) {
const [status, setStatus] = useState<LspStatus>("disconnected");
const connectingRef = useRef(false);
useEffect(() => {
if (!monaco || connectingRef.current) return;
const client = getLspClient();
if (client.status === "ready") {
setStatus("ready");
return;
}
connectingRef.current = true;
const unsub = client.onStatusChange(setStatus);
client.connect(monaco).catch((err) => {
console.error("[LSP] Connection failed:", err);
});
return () => {
unsub();
};
}, [monaco]);
return { lspClient: getLspClient(), status };
}
export function useLspDocument(
editorInstance: editor.IStandaloneCodeEditor | null,
filePath: string,
language: string,
) {
const prevPathRef = useRef<string | null>(null);
useEffect(() => {
if (!editorInstance) return;
const client = getLspClient();
if (client.status !== "ready") return;
const model = editorInstance.getModel();
if (!model) return;
if (prevPathRef.current && prevPathRef.current !== filePath) {
const prevUri = model.uri;
client.notifyDidClose(prevUri);
}
client.notifyDidOpen(model.uri, language, model.getValue());
prevPathRef.current = filePath;
const changeDisposable = model.onDidChangeContent(() => {
client.notifyDidChange(model.uri, model.getValue());
});
return () => {
changeDisposable.dispose();
client.notifyDidClose(model.uri);
};
}, [editorInstance, filePath, language]);
}
-359
View File
@@ -1,359 +0,0 @@
import { toSocket } from "vscode-ws-jsonrpc";
import { createWebSocketConnection } from "vscode-ws-jsonrpc";
import type * as lsp from "vscode-languageserver-protocol";
import type { editor, languages, IDisposable, Uri, MarkerSeverity } from "monaco-editor";
const LSP_TRACE = false;
function trace(...args: unknown[]) {
if (LSP_TRACE) console.debug("[LSP]", ...args);
}
export type LspStatus = "disconnected" | "connecting" | "initializing" | "ready" | "error";
export class LspClient {
private ws: WebSocket | null = null;
private connection: ReturnType<typeof createWebSocketConnection> | null = null;
private disposables: IDisposable[] = [];
private documentVersions = new Map<string, number>();
private statusListeners = new Set<(status: LspStatus) => void>();
private _status: LspStatus = "disconnected";
private monacoInstance: typeof import("monaco-editor") | null = null;
get status() {
return this._status;
}
private setStatus(s: LspStatus) {
this._status = s;
for (const cb of this.statusListeners) cb(s);
}
onStatusChange(cb: (status: LspStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
async connect(monaco: typeof import("monaco-editor")): Promise<void> {
this.monacoInstance = monaco;
this.setStatus("connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/lsp`;
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(url);
this.ws = ws;
ws.onopen = async () => {
try {
const socket = toSocket(ws);
this.connection = createWebSocketConnection(socket, {
error: (msg) => console.error("[LSP]", msg),
warn: (msg) => console.warn("[LSP]", msg),
info: (msg) => trace(msg),
log: (msg) => trace(msg),
});
this.setupNotificationHandlers(monaco);
this.connection.listen();
this.setStatus("initializing");
await this.initialize();
this.registerProviders(monaco);
this.setStatus("ready");
resolve();
} catch (err) {
this.setStatus("error");
reject(err);
}
};
ws.onerror = () => {
this.setStatus("error");
reject(new Error("WebSocket connection failed"));
};
ws.onclose = () => {
this.setStatus("disconnected");
};
});
}
private async initialize(): Promise<void> {
if (!this.connection) throw new Error("No connection");
const initParams: lsp.InitializeParams = {
processId: null,
capabilities: {
textDocument: {
completion: {
completionItem: {
snippetSupport: false,
documentationFormat: ["plaintext", "markdown"],
},
},
hover: {
contentFormat: ["plaintext", "markdown"],
},
synchronization: {
didSave: true,
},
publishDiagnostics: {
relatedInformation: true,
},
},
},
rootUri: "file:///workspace",
workspaceFolders: null,
};
trace("→ initialize", initParams);
const result = await this.connection.sendRequest("initialize", initParams);
trace("← initialize", result);
this.connection.sendNotification("initialized", {});
trace("→ initialized");
}
private setupNotificationHandlers(monaco: typeof import("monaco-editor")): void {
if (!this.connection) return;
this.connection.onNotification(
"textDocument/publishDiagnostics",
(params: lsp.PublishDiagnosticsParams) => {
trace("← publishDiagnostics", params);
const uri = monaco.Uri.parse(params.uri);
const model = monaco.editor.getModel(uri);
if (!model) return;
const markers: editor.IMarkerData[] = params.diagnostics.map((d) => ({
severity: this.mapSeverity(monaco, d.severity),
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
message: d.message,
source: d.source,
}));
monaco.editor.setModelMarkers(model, "nwscript-lsp", markers);
},
);
}
private mapSeverity(
monaco: typeof import("monaco-editor"),
severity: lsp.DiagnosticSeverity | undefined,
): MarkerSeverity {
switch (severity) {
case 1:
return monaco.MarkerSeverity.Error;
case 2:
return monaco.MarkerSeverity.Warning;
case 3:
return monaco.MarkerSeverity.Info;
case 4:
return monaco.MarkerSeverity.Hint;
default:
return monaco.MarkerSeverity.Info;
}
}
private registerProviders(monaco: typeof import("monaco-editor")): void {
this.disposables.push(
monaco.languages.registerCompletionItemProvider("nwscript", {
triggerCharacters: ["."],
provideCompletionItems: async (model, position) => {
if (!this.connection) return { suggestions: [] };
const params: lsp.CompletionParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ completion", params);
const result: lsp.CompletionItem[] | lsp.CompletionList | null =
await this.connection.sendRequest("textDocument/completion", params);
trace("← completion", result);
if (!result) return { suggestions: [] };
const items = Array.isArray(result) ? result : result.items;
const suggestions: languages.CompletionItem[] = items.map((item) => ({
label: item.label,
kind: this.mapCompletionKind(monaco, item.kind),
insertText: item.insertText ?? item.label,
detail: item.detail,
documentation: item.documentation
? typeof item.documentation === "string"
? item.documentation
: { value: item.documentation.value }
: undefined,
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
}));
return { suggestions };
},
}),
);
this.disposables.push(
monaco.languages.registerHoverProvider("nwscript", {
provideHover: async (model, position) => {
if (!this.connection) return null;
const params: lsp.HoverParams = {
textDocument: { uri: model.uri.toString() },
position: {
line: position.lineNumber - 1,
character: position.column - 1,
},
};
trace("→ hover", params);
const result: lsp.Hover | null = await this.connection.sendRequest(
"textDocument/hover",
params,
);
trace("← hover", result);
if (!result?.contents) return null;
const contents = Array.isArray(result.contents)
? result.contents.map((c) =>
typeof c === "string" ? { value: c } : { value: c.value },
)
: typeof result.contents === "string"
? [{ value: result.contents }]
: "value" in result.contents
? [{ value: result.contents.value }]
: [{ value: String(result.contents) }];
return {
contents,
range: result.range
? {
startLineNumber: result.range.start.line + 1,
startColumn: result.range.start.character + 1,
endLineNumber: result.range.end.line + 1,
endColumn: result.range.end.character + 1,
}
: undefined,
};
},
}),
);
}
private mapCompletionKind(
monaco: typeof import("monaco-editor"),
kind: lsp.CompletionItemKind | undefined,
): languages.CompletionItemKind {
const k = monaco.languages.CompletionItemKind;
switch (kind) {
case 1:
return k.Text;
case 2:
return k.Method;
case 3:
return k.Function;
case 4:
return k.Constructor;
case 5:
return k.Field;
case 6:
return k.Variable;
case 7:
return k.Class;
case 8:
return k.Interface;
case 9:
return k.Module;
case 10:
return k.Property;
case 13:
return k.Enum;
case 14:
return k.Keyword;
case 15:
return k.Snippet;
case 21:
return k.Constant;
case 22:
return k.Struct;
default:
return k.Text;
}
}
notifyDidOpen(uri: Uri, languageId: string, text: string): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.set(uri.toString(), 1);
const params: lsp.DidOpenTextDocumentParams = {
textDocument: {
uri: uri.toString(),
languageId,
version: 1,
text,
},
};
trace("→ didOpen", params.textDocument.uri);
this.connection.sendNotification("textDocument/didOpen", params);
}
notifyDidChange(uri: Uri, text: string): void {
if (!this.connection || this._status !== "ready") return;
const version = (this.documentVersions.get(uri.toString()) ?? 0) + 1;
this.documentVersions.set(uri.toString(), version);
const params: lsp.DidChangeTextDocumentParams = {
textDocument: { uri: uri.toString(), version },
contentChanges: [{ text }],
};
trace("→ didChange", params.textDocument.uri);
this.connection.sendNotification("textDocument/didChange", params);
}
notifyDidClose(uri: Uri): void {
if (!this.connection || this._status !== "ready") return;
this.documentVersions.delete(uri.toString());
const params: lsp.DidCloseTextDocumentParams = {
textDocument: { uri: uri.toString() },
};
trace("→ didClose", params.textDocument.uri);
this.connection.sendNotification("textDocument/didClose", params);
}
dispose(): void {
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.connection?.dispose();
this.connection = null;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close();
}
this.ws = null;
this.documentVersions.clear();
this.setStatus("disconnected");
}
}
let globalClient: LspClient | null = null;
export function getLspClient(): LspClient {
if (!globalClient) {
globalClient = new LspClient();
}
return globalClient;
}
@@ -0,0 +1,63 @@
import { registerExtension } from "@codingame/monaco-vscode-api/extensions";
const manifest = {
name: "nwscript-language",
displayName: "NWScript Language",
description: "NWScript syntax highlighting for Neverwinter Nights",
version: "1.0.0",
publisher: "layonara",
engines: { vscode: "*" },
contributes: {
languages: [
{
id: "nwscript",
aliases: ["Neverwinter Script", "nwscript"],
extensions: [".nss"],
configuration: "./syntaxes/language-configuration.json",
},
],
grammars: [
{
language: "nwscript",
scopeName: "source.nss",
path: "./syntaxes/nwscript-ee.tmLanguage.json",
},
],
themes: [
{
id: "forge-dark",
label: "Forge Dark",
uiTheme: "vs-dark",
path: "./themes/forge-dark.json",
},
],
},
};
const { registerFileUrl, whenReady } = registerExtension(manifest);
registerFileUrl(
"./syntaxes/nwscript-ee.tmLanguage.json",
new URL("./syntaxes/nwscript-ee.tmLanguage.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"./syntaxes/language-configuration.json",
new URL("./syntaxes/language-configuration.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"package.json",
new URL("./package.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
registerFileUrl(
"./themes/forge-dark.json",
new URL("./themes/forge-dark.json", import.meta.url).toString(),
{ mimeType: "application/json" },
);
export { whenReady };
@@ -0,0 +1,32 @@
{
"name": "nwscript-language-extension",
"version": "1.0.0",
"engines": {
"vscode": "*"
},
"contributes": {
"languages": [
{
"id": "nwscript",
"aliases": ["Neverwinter Script", "nwscript"],
"extensions": [".nss"],
"configuration": "./syntaxes/language-configuration.json"
}
],
"grammars": [
{
"language": "nwscript",
"scopeName": "source.nss",
"path": "./syntaxes/nwscript-ee.tmLanguage.json"
}
],
"themes": [
{
"id": "forge-dark",
"label": "Forge Dark",
"uiTheme": "vs-dark",
"path": "./themes/forge-dark.json"
}
]
}
}
@@ -0,0 +1,30 @@
{
"comments": {
// symbol used for single line comment. Remove this entry if your language does not support line comments
"lineComment": "//",
// symbols used for start and end a block comment. Remove this entry if your language does not support block comments
"blockComment": ["/*", "*/"]
},
// symbols used as brackets
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
// symbols that are auto closed when typing
"autoClosingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"]
],
// symbols that that can be used to surround a selection
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"]
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,347 @@
{
"name": "Forge Dark",
"type": "dark",
"colors": {
"editor.background": "#231e17",
"editor.foreground": "#ece8e3",
"editor.lineHighlightBackground": "#302a2040",
"editor.selectionBackground": "#3d3018",
"editor.selectionHighlightBackground": "#3d301860",
"editor.inactiveSelectionBackground": "#3d301850",
"editor.wordHighlightBackground": "#3d301840",
"editor.wordHighlightStrongBackground": "#3d301860",
"editor.findMatchBackground": "#b07a0040",
"editor.findMatchHighlightBackground": "#b07a0025",
"editor.hoverHighlightBackground": "#3d301830",
"editor.rangeHighlightBackground": "#3d301820",
"editorCursor.foreground": "#b07a00",
"editorWhitespace.foreground": "#4a403550",
"editorIndentGuide.background": "#4a403530",
"editorIndentGuide.activeBackground": "#4a403580",
"editorLineNumber.foreground": "#a69f9650",
"editorLineNumber.activeForeground": "#ece8e3",
"editorBracketMatch.background": "#3d301840",
"editorBracketMatch.border": "#b07a0080",
"editorOverviewRuler.border": "#4a4035",
"editorRuler.foreground": "#4a403540",
"editorGutter.background": "#231e17",
"editorError.foreground": "#f14c4c",
"editorWarning.foreground": "#cca700",
"editorInfo.foreground": "#3794ff",
"editorWidget.background": "#3b3328",
"editorWidget.foreground": "#ece8e3",
"editorWidget.border": "#4a4035",
"editorSuggestWidget.background": "#3b3328",
"editorSuggestWidget.border": "#4a4035",
"editorSuggestWidget.foreground": "#ece8e3",
"editorSuggestWidget.highlightForeground": "#b07a00",
"editorSuggestWidget.selectedBackground": "#3d3018",
"editorHoverWidget.background": "#3b3328",
"editorHoverWidget.border": "#4a4035",
"sideBar.background": "#302a20",
"sideBar.foreground": "#ece8e3",
"sideBar.border": "#4a4035",
"sideBarTitle.foreground": "#ece8e3",
"sideBarSectionHeader.background": "#302a20",
"sideBarSectionHeader.foreground": "#ece8e3",
"sideBarSectionHeader.border": "#4a4035",
"activityBar.background": "#231e17",
"activityBar.foreground": "#ece8e3",
"activityBar.border": "#4a4035",
"activityBar.activeBorder": "#b07a00",
"activityBarBadge.background": "#b07a00",
"activityBarBadge.foreground": "#231e17",
"titleBar.activeBackground": "#231e17",
"titleBar.activeForeground": "#ece8e3",
"titleBar.inactiveBackground": "#231e17",
"titleBar.inactiveForeground": "#a69f96",
"titleBar.border": "#4a4035",
"statusBar.background": "#231e17",
"statusBar.foreground": "#a69f96",
"statusBar.border": "#4a4035",
"statusBar.debuggingBackground": "#b07a00",
"statusBar.debuggingForeground": "#231e17",
"statusBar.noFolderBackground": "#231e17",
"tab.activeBackground": "#231e17",
"tab.activeForeground": "#ece8e3",
"tab.activeBorderTop": "#b07a00",
"tab.inactiveBackground": "#302a20",
"tab.inactiveForeground": "#a69f96",
"tab.border": "#4a4035",
"tab.hoverBackground": "#3b3328",
"editorGroupHeader.tabsBackground": "#302a20",
"editorGroupHeader.tabsBorder": "#4a4035",
"editorGroup.border": "#4a4035",
"panel.background": "#302a20",
"panel.border": "#4a4035",
"panelTitle.activeBorder": "#b07a00",
"panelTitle.activeForeground": "#ece8e3",
"panelTitle.inactiveForeground": "#a69f96",
"list.activeSelectionBackground": "#3d3018",
"list.activeSelectionForeground": "#ece8e3",
"list.inactiveSelectionBackground": "#3d301880",
"list.hoverBackground": "#302a2080",
"list.highlightForeground": "#b07a00",
"list.focusOutline": "#b07a00",
"input.background": "#231e17",
"input.foreground": "#ece8e3",
"input.border": "#4a4035",
"input.placeholderForeground": "#a69f9680",
"inputOption.activeBorder": "#b07a00",
"inputOption.activeBackground": "#3d3018",
"dropdown.background": "#3b3328",
"dropdown.foreground": "#ece8e3",
"dropdown.border": "#4a4035",
"button.background": "#b07a00",
"button.foreground": "#231e17",
"button.hoverBackground": "#c88b00",
"button.secondaryBackground": "#3b3328",
"button.secondaryForeground": "#ece8e3",
"button.secondaryHoverBackground": "#4a4035",
"badge.background": "#b07a00",
"badge.foreground": "#231e17",
"scrollbar.shadow": "#00000040",
"scrollbarSlider.background": "#4a403540",
"scrollbarSlider.hoverBackground": "#4a403580",
"scrollbarSlider.activeBackground": "#4a4035a0",
"focusBorder": "#b07a0080",
"foreground": "#ece8e3",
"descriptionForeground": "#a69f96",
"icon.foreground": "#a69f96",
"selection.background": "#3d3018",
"widget.shadow": "#00000040",
"terminal.foreground": "#ece8e3",
"terminal.background": "#231e17",
"terminal.ansiBlack": "#231e17",
"terminal.ansiRed": "#f14c4c",
"terminal.ansiGreen": "#6a9955",
"terminal.ansiYellow": "#b07a00",
"terminal.ansiBlue": "#569cd6",
"terminal.ansiMagenta": "#c586c0",
"terminal.ansiCyan": "#4ec9b0",
"terminal.ansiWhite": "#ece8e3",
"terminal.ansiBrightBlack": "#a69f96",
"terminal.ansiBrightRed": "#f14c4c",
"terminal.ansiBrightGreen": "#6a9955",
"terminal.ansiBrightYellow": "#dcdcaa",
"terminal.ansiBrightBlue": "#4fc1ff",
"terminal.ansiBrightMagenta": "#c586c0",
"terminal.ansiBrightCyan": "#4ec9b0",
"terminal.ansiBrightWhite": "#ece8e3",
"breadcrumb.foreground": "#a69f96",
"breadcrumb.focusForeground": "#ece8e3",
"breadcrumb.activeSelectionForeground": "#ece8e3",
"breadcrumbPicker.background": "#3b3328",
"peekView.border": "#b07a00",
"peekViewEditor.background": "#231e17",
"peekViewResult.background": "#302a20",
"peekViewTitle.background": "#302a20",
"peekViewTitleLabel.foreground": "#ece8e3",
"peekViewTitleDescription.foreground": "#a69f96",
"peekViewResult.selectionBackground": "#3d3018",
"peekViewResult.selectionForeground": "#ece8e3",
"minimap.selectionHighlight": "#3d3018",
"minimap.findMatchHighlight": "#b07a0060",
"gitDecoration.addedResourceForeground": "#6a9955",
"gitDecoration.modifiedResourceForeground": "#e2c08d",
"gitDecoration.deletedResourceForeground": "#f14c4c",
"gitDecoration.untrackedResourceForeground": "#73c991",
"gitDecoration.ignoredResourceForeground": "#a69f9680",
"gitDecoration.conflictingResourceForeground": "#e2c08d"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": {
"foreground": "#6a9955",
"fontStyle": "italic"
}
},
{
"scope": [
"string",
"string.quoted",
"string.template",
"punctuation.definition.string"
],
"settings": {
"foreground": "#ce9178"
}
},
{
"scope": "string.regexp",
"settings": {
"foreground": "#d16969"
}
},
{
"scope": [
"keyword",
"keyword.control",
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.cast",
"keyword.operator.sizeof",
"storage.modifier"
],
"settings": {
"foreground": "#c586c0"
}
},
{
"scope": [
"storage.type",
"keyword.type",
"support.type",
"entity.name.type",
"entity.name.class",
"entity.name.namespace",
"entity.name.struct"
],
"settings": {
"foreground": "#4ec9b0"
}
},
{
"scope": [
"constant",
"variable.other.constant",
"variable.other.enummember",
"support.constant"
],
"settings": {
"foreground": "#4fc1ff"
}
},
{
"scope": ["constant.numeric", "keyword.other.unit"],
"settings": {
"foreground": "#b5cea8"
}
},
{
"scope": "constant.language",
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": [
"entity.name.function",
"support.function",
"entity.name.function.preprocessor"
],
"settings": {
"foreground": "#dcdcaa"
}
},
{
"scope": [
"variable",
"variable.other",
"variable.parameter",
"meta.definition.variable"
],
"settings": {
"foreground": "#9cdcfe"
}
},
{
"scope": [
"keyword.control.directive",
"keyword.preprocessor",
"meta.preprocessor",
"entity.name.function.preprocessor"
],
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": [
"keyword.operator",
"keyword.operator.assignment",
"keyword.operator.arithmetic",
"keyword.operator.logical",
"keyword.operator.comparison",
"keyword.operator.bitwise",
"punctuation"
],
"settings": {
"foreground": "#d4d4d4"
}
},
{
"scope": [
"entity.name.tag",
"punctuation.definition.tag"
],
"settings": {
"foreground": "#569cd6"
}
},
{
"scope": "entity.other.attribute-name",
"settings": {
"foreground": "#9cdcfe"
}
},
{
"scope": "constant.character.escape",
"settings": {
"foreground": "#d7ba7d"
}
},
{
"scope": "invalid",
"settings": {
"foreground": "#f44747"
}
},
{
"scope": "markup.heading",
"settings": {
"foreground": "#569cd6",
"fontStyle": "bold"
}
},
{
"scope": "markup.bold",
"settings": {
"fontStyle": "bold"
}
},
{
"scope": "markup.italic",
"settings": {
"fontStyle": "italic"
}
},
{
"scope": "markup.inline.raw",
"settings": {
"foreground": "#ce9178"
}
}
]
}
+3 -1
View File
@@ -37,9 +37,10 @@ function filePathFromTabKey(tabKey: string): string {
interface EditorProps {
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
workspacePath?: string;
}
export function Editor({ editorState }: EditorProps) {
export function Editor({ editorState, workspacePath }: EditorProps) {
const {
openTabs,
activeTab,
@@ -199,6 +200,7 @@ export function Editor({ editorState }: EditorProps) {
filePath={activeFilePath}
content={activeContent}
onChange={handleChange}
workspacePath={workspacePath}
/>
</div>
</div>
+3 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
import { SimpleEditor } from "../components/editor/SimpleEditor";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
@@ -524,17 +524,15 @@ function SQLConsole() {
</div>
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<ReactMonacoEditor
<SimpleEditor
value={query}
language="sql"
theme="vs-dark"
onChange={(v) => setQuery(v ?? "")}
onChange={(v) => setQuery(v)}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: "off",
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: "on",
padding: { top: 4, bottom: 4 },
renderLineHighlight: "none",
+72 -16
View File
@@ -212,12 +212,62 @@ function StepNav({
);
}
function friendlyError(error: string): { title: string; detail?: string } {
if (error.includes("EACCES") && error.includes("docker.sock")) {
return {
title: "Docker socket not accessible",
detail: "The backend can\u2019t reach the Docker daemon. If running natively, add your user to the docker group or run inside the Forge container.",
};
}
if (error.includes("ENOENT") && error.includes("spawn git")) {
return {
title: "Git not found",
detail: "The git binary is not on the system PATH. Install git or ensure the Forge container includes it.",
};
}
if (error.includes("ENOENT")) {
return {
title: "File or directory not found",
detail: error,
};
}
if (error.includes("ECONNREFUSED")) {
return {
title: "Connection refused",
detail: "The service is not running or not reachable. Check that the required containers are started.",
};
}
if (error === "Internal Server Error") {
return {
title: "Server error",
detail: "An unexpected error occurred on the backend. Check the terminal running the Forge backend for details.",
};
}
return { title: error };
}
function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
const { title, detail } = friendlyError(error);
return (
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", border: "1px solid var(--forge-danger-border)" }}>
<p style={{ color: "var(--forge-danger)" }}>{error}</p>
<div style={{ marginTop: "1rem", borderRadius: "0.5rem", padding: "0.875rem 1.25rem", backgroundColor: "var(--forge-danger-bg)", border: "1px solid var(--forge-danger-border)" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600, color: "var(--forge-danger)" }}>{title}</p>
{detail && (
<p style={{ margin: "0.375rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)", lineHeight: 1.5 }}>{detail}</p>
)}
{onRetry && (
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "var(--forge-danger)" }}>
<button
onClick={onRetry}
style={{
marginTop: "0.625rem",
fontSize: "var(--text-xs)",
color: "var(--forge-danger)",
background: "none",
border: "none",
textDecoration: "underline",
cursor: "pointer",
padding: 0,
}}
>
Retry
</button>
)}
@@ -592,12 +642,12 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
{forkableRepos.map((repo) => (
<div key={repo} style={{ ...statusRow, justifyContent: "space-between" }}>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
layonara/{repo}
</p>
{errors[repo] && (
<p className="mt-1 text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>
{friendlyError(errors[repo]).title}
</p>
)}
</div>
@@ -616,14 +666,14 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
))}
<div style={{ ...statusRow, justifyContent: "space-between" }}>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
plenarius/unified
</p>
<p className="mt-1 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
Read-only from GitHub (no fork needed)
</p>
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
Read-only
</span>
</div>
@@ -672,22 +722,25 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
: cloneStatus[repo] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{repo}
</span>
{repo === "unified" && (
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
(GitHub, read-only)
</span>
)}
{errors[repo] && (
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>
{friendlyError(errors[repo]).title}
</span>
)}
</div>
))}
</div>
{Object.values(errors).some(Boolean) && (
<ErrorBox error={Object.values(errors).find(Boolean) || ""} />
)}
<div style={{ marginTop: "1.25rem" }}>
<button
onClick={cloneAll}
@@ -739,17 +792,20 @@ function PullImagesStep({ onNext, onBack }: StepProps) {
: pullStatus[image] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{image}
</span>
{errors[image] && (
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[image]}
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-danger)" }}>
{friendlyError(errors[image]).title}
</span>
)}
</div>
))}
</div>
{Object.values(errors).some(Boolean) && (
<ErrorBox error={Object.values(errors).find(Boolean) || ""} />
)}
<div style={{ marginTop: "1.25rem" }}>
<button
onClick={pullAll}
+2 -7
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { SimpleDiffEditor } from "../components/editor/SimpleEditor";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
@@ -749,18 +749,13 @@ export function Toolset() {
backgroundColor: "var(--forge-log-bg)",
}}
>
<DiffEditor
<SimpleDiffEditor
original={diffData.original}
modified={diffData.modified}
language="json"
theme="vs-dark"
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
renderSideBySide: true,
padding: { top: 4 },
}}
/>
+17 -2
View File
@@ -6,8 +6,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
let message = res.statusText;
try {
const body = await res.json();
message = body.error || body.message || message;
} catch {
const text = await res.text().catch(() => "");
if (text && text.length < 200 && !text.includes("<")) message = text;
}
throw new Error(message);
}
return res.json();
}
@@ -41,6 +48,14 @@ export const api = {
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
gffSchema: (type: string) =>
request<import("../components/gff/GffEditor").GffTypeSchema>(`/editor/gff-schema/${type}`),
tlkLookup: async (id: number): Promise<string | null> => {
try {
const result = await request<{ text: string }>(`/editor/tlk/${id}`);
return result.text ?? null;
} catch {
return null;
}
},
},
workspace: {
+12
View File
@@ -1,8 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import importMetaUrlPlugin from "@codingame/esbuild-import-meta-url-plugin";
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ["vscode"],
},
worker: {
format: "es",
},
optimizeDeps: {
esbuildOptions: {
plugins: [importMetaUrlPlugin],
},
},
server: {
port: 5173,
proxy: {