feat: integrate monaco-languageclient v10 with NWScript LSP
Replace hand-rolled LSP client (lspClient.ts, useLspClient.ts) with monaco-languageclient v10 extended mode using @typefox/monaco-editor-react. NWScript TextMate grammar from the LSP submodule provides syntax highlighting. Full LSP features: completion, hover, diagnostics, go-to-definition, signature help — all wired through WebSocket to the nwscript-language-server. LSP server patches: fix workspaceFolders null assertion crash, handle missing workspace/configuration gracefully, derive rootPath from rootUri when null, guard tokenizer getRawTokenContent against undefined tokens. Backend fixes: WebSocket routing changed to noServer mode so /ws, /ws/lsp, and /ws/terminal/* don't conflict. TLK index loaded at startup (41,927 entries from nwn-haks/layonara.tlk.json). Workspace routes get proper try/catch. writeConfig creates parent directories. setupClone ensures workspace structure. Frontend: GffEditor and AreaEditor rewritten with inline styles and TLK resolution for CExoLocString fields. EditorTabs rewritten with lucide icons. Tab content hydrates from API on refresh. Setup wizard gets friendly error messages. SimpleEditor/SimpleDiffEditor for non-LSP editor uses. Vite config updated for monaco-vscode-api compatibility.
This commit is contained in:
@@ -1,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");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user