Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 939a7fd191 | |||
| d12319392f |
@@ -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.
|
||||||
Binary file not shown.
@@ -18,6 +18,7 @@ TEXT_CONTENT_TYPES = {
|
|||||||
".yaml": "text/yaml",
|
".yaml": "text/yaml",
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
".js": "text/javascript",
|
".js": "text/javascript",
|
||||||
|
".py": "text/x-python",
|
||||||
".css": "text/css",
|
".css": "text/css",
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,6 +67,19 @@ class EditApiGoldenTest(unittest.TestCase):
|
|||||||
self.assertFalse(body["truncated"])
|
self.assertFalse(body["truncated"])
|
||||||
self.assertIn("modified", body)
|
self.assertIn("modified", body)
|
||||||
|
|
||||||
|
def test_edit_view_python_success(self) -> None:
|
||||||
|
file_path = self.root / "script.py"
|
||||||
|
file_path.write_text("print('hello')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._request("GET", "/api/files/view", params={"path": "storage1/script.py", "for_edit": "true"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["path"], "storage1/script.py")
|
||||||
|
self.assertEqual(body["name"], "script.py")
|
||||||
|
self.assertEqual(body["content_type"], "text/x-python")
|
||||||
|
self.assertEqual(body["content"], "print('hello')\n")
|
||||||
|
|
||||||
def test_save_success(self) -> None:
|
def test_save_success(self) -> None:
|
||||||
file_path = self.root / "notes.txt"
|
file_path = self.root / "notes.txt"
|
||||||
file_path.write_text("hello", encoding="utf-8")
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -156,8 +156,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function openRenamePopup()', app_js)
|
self.assertIn('function openRenamePopup()', app_js)
|
||||||
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
||||||
self.assertIn('return triggerActionButton("rename-btn");', app_js)
|
self.assertIn('return triggerActionButton("rename-btn");', app_js)
|
||||||
|
self.assertIn('".py"', 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 +191,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")
|
||||||
|
|||||||
+138
-11
@@ -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"),
|
||||||
@@ -552,7 +562,48 @@ function isEditableSelection(item) {
|
|||||||
if (lower === "dockerfile" || lower === "containerfile") {
|
if (lower === "dockerfile" || lower === "containerfile") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".css", ".html"].some((suffix) => lower.endsWith(suffix));
|
return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".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) {
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user