From b84dff6d8cbbc3de1cb2c56d6ce605dee792b331 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 28 Feb 2026 16:49:15 +0100 Subject: [PATCH] File-Explorer: Editor editierbar + Speichern-Button --- Endpoints/WorkspaceEndpoint.cs | 53 +++++++++++++++++++++ wwwroot/css/file-explorer.css | 25 ++++++++++ wwwroot/js/file-explorer.js | 87 ++++++++++++++++++++++++++++++---- 3 files changed, 157 insertions(+), 8 deletions(-) diff --git a/Endpoints/WorkspaceEndpoint.cs b/Endpoints/WorkspaceEndpoint.cs index 421038d..72375ed 100644 --- a/Endpoints/WorkspaceEndpoint.cs +++ b/Endpoints/WorkspaceEndpoint.cs @@ -38,6 +38,7 @@ public static class WorkspaceEndpoint { app.MapGet("/api/workspace/tree", HandleTree); app.MapGet("/api/workspace/file", HandleFile); + app.MapPut("/api/workspace/file", HandleFileSave); app.MapGet("/api/workspace/browse", HandleBrowse); } @@ -158,6 +159,58 @@ public static class WorkspaceEndpoint }); } + private static async Task HandleFileSave(HttpContext ctx) + { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + var filePath = ctx.Request.Query["path"].FirstOrDefault(); + + if (string.IsNullOrEmpty(filePath)) + { + ctx.Response.StatusCode = 400; + return; + } + + var db = ctx.RequestServices.GetRequiredService(); + var session = await db.GetSession(sessionId ?? ""); + if (session == null || string.IsNullOrEmpty(session.Workspace)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" }); + return; + } + + var basePath = Path.GetFullPath(session.Workspace); + var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath)); + + if (!targetPath.StartsWith(basePath)) + { + ctx.Response.StatusCode = 403; + return; + } + + if (!File.Exists(targetPath)) + { + ctx.Response.StatusCode = 404; + return; + } + + using var reader = new StreamReader(ctx.Request.Body); + var content = await reader.ReadToEndAsync(); + + await File.WriteAllTextAsync(targetPath, content); + + var info = new FileInfo(targetPath); + var lineCount = content.Split('\n').Length; + + await ctx.Response.WriteAsJsonAsync(new + { + path = filePath, + name = info.Name, + size = info.Length, + lineCount + }); + } + private static async Task HandleBrowse(HttpContext ctx) { var path = ctx.Request.Query["path"].FirstOrDefault() ?? "/"; diff --git a/wwwroot/css/file-explorer.css b/wwwroot/css/file-explorer.css index f44bf6f..78940b0 100644 --- a/wwwroot/css/file-explorer.css +++ b/wwwroot/css/file-explorer.css @@ -116,6 +116,15 @@ height: 100%; } +.fe-cm-wrap > div { + display: flex; + flex: 1; + /* kein overflow:hidden – CodeMirror positioniert sein internes textarea + mit negativem Offset; overflow:hidden würde es clippen → kein Input */ + position: relative; + height: 100%; +} + .fe-mde-wrap .EasyMDEContainer { display: flex; flex: 1; @@ -261,3 +270,19 @@ flex: 1; } +.fe-footer-actions { + display: flex; + gap: .5em; + flex-shrink: 0; +} + +.fe-save-btn:not(:disabled) { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.fe-save-btn:not(:disabled):hover { + opacity: 0.85; +} + diff --git a/wwwroot/js/file-explorer.js b/wwwroot/js/file-explorer.js index 51fb767..ecaa13c 100644 --- a/wwwroot/js/file-explorer.js +++ b/wwwroot/js/file-explorer.js @@ -41,7 +41,10 @@ function openFileExplorer(opts = {}) { `; @@ -51,6 +54,7 @@ function openFileExplorer(opts = {}) { document.getElementById('feBack').addEventListener('click', feGoBack); document.getElementById('feClose').addEventListener('click', closeFileExplorer); document.getElementById('feSelectBtn').addEventListener('click', feDoSelect); + document.getElementById('feSaveBtn').addEventListener('click', feDoSave); // ESC _feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); }; @@ -220,6 +224,12 @@ async function feSelectFile(entry, itemEl) { } info.textContent = `${data.path} — ${data.lineCount} Zeilen, ${formatSize(data.size)}`; + + const saveBtn = document.getElementById('feSaveBtn'); + const selectBtn = document.getElementById('feSelectBtn'); + saveBtn.hidden = false; + saveBtn.disabled = true; + selectBtn.hidden = !_feState.onSelect; footer.hidden = false; } catch (e) { @@ -239,22 +249,25 @@ function feDestroyEditor() { } function feInitCodeMirror(container, data, ext) { - container.innerHTML = '
'; - - const textarea = document.getElementById('feCmTextarea'); - textarea.value = data.content; + container.innerHTML = '
'; + const host = document.getElementById('feCmHost'); const mode = getCmMode(ext); + const originalContent = data.content; - const cm = CodeMirror.fromTextArea(textarea, { + const cm = CodeMirror(host, { + value: data.content, mode: mode, theme: 'default', lineNumbers: true, - readOnly: true, + readOnly: false, lineWrapping: false, tabSize: 4, indentWithTabs: false, - extraKeys: {} + extraKeys: { + 'Ctrl-S': () => feDoSave(), + 'Cmd-S': () => feDoSave() + } }); // Selektion-Tracking für Zeilen-Info @@ -263,6 +276,15 @@ function feInitCodeMirror(container, data, ext) { feUpdateSelectionInfo(cm, data); }); + // Änderungs-Tracking + cm.on('change', () => { + const saveBtn = document.getElementById('feSaveBtn'); + if (saveBtn) saveBtn.disabled = cm.getValue() === originalContent; + }); + + // Refresh nötig wenn CM in einem Container ohne finale Größe initialisiert wird + requestAnimationFrame(() => cm.refresh()); + _feState._editor = cm; _feState._editorType = 'codemirror'; } @@ -374,6 +396,55 @@ function feDoSelect() { closeFileExplorer(); } +async function feDoSave() { + if (!_feState || !_feState.selectedFile) return; + + const cm = feGetCm(); + if (!cm) return; + + const saveBtn = document.getElementById('feSaveBtn'); + if (saveBtn && saveBtn.disabled) return; + + const content = cm.getValue(); + const path = _feState.selectedFile.path; + + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = 'Speichern...'; + } + + try { + const resp = await fetch( + `/api/workspace/file?sessionId=${_feState.sessionId}&path=${encodeURIComponent(path)}`, + { method: 'PUT', body: content } + ); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + alert('Fehler beim Speichern: ' + (err.error || resp.status)); + if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; } + return; + } + + const result = await resp.json(); + + // State aktualisieren + _feState.selectedFile.content = content; + _feState.selectedFile.size = result.size; + _feState.selectedFile.lineCount = result.lineCount; + + const info = document.getElementById('feInfo'); + if (info) info.textContent = `${path} — ${result.lineCount} Zeilen, ${formatSize(result.size)}`; + + if (saveBtn) { saveBtn.textContent = 'Speichern'; } + // bleibt disabled, da gespeicherter Stand == aktueller Stand + + } catch (e) { + alert('Netzwerk-Fehler beim Speichern'); + if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; } + } +} + // ── Folder Browser Mode ── // Nutzt /api/workspace/browse mit absolutem Pfad, zum Ordner-Auswählen.