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
@@ -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")
+137 -10
View File
@@ -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();
+2 -2
View File
@@ -226,13 +226,13 @@
</div>
<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>
<h3 id="editor-title">Edit</h3>
<div id="editor-file-name" class="popup-meta"></div>
<div id="editor-file-path" class="popup-meta"></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">
<button id="editor-save-btn" type="button">Save</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;
}
#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 {
font-size: 12px;
color: var(--color-text-muted);
@@ -625,6 +638,11 @@ button:disabled {
flex-direction: column;
}
.editor-card {
width: min(1180px, calc(100vw - 28px));
max-height: calc(100vh - 24px);
}
.video-player {
width: 100%;
max-height: calc(100vh - 180px);
@@ -802,7 +820,7 @@ button:disabled {
}
.viewer-content,
.editor-content {
.editor-host {
margin: 6px 0 0 0;
padding: 11px;
overflow: auto;
@@ -821,13 +839,17 @@ button:disabled {
user-select: text;
}
.editor-content {
.editor-host {
margin-bottom: 8px;
min-height: 280px;
max-height: calc(100vh - 220px);
min-height: 520px;
height: calc(100vh - 220px);
width: 100%;
resize: vertical;
white-space: pre;
overflow: hidden;
padding: 0;
}
.editor-host .monaco-editor {
border-radius: calc(var(--radius-sm) - 1px);
}
@media (max-width: 1200px) {