From d12319392f2929c856e30d2c55bba9c81b907946 Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 12 Mar 2026 17:13:40 +0100 Subject: [PATCH] feat: monaco editor toegevoegd --- project_docs/MONACO_EDITOR_V1_DESIGN.md | 178 ++++++++++++++++++ .../test_ui_smoke_golden.cpython-313.pyc | Bin 14516 -> 15019 bytes .../tests/golden/test_ui_smoke_golden.py | 8 +- webui/html/app.js | 147 ++++++++++++++- webui/html/index.html | 4 +- webui/html/style.css | 34 +++- 6 files changed, 352 insertions(+), 19 deletions(-) create mode 100644 project_docs/MONACO_EDITOR_V1_DESIGN.md diff --git a/project_docs/MONACO_EDITOR_V1_DESIGN.md b/project_docs/MONACO_EDITOR_V1_DESIGN.md new file mode 100644 index 0000000..a6caf45 --- /dev/null +++ b/project_docs/MONACO_EDITOR_V1_DESIGN.md @@ -0,0 +1,178 @@ +# Monaco Editor v1 + +## 1. Doel +Monaco voegt nu waarde toe omdat de huidige textarea-editor functioneel is, maar beperkt blijft voor code- en configbestanden. Voor deze app is de winst vooral: betere leesbaarheid, syntax highlighting, line numbers, current-line focus en een meer betrouwbare editervaring voor technische bestanden. + +Dit past naast de bestaande text editor modal als een vervanging van de edit-UI, niet als een nieuwe backendflow. De bestaande backend read/save-flow bestaat al en moet ongewijzigd worden hergebruikt: +- initial read via de bestaande edit/view-flow +- save via het bestaande save-endpoint +- bestaande conflict- en io_error-semantiek blijven leidend + +Het doel is een "VS Code light"-achtige ervaring voor 1 bestand tegelijk, zonder IDE-verbreding. + +## 2. Scope +In scope voor v1: +- Monaco Editor in een modal +- F4 opent Monaco voor ondersteunde tekst/codebestanden +- syntax highlighting +- line numbers +- current line highlight +- save via bestaande save-flow +- dirty state behouden +- conflict handling behouden +- light/dark theme-integratie + +Niet in scope voor v1: +- LSP +- autocomplete op IDE-niveau +- multi-tab editor +- diff editor +- command palette +- file tree in de modal +- symbol outline +- search panel met geavanceerde editorfeatures +- plugin- of extensionmodel + +## 3. Integratierichting +Aanbevolen route: moderne browser-side Monaco-integratie met de officiële ESM-distributie van `monaco-editor`, gebundeld als statische frontend-assets voor deze app. + +Aanbevolen technische richting: +- Monaco alleen lazy loaden wanneer de edit modal voor het eerst opent +- editor-instance per modal-open lifecycle aanmaken +- bij sluiten altijd `editor.dispose()` en eventuele model cleanup uitvoeren +- per geopend bestand 1 model tegelijk gebruiken +- bestaande modal DOM behouden, maar de textarea vervangen door een editor host-container + +Waarom deze route: +- sluit aan op de huidige lichte frontend zonder volledige frameworkmigratie +- voorkomt dat Monaco initieel alle schermen vertraagt +- houdt lifecycle beheersbaar + +Belangrijke lifecycle-regels: +- editor pas initialiseren nadat modal zichtbaar is en afmetingen stabiel zijn +- bij heropenen van een ander bestand geen oude editor hergebruiken zonder expliciete reset +- model en listeners bij sluiten expliciet opruimen +- resize van modal/viewport koppelen aan `editor.layout()` zolang modal open is + +## 4. Taalondersteuning +Minimale extensie/bestandsnaammapping voor v1: +- `js`, `mjs`, `cjs` -> `javascript` +- `ts`, `tsx` -> `typescript` +- `json` -> `json` +- `css` -> `css` +- `html`, `htm` -> `html` +- `xml` -> `xml` +- `yml`, `yaml` -> `yaml` +- `py` -> `python` +- `sh`, `bash`, `zsh`, `fish` -> `shell` +- `md`, `markdown` -> `markdown` +- `txt`, `log`, `ini`, `cfg`, `conf` -> `plaintext` +- `Dockerfile`, `Containerfile` -> `dockerfile` + +V1-verwachting per taal: +- syntax highlighting voor alle bovenstaande talen +- bracket matching / basic Monaco editor behavior waar Monaco dat standaard biedt +- geen extra taalservices of projectintelligentie buiten wat Monaco standaard client-side levert + +Pragmatische keuze: +- v1 focust op highlighting en nette editing +- rijkere taalfeatures zijn een bijeffect van Monaco waar beschikbaar, maar geen contract + +## 5. Theme-integratie +Monaco moet aansluiten op de bestaande light/dark mode. + +Aanbevolen v1-richting: +- dark mode -> Monaco `vs-dark` +- light mode -> Monaco `vs` +- alleen een custom Monaco theme toevoegen als kleurafwijking zichtbaar storend is + +Dit houdt regressierisico laag. De rest van de UI blijft leidend; Monaco hoeft in v1 niet pixel-perfect het app-thema te kopieren, zolang het visueel coherent is. + +## 6. Modal-UX +Aanbevolen editor-modal: +- groot, bijna viewport-vullend +- duidelijk groter dan de huidige eenvoudige edit-modal +- behoud van titelbalk met bestandsnaam/pad +- behoud van `Save`, `Cancel` en `X` + +Gedrag: +- `Save` gebruikt bestaande save-flow +- `Cancel` sluit alleen zonder prompt als niet dirty +- `X` volgt hetzelfde gedrag als `Cancel` +- `Escape`: + - zonder wijzigingen -> sluit + - met wijzigingen -> bestaande discard-waarschuwing behouden + +Keyboard/focus: +- terwijl Monaco open is, mogen paneelshortcuts niet ingrijpen +- editor krijgt focus direct na openen +- modal blijft de enige actieve interactiezone totdat hij sluit + +## 7. Regressiebehoud +Moet functioneel behouden blijven: +- bestaande F4 edit-semantiek +- bestaande backend save/conflict-flow +- bestaande optimistic locking / expected_modified gedrag +- bestaande dirty-state logica +- bestaande text/video/pdf viewers +- bestaande browse- en paneelworkflow buiten de modal + +Niet doen in v1: +- backend save-contract wijzigen +- nieuwe backend editorfeatures toevoegen +- bestaande foutcodes of response-shapes wijzigen + +## 8. Performance en risico +Belangrijkste risico's: +- Monaco vergroot frontend bundle/load +- editor initialisatie is zwaarder dan textarea +- foutieve dispose kan memory leaks geven +- taalbundels kunnen groter zijn dan nodig + +Laag-risico aanpak: +- lazy load alleen bij eerste edit-open +- alleen de noodzakelijke Monaco assets shippen +- geen extra taalplugins of worker-uitbreidingen toevoegen buiten wat nodig is +- editor instance altijd disposer'en bij sluiten + +Reële tradeoff: +- Monaco is zwaarder dan strikt nodig voor een kleine file manager +- maar de UX-winst voor code/config editing is groot genoeg als de integratie sober blijft + +## 9. Teststrategie +UI smoke/regressietests: +- Monaco host-container aanwezig in edit modal +- F4 wiring blijft aanwezig +- `Save`, `Cancel`, `X` blijven aanwezig +- bestaande edit modalflow blijft openen/sluiten +- light/dark theme blijft editor-opening niet breken + +Handmatige validatie: +- openen van ondersteund codebestand via F4 +- syntax highlighting voor representatieve typen: + - js + - json + - yaml + - py + - markdown + - Dockerfile +- save success +- save conflict +- cancel/escape met dirty state +- herhaald open/close van editor zonder UI-vertraging of kapotte focus +- switch tussen light/dark terwijl editor open of dicht is + +## 10. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- vervang de huidige textarea-editor UI door een Monaco-modal +- hergebruik ongewijzigd de bestaande backend read/save-flow +- beperk v1 tot syntax highlighting, line numbers, current line highlight en nette modal-UX +- gebruik Monaco lazy-loaded en dispose correct bij sluiten +- gebruik standaard `vs` / `vs-dark` thema's tenzij er een duidelijke mismatch blijkt + +Niet proberen in v1: +- IDE-features najagen +- extra backendsemantiek toevoegen +- editorflow verbreden naar meerdere bestanden of workspaceconcepten + +De juiste lat voor v1 is: betere editor-ergonomie zonder de app architectonisch zwaarder te maken dan nodig. diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 7e9d6db52ec8ea95cb635a36404f1b7bf23fbbec..42bd940d43f91cf7c1c78e8f0b0a6272759c5ae5 100644 GIT binary patch delta 554 zcmdl|xVn_@GcPX}0}xz$v?=qu#zwv*VSa(k6kDa#l+2R+BHfJq;>}IMF>I3~B={$r zOYv|1BiX^oWX3a@k40tkD(TZ4lkduiP1fZVn0#MGgwbyD1sUbZr4ZianY?_HOI55U z+pE5qykA9Rvb7rTs3sB}eM4C$*U2j~`c1Y`(`EG!4hRmM zoTsMB=^q>v9B9du$2R$Znm99SaPZ^@dOdzK-FeosjGiZixexWAHR? zT$z`wkXD+PT#}ier;wAMnBtqCmzbRIo1apeld7RvWq_t6HLtj|C>5f}73^<~g2a*x z9fjolypq(s63r?(xN#|&#Rd7rsEReK%rZ(!3W}}t^^%Zl{!QcLvn z3Uc*x!S?Gy!qmY)&(uiINMAd(ICt|Oop@$x9zAGiCnpxARKa<`urJvhWe~y0q{+H@ zi{T9>6U!ZDS2&z*aPai=cJf{j^Ejb)g(DEoHQT{!*E8X3oM(1jBhY)PB&Y@xLMnRm66eZv!SIIqew9mqvZ^a>k^t5B{aV< NfOsErCih#(006c{LwNuI diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index a8cd77c..f1f4144 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -76,7 +76,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) - self.assertIn('id="editor-content"', body) + self.assertIn('id="editor-host"', body) self.assertIn('id="editor-save-btn"', body) self.assertIn('id="editor-cancel-btn"', body) self.assertIn('id="move-popup"', body) @@ -158,6 +158,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('return triggerActionButton("rename-btn");', app_js) self.assertIn('function openVideoViewer()', app_js) self.assertIn('function openPdfViewer()', app_js) + self.assertIn('async function loadMonacoModule()', app_js) + self.assertIn('async function ensureMonacoEditor(path, content)', app_js) + self.assertIn('function disposeMonacoEditor()', app_js) + self.assertIn('https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm', app_js) self.assertIn('document.getElementById("pdf-modal")', app_js) self.assertIn("`/api/files/pdf?", app_js) self.assertIn('if (isPdfSelection(selected)) {', app_js) @@ -186,6 +190,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('.entry-media-icon.pdf', style_css) self.assertIn('.entry-media-svg', style_css) self.assertIn('.entry-media-icon.file', style_css) + self.assertIn('.editor-card', style_css) + self.assertIn('.editor-host', style_css) self.assertNotIn('.select-marker', style_css) app_js_url = app.url_path_for("ui", path="/app.js") diff --git a/webui/html/app.js b/webui/html/app.js index 228cefa..4674aa3 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -32,6 +32,13 @@ let editorState = { originalContent: "", modified: null, }; +let monacoState = { + module: null, + loadPromise: null, + editor: null, + model: null, + resizeHandler: null, +}; let moveState = { source: null, destination: "", @@ -81,6 +88,9 @@ function applyTheme(theme) { button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); } + if (monacoState.module) { + monacoState.module.editor.setTheme(nextTheme === "light" ? "vs" : "vs-dark"); + } } function toggleTheme() { @@ -143,7 +153,7 @@ function editorElements() { fileName: document.getElementById("editor-file-name"), filePath: document.getElementById("editor-file-path"), error: document.getElementById("editor-error"), - content: document.getElementById("editor-content"), + host: document.getElementById("editor-host"), closeButton: document.getElementById("editor-close-btn"), saveButton: document.getElementById("editor-save-btn"), cancelButton: document.getElementById("editor-cancel-btn"), @@ -555,6 +565,47 @@ function isEditableSelection(item) { return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".css", ".html"].some((suffix) => lower.endsWith(suffix)); } +function monacoLanguageForName(name) { + const lower = (name || "").toLowerCase(); + if (lower === "dockerfile" || lower === "containerfile") { + return "dockerfile"; + } + if ([".js", ".mjs", ".cjs"].some((suffix) => lower.endsWith(suffix))) { + return "javascript"; + } + if ([".ts", ".tsx"].some((suffix) => lower.endsWith(suffix))) { + return "typescript"; + } + if (lower.endsWith(".json")) { + return "json"; + } + if (lower.endsWith(".css")) { + return "css"; + } + if ([".html", ".htm"].some((suffix) => lower.endsWith(suffix))) { + return "html"; + } + if (lower.endsWith(".xml")) { + return "xml"; + } + if ([".yml", ".yaml"].some((suffix) => lower.endsWith(suffix))) { + return "yaml"; + } + if (lower.endsWith(".py")) { + return "python"; + } + if ([".sh", ".bash", ".zsh", ".fish"].some((suffix) => lower.endsWith(suffix))) { + return "shell"; + } + if ([".md", ".markdown"].some((suffix) => lower.endsWith(suffix))) { + return "markdown"; + } + if ([".txt", ".log", ".ini", ".cfg", ".conf"].some((suffix) => lower.endsWith(suffix))) { + return "plaintext"; + } + return "plaintext"; +} + function isVideoSelection(item) { if (!item || item.kind !== "file") { return false; @@ -1264,6 +1315,82 @@ function isEditorOpen() { return !editorElements().overlay.classList.contains("hidden"); } +async function loadMonacoModule() { + if (monacoState.module) { + return monacoState.module; + } + if (!monacoState.loadPromise) { + monacoState.loadPromise = import("https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm") + .then((module) => { + monacoState.module = module; + module.editor.setTheme(document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark"); + return module; + }); + } + return monacoState.loadPromise; +} + +function disposeMonacoEditor() { + if (typeof monacoState.resizeHandler === "function") { + window.removeEventListener("resize", monacoState.resizeHandler); + monacoState.resizeHandler = null; + } + if (monacoState.editor) { + monacoState.editor.dispose(); + monacoState.editor = null; + } + if (monacoState.model) { + monacoState.model.dispose(); + monacoState.model = null; + } +} + +function nextAnimationFrame() { + return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); +} + +async function ensureMonacoEditor(path, content) { + const editor = editorElements(); + const monaco = await loadMonacoModule(); + await nextAnimationFrame(); + await nextAnimationFrame(); + disposeMonacoEditor(); + editor.host.textContent = ""; + const model = monaco.editor.createModel(content, monacoLanguageForName(baseName(path))); + const instance = monaco.editor.create(editor.host, { + model, + theme: document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark", + language: monacoLanguageForName(baseName(path)), + automaticLayout: false, + lineNumbers: "on", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderLineHighlight: "line", + wordWrap: "on", + fontSize: 13, + roundedSelection: false, + readOnly: false, + }); + const resizeHandler = () => { + if (monacoState.editor) { + monacoState.editor.layout(); + } + }; + window.addEventListener("resize", resizeHandler); + monacoState.model = model; + monacoState.editor = instance; + monacoState.resizeHandler = resizeHandler; + instance.layout(); + instance.focus(); +} + +function currentEditorContent() { + if (monacoState.editor) { + return monacoState.editor.getValue(); + } + return ""; +} + function escapeRegExp(text) { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } @@ -1854,7 +1981,7 @@ async function openSettings(tab = "general") { } function editorIsDirty() { - return editorElements().content.value !== editorState.originalContent; + return currentEditorContent() !== editorState.originalContent; } function resetEditorState() { @@ -1874,9 +2001,10 @@ function attemptCloseEditor() { function closeEditor() { const editor = editorElements(); + disposeMonacoEditor(); editor.overlay.classList.add("hidden"); editor.error.textContent = ""; - editor.content.value = ""; + editor.host.textContent = ""; resetEditorState(); } @@ -1962,21 +2090,20 @@ async function openEditor() { editor.fileName.textContent = selected.name; editor.filePath.textContent = selected.path; editor.error.textContent = ""; - editor.content.value = ""; - editor.content.disabled = true; + editor.host.textContent = "Loading editor..."; editor.saveButton.disabled = true; try { const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`); editor.fileName.textContent = data.name; editor.filePath.textContent = data.path; - editor.content.value = data.content; - editor.content.disabled = false; + await ensureMonacoEditor(data.path, data.content); editor.saveButton.disabled = false; editorState.path = data.path; editorState.originalContent = data.content; editorState.modified = data.modified; - editor.content.focus(); } catch (err) { + disposeMonacoEditor(); + editor.host.textContent = ""; editor.error.textContent = err.message; } } @@ -2007,10 +2134,10 @@ async function saveEditor() { try { const response = await apiRequest("POST", "/api/files/save", { path: editorState.path, - content: editor.content.value, + content: currentEditorContent(), expected_modified: editorState.modified, }); - editorState.originalContent = editor.content.value; + editorState.originalContent = currentEditorContent(); editorState.modified = response.modified; setStatus(`Saved ${response.path}`); closeEditor(); diff --git a/webui/html/index.html b/webui/html/index.html index 2dc32e2..5df2549 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -226,13 +226,13 @@