feat: monaco editor toegevoegd

This commit is contained in:
kodi
2026-03-12 17:13:40 +01:00
parent aac84a0a7f
commit d12319392f
6 changed files with 352 additions and 19 deletions
+178
View File
@@ -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.
@@ -76,7 +76,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="settings-logs-list"', body) self.assertIn('id="settings-logs-list"', body)
self.assertIn('id="viewer-content"', body) self.assertIn('id="viewer-content"', body)
self.assertIn('id="editor-modal"', 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-save-btn"', body)
self.assertIn('id="editor-cancel-btn"', body) self.assertIn('id="editor-cancel-btn"', body)
self.assertIn('id="move-popup"', 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('return triggerActionButton("rename-btn");', app_js)
self.assertIn('function openVideoViewer()', app_js) self.assertIn('function openVideoViewer()', app_js)
self.assertIn('function openPdfViewer()', 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('document.getElementById("pdf-modal")', app_js)
self.assertIn("`/api/files/pdf?", app_js) self.assertIn("`/api/files/pdf?", app_js)
self.assertIn('if (isPdfSelection(selected)) {', 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-icon.pdf', style_css)
self.assertIn('.entry-media-svg', style_css) self.assertIn('.entry-media-svg', style_css)
self.assertIn('.entry-media-icon.file', 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) self.assertNotIn('.select-marker', style_css)
app_js_url = app.url_path_for("ui", path="/app.js") app_js_url = app.url_path_for("ui", path="/app.js")
+137 -10
View File
@@ -32,6 +32,13 @@ let editorState = {
originalContent: "", originalContent: "",
modified: null, modified: null,
}; };
let monacoState = {
module: null,
loadPromise: null,
editor: null,
model: null,
resizeHandler: null,
};
let moveState = { let moveState = {
source: null, source: null,
destination: "", destination: "",
@@ -81,6 +88,9 @@ function applyTheme(theme) {
button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`);
button.setAttribute("title", `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() { function toggleTheme() {
@@ -143,7 +153,7 @@ function editorElements() {
fileName: document.getElementById("editor-file-name"), fileName: document.getElementById("editor-file-name"),
filePath: document.getElementById("editor-file-path"), filePath: document.getElementById("editor-file-path"),
error: document.getElementById("editor-error"), error: document.getElementById("editor-error"),
content: document.getElementById("editor-content"), host: document.getElementById("editor-host"),
closeButton: document.getElementById("editor-close-btn"), closeButton: document.getElementById("editor-close-btn"),
saveButton: document.getElementById("editor-save-btn"), saveButton: document.getElementById("editor-save-btn"),
cancelButton: document.getElementById("editor-cancel-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)); 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) { function isVideoSelection(item) {
if (!item || item.kind !== "file") { if (!item || item.kind !== "file") {
return false; return false;
@@ -1264,6 +1315,82 @@ function isEditorOpen() {
return !editorElements().overlay.classList.contains("hidden"); 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) { function escapeRegExp(text) {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
} }
@@ -1854,7 +1981,7 @@ async function openSettings(tab = "general") {
} }
function editorIsDirty() { function editorIsDirty() {
return editorElements().content.value !== editorState.originalContent; return currentEditorContent() !== editorState.originalContent;
} }
function resetEditorState() { function resetEditorState() {
@@ -1874,9 +2001,10 @@ function attemptCloseEditor() {
function closeEditor() { function closeEditor() {
const editor = editorElements(); const editor = editorElements();
disposeMonacoEditor();
editor.overlay.classList.add("hidden"); editor.overlay.classList.add("hidden");
editor.error.textContent = ""; editor.error.textContent = "";
editor.content.value = ""; editor.host.textContent = "";
resetEditorState(); resetEditorState();
} }
@@ -1962,21 +2090,20 @@ async function openEditor() {
editor.fileName.textContent = selected.name; editor.fileName.textContent = selected.name;
editor.filePath.textContent = selected.path; editor.filePath.textContent = selected.path;
editor.error.textContent = ""; editor.error.textContent = "";
editor.content.value = ""; editor.host.textContent = "Loading editor...";
editor.content.disabled = true;
editor.saveButton.disabled = true; editor.saveButton.disabled = true;
try { try {
const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`); const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`);
editor.fileName.textContent = data.name; editor.fileName.textContent = data.name;
editor.filePath.textContent = data.path; editor.filePath.textContent = data.path;
editor.content.value = data.content; await ensureMonacoEditor(data.path, data.content);
editor.content.disabled = false;
editor.saveButton.disabled = false; editor.saveButton.disabled = false;
editorState.path = data.path; editorState.path = data.path;
editorState.originalContent = data.content; editorState.originalContent = data.content;
editorState.modified = data.modified; editorState.modified = data.modified;
editor.content.focus();
} catch (err) { } catch (err) {
disposeMonacoEditor();
editor.host.textContent = "";
editor.error.textContent = err.message; editor.error.textContent = err.message;
} }
} }
@@ -2007,10 +2134,10 @@ async function saveEditor() {
try { try {
const response = await apiRequest("POST", "/api/files/save", { const response = await apiRequest("POST", "/api/files/save", {
path: editorState.path, path: editorState.path,
content: editor.content.value, content: currentEditorContent(),
expected_modified: editorState.modified, expected_modified: editorState.modified,
}); });
editorState.originalContent = editor.content.value; editorState.originalContent = currentEditorContent();
editorState.modified = response.modified; editorState.modified = response.modified;
setStatus(`Saved ${response.path}`); setStatus(`Saved ${response.path}`);
closeEditor(); closeEditor();
+2 -2
View File
@@ -226,13 +226,13 @@
</div> </div>
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title"> <div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<div class="popup-card viewer-card"> <div class="popup-card viewer-card editor-card">
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button> <button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
<h3 id="editor-title">Edit</h3> <h3 id="editor-title">Edit</h3>
<div id="editor-file-name" class="popup-meta"></div> <div id="editor-file-name" class="popup-meta"></div>
<div id="editor-file-path" class="popup-meta"></div> <div id="editor-file-path" class="popup-meta"></div>
<div id="editor-error" class="error"></div> <div id="editor-error" class="error"></div>
<textarea id="editor-content" class="editor-content" spellcheck="false"></textarea> <div id="editor-host" class="editor-host" aria-label="Code editor"></div>
<div class="popup-actions"> <div class="popup-actions">
<button id="editor-save-btn" type="button">Save</button> <button id="editor-save-btn" type="button">Save</button>
<button id="editor-cancel-btn" type="button">Cancel</button> <button id="editor-cancel-btn" type="button">Cancel</button>
+28 -6
View File
@@ -587,6 +587,19 @@ button:disabled {
margin: 4px 0 8px 0; margin: 4px 0 8px 0;
} }
#editor-file-name {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 600;
margin-top: 8px;
margin-bottom: 2px;
}
#editor-file-path {
margin-top: 0;
margin-bottom: 8px;
}
.popup-label { .popup-label {
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -625,6 +638,11 @@ button:disabled {
flex-direction: column; flex-direction: column;
} }
.editor-card {
width: min(1180px, calc(100vw - 28px));
max-height: calc(100vh - 24px);
}
.video-player { .video-player {
width: 100%; width: 100%;
max-height: calc(100vh - 180px); max-height: calc(100vh - 180px);
@@ -802,7 +820,7 @@ button:disabled {
} }
.viewer-content, .viewer-content,
.editor-content { .editor-host {
margin: 6px 0 0 0; margin: 6px 0 0 0;
padding: 11px; padding: 11px;
overflow: auto; overflow: auto;
@@ -821,13 +839,17 @@ button:disabled {
user-select: text; user-select: text;
} }
.editor-content { .editor-host {
margin-bottom: 8px; margin-bottom: 8px;
min-height: 280px; min-height: 520px;
max-height: calc(100vh - 220px); height: calc(100vh - 220px);
width: 100%; width: 100%;
resize: vertical; overflow: hidden;
white-space: pre; padding: 0;
}
.editor-host .monaco-editor {
border-radius: calc(var(--radius-sm) - 1px);
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {