f39f1d818b
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.
231 lines
6.5 KiB
TypeScript
231 lines
6.5 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { MonacoEditor } from "../components/editor/MonacoEditor";
|
|
import { EditorTabs } from "../components/editor/EditorTabs";
|
|
import { GffEditor } from "../components/gff/GffEditor";
|
|
import { ItemEditor } from "../components/gff/ItemEditor";
|
|
import { CreatureEditor } from "../components/gff/CreatureEditor";
|
|
import { AreaEditor } from "../components/gff/AreaEditor";
|
|
import { DialogEditor } from "../components/gff/DialogEditor";
|
|
import { FileCode, Code2, Eye } from "lucide-react";
|
|
|
|
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
|
|
|
|
type GffEditorType = "uti" | "utc" | "are" | "dlg" | "generic" | null;
|
|
|
|
function isGffFile(tabKey: string): boolean {
|
|
return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext));
|
|
}
|
|
|
|
function getGffEditorType(tabKey: string): GffEditorType {
|
|
if (tabKey.endsWith(".uti.json")) return "uti";
|
|
if (tabKey.endsWith(".utc.json")) return "utc";
|
|
if (tabKey.endsWith(".are.json")) return "are";
|
|
if (tabKey.endsWith(".dlg.json")) return "dlg";
|
|
if (tabKey.endsWith(".utp.json") || tabKey.endsWith(".utm.json")) return "generic";
|
|
return null;
|
|
}
|
|
|
|
function repoFromTabKey(tabKey: string): string {
|
|
const idx = tabKey.indexOf(":");
|
|
return idx > 0 ? tabKey.slice(0, idx) : "";
|
|
}
|
|
|
|
function filePathFromTabKey(tabKey: string): string {
|
|
const idx = tabKey.indexOf(":");
|
|
return idx > 0 ? tabKey.slice(idx + 1) : tabKey;
|
|
}
|
|
|
|
interface EditorProps {
|
|
editorState: ReturnType<typeof import("../hooks/useEditorState").useEditorState>;
|
|
workspacePath?: string;
|
|
}
|
|
|
|
export function Editor({ editorState, workspacePath }: EditorProps) {
|
|
const {
|
|
openTabs,
|
|
activeTab,
|
|
dirtyFiles,
|
|
selectTab,
|
|
closeFile,
|
|
updateContent,
|
|
getContent,
|
|
markClean,
|
|
} = editorState;
|
|
|
|
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
|
|
|
|
const tabs = useMemo(
|
|
() =>
|
|
openTabs.map((path: string) => ({
|
|
path,
|
|
dirty: dirtyFiles.has(path),
|
|
})),
|
|
[openTabs, dirtyFiles],
|
|
);
|
|
|
|
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
|
|
const activeFilePath = activeTab ? filePathFromTabKey(activeTab) : "";
|
|
const activeRepo = activeTab ? repoFromTabKey(activeTab) : "";
|
|
|
|
const isActiveGff = activeTab ? isGffFile(activeTab) : false;
|
|
const activeMode = activeTab
|
|
? editorModes[activeTab] ?? (isActiveGff ? "visual" : "raw")
|
|
: "raw";
|
|
|
|
const handleChange = useCallback(
|
|
(value: string) => {
|
|
if (activeTab) {
|
|
updateContent(activeTab, value);
|
|
}
|
|
},
|
|
[activeTab, updateContent],
|
|
);
|
|
|
|
const handleSwitchToRaw = useCallback(() => {
|
|
if (activeTab) {
|
|
setEditorModes((prev) => ({ ...prev, [activeTab]: "raw" }));
|
|
}
|
|
}, [activeTab]);
|
|
|
|
const handleSwitchToVisual = useCallback(() => {
|
|
if (activeTab) {
|
|
setEditorModes((prev) => ({ ...prev, [activeTab]: "visual" }));
|
|
}
|
|
}, [activeTab]);
|
|
|
|
const handleGffSave = useCallback(
|
|
(newContent: string) => {
|
|
if (activeTab) {
|
|
updateContent(activeTab, newContent);
|
|
markClean(activeTab);
|
|
}
|
|
},
|
|
[activeTab, updateContent, markClean],
|
|
);
|
|
|
|
const renderEditor = () => {
|
|
if (!activeTab) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: "100%",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<FileCode
|
|
size={48}
|
|
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
|
|
/>
|
|
<p
|
|
style={{
|
|
color: "var(--forge-text-secondary)",
|
|
fontSize: "var(--text-lg)",
|
|
fontFamily: "var(--font-heading)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Open a file from the File Explorer to start editing
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isActiveGff && activeMode === "visual") {
|
|
const editorType = getGffEditorType(activeTab);
|
|
const commonProps = {
|
|
key: activeTab,
|
|
repo: activeRepo,
|
|
filePath: activeFilePath,
|
|
content: activeContent,
|
|
onSave: handleGffSave,
|
|
onSwitchToRaw: handleSwitchToRaw,
|
|
};
|
|
|
|
switch (editorType) {
|
|
case "uti":
|
|
return <ItemEditor {...commonProps} />;
|
|
case "utc":
|
|
return <CreatureEditor {...commonProps} />;
|
|
case "are":
|
|
return <AreaEditor {...commonProps} />;
|
|
case "dlg":
|
|
return <DialogEditor {...commonProps} />;
|
|
default:
|
|
return <GffEditor {...commonProps} />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
{isActiveGff && activeMode === "raw" && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
flexShrink: 0,
|
|
padding: "4px 16px",
|
|
borderBottom: "1px solid var(--forge-border)",
|
|
backgroundColor: "var(--forge-surface-raised)",
|
|
}}
|
|
>
|
|
<button
|
|
onClick={handleSwitchToVisual}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "4px 12px",
|
|
borderRadius: 4,
|
|
border: "1px solid var(--forge-border)",
|
|
backgroundColor: "var(--forge-surface)",
|
|
color: "var(--forge-text-secondary)",
|
|
fontSize: "var(--text-xs)",
|
|
fontFamily: "var(--font-mono)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Eye size={13} />
|
|
Switch to Visual Editor
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
<MonacoEditor
|
|
key={activeTab}
|
|
filePath={activeFilePath}
|
|
content={activeContent}
|
|
onChange={handleChange}
|
|
workspacePath={workspacePath}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
backgroundColor: "var(--forge-bg)",
|
|
}}
|
|
>
|
|
<EditorTabs
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onSelect={selectTab}
|
|
onClose={closeFile}
|
|
/>
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
{renderEditor()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|