feat: integrate Monaco Editor with tabs, NWScript syntax, and session persistence
This commit is contained in:
@@ -3,3 +3,4 @@ dist/
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
Generated
+69
@@ -860,6 +860,29 @@
|
|||||||
"resolved": "packages/frontend",
|
"resolved": "packages/frontend",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@monaco-editor/loader": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"state-local": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@monaco-editor/react": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/loader": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"monaco-editor": ">= 0.25.0 < 1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/aspromise": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
@@ -1567,6 +1590,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -2271,6 +2301,15 @@
|
|||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2848,6 +2887,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2909,6 +2960,16 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "3.2.7",
|
||||||
|
"marked": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -3600,6 +3661,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/state-local": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -4719,6 +4786,8 @@
|
|||||||
"name": "@layonara-forge/frontend",
|
"name": "@layonara-forge/frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { Editor } from "./pages/Editor";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/editor" element={<Editor />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
interface Tab {
|
||||||
|
path: string;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorTabsProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTab: string | null;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filename(path: string): string {
|
||||||
|
return path.split("/").pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorTabs({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: EditorTabsProps) {
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
borderBottom: "1px solid var(--forge-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<span
|
||||||
|
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={{ color: "var(--forge-text-secondary)" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useRef, useCallback } from "react";
|
||||||
|
import { Editor as ReactMonacoEditor, type OnMount } from "@monaco-editor/react";
|
||||||
|
import type { editor } from "monaco-editor";
|
||||||
|
|
||||||
|
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: [
|
||||||
|
[/[^\\"]+/, "string"],
|
||||||
|
[/\\./, "string.escape"],
|
||||||
|
[/"/, { token: "string.quote", next: "@pop" }],
|
||||||
|
],
|
||||||
|
|
||||||
|
whitespace: [
|
||||||
|
[/[ \t\r\n]+/, "white"],
|
||||||
|
[/\/\*/, "comment", "@comment"],
|
||||||
|
[/\/\/.*$/, "comment"],
|
||||||
|
],
|
||||||
|
|
||||||
|
comment: [
|
||||||
|
[/[^/*]+/, "comment"],
|
||||||
|
[/\*\//, "comment", "@pop"],
|
||||||
|
[/[/*]/, "comment"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
|
||||||
|
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
|
||||||
|
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
|
||||||
|
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
|
||||||
|
const textSecondary =
|
||||||
|
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
|
||||||
|
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
"editor.background": bg,
|
||||||
|
"editor.foreground": text,
|
||||||
|
"editorCursor.foreground": accent,
|
||||||
|
"editor.lineHighlightBackground": surface,
|
||||||
|
"editorLineNumber.foreground": textSecondary,
|
||||||
|
"editorLineNumber.activeForeground": text,
|
||||||
|
"editor.selectionBackground": "#264f7840",
|
||||||
|
"editorWidget.background": surface,
|
||||||
|
"editorWidget.border": border,
|
||||||
|
"editorSuggestWidget.background": surface,
|
||||||
|
"editorSuggestWidget.border": border,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageFromPath(filePath: string): string {
|
||||||
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
nss: "nwscript",
|
||||||
|
json: "json",
|
||||||
|
sql: "sql",
|
||||||
|
css: "css",
|
||||||
|
html: "html",
|
||||||
|
xml: "xml",
|
||||||
|
js: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
md: "markdown",
|
||||||
|
txt: "plaintext",
|
||||||
|
"2da": "plaintext",
|
||||||
|
};
|
||||||
|
return map[ext ?? ""] ?? "plaintext";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoEditor({
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
language,
|
||||||
|
onChange,
|
||||||
|
}: MonacoEditorProps) {
|
||||||
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
|
||||||
|
const handleMount: OnMount = useCallback(
|
||||||
|
(editorInstance, monaco) => {
|
||||||
|
editorRef.current = editorInstance;
|
||||||
|
registerNWScript(monaco);
|
||||||
|
defineForgeTheme(monaco);
|
||||||
|
monaco.editor.setTheme("forge-dark");
|
||||||
|
|
||||||
|
const model = editorInstance.getModel();
|
||||||
|
if (model) {
|
||||||
|
const lang = language ?? languageFromPath(filePath);
|
||||||
|
monaco.editor.setModelLanguage(model, lang);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filePath, language],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string | undefined) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
onChange?.(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "forge-editor-state";
|
||||||
|
|
||||||
|
interface PersistedState {
|
||||||
|
openTabs: string[];
|
||||||
|
activeTab: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedState(): PersistedState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
openTabs: Array.isArray(parsed.openTabs) ? parsed.openTabs : [],
|
||||||
|
activeTab:
|
||||||
|
typeof parsed.activeTab === "string" ? parsed.activeTab : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// corrupted storage — start fresh
|
||||||
|
}
|
||||||
|
return { openTabs: [], activeTab: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistState(state: PersistedState) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// quota exceeded — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorState() {
|
||||||
|
const [openTabs, setOpenTabs] = useState<string[]>(
|
||||||
|
() => loadPersistedState().openTabs,
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>(
|
||||||
|
() => loadPersistedState().activeTab,
|
||||||
|
);
|
||||||
|
const [dirtyFiles, setDirtyFiles] = useState<Set<string>>(new Set());
|
||||||
|
const fileContents = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
persistState({ openTabs, activeTab });
|
||||||
|
}, [openTabs, activeTab]);
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
(path: string, content?: string) => {
|
||||||
|
if (content !== undefined) {
|
||||||
|
fileContents.current.set(path, content);
|
||||||
|
}
|
||||||
|
setOpenTabs((prev) => {
|
||||||
|
if (prev.includes(path)) return prev;
|
||||||
|
return [...prev, path];
|
||||||
|
});
|
||||||
|
setActiveTab(path);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeFile = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setOpenTabs((prev) => {
|
||||||
|
const next = prev.filter((p) => p !== path);
|
||||||
|
setActiveTab((current) => {
|
||||||
|
if (current !== path) return current;
|
||||||
|
const idx = prev.indexOf(path);
|
||||||
|
return next[Math.min(idx, next.length - 1)] ?? null;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirtyFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
fileContents.current.delete(path);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectTab = useCallback((path: string) => {
|
||||||
|
setActiveTab(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markDirty = useCallback((path: string) => {
|
||||||
|
setDirtyFiles((prev) => {
|
||||||
|
if (prev.has(path)) return prev;
|
||||||
|
return new Set(prev).add(path);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markClean = useCallback((path: string) => {
|
||||||
|
setDirtyFiles((prev) => {
|
||||||
|
if (!prev.has(path)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateContent = useCallback(
|
||||||
|
(path: string, content: string) => {
|
||||||
|
fileContents.current.set(path, content);
|
||||||
|
markDirty(path);
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getContent = useCallback((path: string): string | undefined => {
|
||||||
|
return fileContents.current.get(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openTabs,
|
||||||
|
activeTab,
|
||||||
|
dirtyFiles,
|
||||||
|
openFile,
|
||||||
|
closeFile,
|
||||||
|
selectTab,
|
||||||
|
markDirty,
|
||||||
|
markClean,
|
||||||
|
updateContent,
|
||||||
|
getContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { MonacoEditor } from "../components/editor/MonacoEditor";
|
||||||
|
import { EditorTabs } from "../components/editor/EditorTabs";
|
||||||
|
import { useEditorState } from "../hooks/useEditorState";
|
||||||
|
|
||||||
|
export function Editor() {
|
||||||
|
const {
|
||||||
|
openTabs,
|
||||||
|
activeTab,
|
||||||
|
dirtyFiles,
|
||||||
|
selectTab,
|
||||||
|
closeFile,
|
||||||
|
updateContent,
|
||||||
|
getContent,
|
||||||
|
} = useEditorState();
|
||||||
|
|
||||||
|
const tabs = useMemo(
|
||||||
|
() =>
|
||||||
|
openTabs.map((path: string) => ({
|
||||||
|
path,
|
||||||
|
dirty: dirtyFiles.has(path),
|
||||||
|
})),
|
||||||
|
[openTabs, dirtyFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeContent = activeTab ? (getContent(activeTab) ?? "") : "";
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (activeTab) {
|
||||||
|
updateContent(activeTab, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTab, updateContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||||
|
<EditorTabs
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onSelect={selectTab}
|
||||||
|
onClose={closeFile}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{activeTab ? (
|
||||||
|
<MonacoEditor
|
||||||
|
key={activeTab}
|
||||||
|
filePath={activeTab}
|
||||||
|
content={activeContent}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
|
||||||
|
Open a file from the File Explorer to start editing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user