From 8e7bda50f9a339c447055f55e24b2feeb92a659f Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 5 Mar 2026 21:01:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Ordner=20erstellen/l=C3=B6schen=20im=20?= =?UTF-8?q?File=20Explorer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (WorkspaceEndpoint.cs): - POST /api/workspace/mkdir – Ordner im Session-Workspace erstellen - DELETE /api/workspace/dir – Leeren Ordner im Session-Workspace löschen - POST /api/workspace/agent/mkdir – Ordner im Agent-Memory erstellen - DELETE /api/workspace/agent/dir – Leeren Ordner im Agent-Memory löschen - POST /api/workspace/browse/mkdir – Ordner im Folder-Browser erstellen - Sicherheit: Path-Traversal-Schutz, Root nicht löschbar, nicht-leer → 409 Frontend (file-explorer.js): - 📁+ Button im Header für neuen Ordner (File Explorer + Folder Browser) - 🗑️ Button bei jedem Ordner (erscheint on hover, nur File Explorer) - Validierung: ungültige Zeichen werden abgelehnt CSS: - base.css: Icon-Button mit zwei Icons (folder + plus) - file-explorer.css: Ordner-Zeile als Flex (Label + Delete-Button) --- Endpoints/WorkspaceEndpoint.cs | 205 +++++++++++++++++++++++++++++++++ wwwroot/css/base.css | 7 ++ wwwroot/css/file-explorer.css | 27 +++++ wwwroot/js/file-explorer.js | 98 +++++++++++++++- 4 files changed, 335 insertions(+), 2 deletions(-) diff --git a/Endpoints/WorkspaceEndpoint.cs b/Endpoints/WorkspaceEndpoint.cs index ac3237a..c2899f0 100644 --- a/Endpoints/WorkspaceEndpoint.cs +++ b/Endpoints/WorkspaceEndpoint.cs @@ -41,10 +41,15 @@ public static class WorkspaceEndpoint app.MapGet("/api/workspace/file", HandleFile); app.MapPut("/api/workspace/file", HandleFileSave); app.MapGet("/api/workspace/browse", HandleBrowse); + app.MapPost("/api/workspace/browse/mkdir", HandleBrowseMkdir); app.MapGet("/api/workspace/agent/tree", HandleAgentTree); app.MapGet("/api/workspace/agent/file", HandleAgentFile); app.MapPut("/api/workspace/agent/file", HandleAgentFileSave); app.MapGet("/api/workspace/raw", HandleRawFile); + app.MapPost("/api/workspace/mkdir", HandleMkdir); + app.MapDelete("/api/workspace/dir", HandleRmdir); + app.MapPost("/api/workspace/agent/mkdir", HandleAgentMkdir); + app.MapDelete("/api/workspace/agent/dir", HandleAgentRmdir); } private static async Task HandleTree(HttpContext ctx) @@ -251,6 +256,38 @@ public static class WorkspaceEndpoint await ctx.Response.WriteAsJsonAsync(new { current = targetPath.Replace('\\', '/'), entries }); } + private static async Task HandleBrowseMkdir(HttpContext ctx) + { + var path = ctx.Request.Query["path"].FirstOrDefault(); + if (string.IsNullOrEmpty(path)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var targetPath = Path.GetFullPath(path); + + if (Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); + return; + } + + // Parent muss existieren + var parent = Path.GetDirectoryName(targetPath); + if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Elternverzeichnis nicht gefunden" }); + return; + } + + Directory.CreateDirectory(targetPath); + await ctx.Response.WriteAsJsonAsync(new { path = targetPath.Replace('\\', '/'), created = true }); + } + private static bool IsLikelyTextFile(string name) { var lower = name.ToLowerInvariant(); @@ -424,6 +461,174 @@ public static class WorkspaceEndpoint }); } + private static async Task HandleMkdir(HttpContext ctx) + { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + var dirPath = ctx.Request.Query["path"].FirstOrDefault(); + + if (string.IsNullOrEmpty(dirPath)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var db = ctx.RequestServices.GetRequiredService(); + var session = await db.GetSession(sessionId ?? ""); + if (session == null || session.UserId != ctx.GetUserId() || 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, dirPath)); + + if (!targetPath.StartsWith(basePath)) + { + ctx.Response.StatusCode = 403; + return; + } + + if (Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); + return; + } + + Directory.CreateDirectory(targetPath); + await ctx.Response.WriteAsJsonAsync(new { path = dirPath, created = true }); + } + + private static async Task HandleRmdir(HttpContext ctx) + { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + var dirPath = ctx.Request.Query["path"].FirstOrDefault(); + + if (string.IsNullOrEmpty(dirPath)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var db = ctx.RequestServices.GetRequiredService(); + var session = await db.GetSession(sessionId ?? ""); + if (session == null || session.UserId != ctx.GetUserId() || 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, dirPath)); + + if (!targetPath.StartsWith(basePath) || targetPath == basePath) + { + ctx.Response.StatusCode = 403; + return; + } + + if (!Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner nicht gefunden" }); + return; + } + + if (Directory.GetFileSystemEntries(targetPath).Length > 0) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner ist nicht leer" }); + return; + } + + Directory.Delete(targetPath); + await ctx.Response.WriteAsJsonAsync(new { path = dirPath, deleted = true }); + } + + private static async Task HandleAgentMkdir(HttpContext ctx) + { + var basePath = GetAgentBasePath(ctx, out var error); + if (basePath == null) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error }); + return; + } + + var dirPath = ctx.Request.Query["path"].FirstOrDefault(); + if (string.IsNullOrEmpty(dirPath)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); + if (!targetPath.StartsWith(basePath)) + { + ctx.Response.StatusCode = 403; + return; + } + + if (Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" }); + return; + } + + Directory.CreateDirectory(targetPath); + await ctx.Response.WriteAsJsonAsync(new { path = dirPath, created = true }); + } + + private static async Task HandleAgentRmdir(HttpContext ctx) + { + var basePath = GetAgentBasePath(ctx, out var error); + if (basePath == null) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error }); + return; + } + + var dirPath = ctx.Request.Query["path"].FirstOrDefault(); + if (string.IsNullOrEmpty(dirPath)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" }); + return; + } + + var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath)); + if (!targetPath.StartsWith(basePath) || targetPath == basePath) + { + ctx.Response.StatusCode = 403; + return; + } + + if (!Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner nicht gefunden" }); + return; + } + + if (Directory.GetFileSystemEntries(targetPath).Length > 0) + { + ctx.Response.StatusCode = 409; + await ctx.Response.WriteAsJsonAsync(new { error = "Ordner ist nicht leer" }); + return; + } + + Directory.Delete(targetPath); + await ctx.Response.WriteAsJsonAsync(new { path = dirPath, deleted = true }); + } + private static readonly Dictionary MimeTypes = new(StringComparer.OrdinalIgnoreCase) { { ".png", "image/png" }, { ".jpg", "image/jpeg" }, { ".jpeg", "image/jpeg" }, diff --git a/wwwroot/css/base.css b/wwwroot/css/base.css index 8441814..7bdc7da 100644 --- a/wwwroot/css/base.css +++ b/wwwroot/css/base.css @@ -81,6 +81,13 @@ input { flex-shrink: 0; } +.icon-btn:has(i + i) { + width: 3em; + height: 2em; + aspect-ratio: unset; + margin-right: var(--badge-spacing); +} + button.icon-btn, button.icon-btn:hover { border-color: transparent; diff --git a/wwwroot/css/file-explorer.css b/wwwroot/css/file-explorer.css index be37655..67723e5 100644 --- a/wwwroot/css/file-explorer.css +++ b/wwwroot/css/file-explorer.css @@ -92,6 +92,33 @@ .fe-dir { color: var(--text); + display: flex; + align-items: center; + justify-content: space-between; +} + +.fe-dir-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.fe-dir-del { + opacity: 0; + color: var(--text) !important; + padding: 2px 6px !important; + font-size: .8em; + transition: opacity .15s; + flex-shrink: 0; +} + +.fe-dir:hover .fe-dir-del { + opacity: var(--semi-transparent); +} + +.fe-dir-del:hover { + opacity: 1 !important; + color: var(--error) !important; } .fe-file { diff --git a/wwwroot/js/file-explorer.js b/wwwroot/js/file-explorer.js index 1012ade..5c462be 100644 --- a/wwwroot/js/file-explorer.js +++ b/wwwroot/js/file-explorer.js @@ -37,6 +37,7 @@ function openFileExplorer(opts = {}) { / +
@@ -60,6 +61,7 @@ function openFileExplorer(opts = {}) { document.body.appendChild(overlay); document.getElementById('feBack').addEventListener('click', feGoBack); + document.getElementById('feNewDir').addEventListener('click', feCreateDir); document.getElementById('feClose').addEventListener('click', closeFileExplorer); document.getElementById('feSelectBtn').addEventListener('click', feDoSelect); document.getElementById('feSaveBtn').addEventListener('click', feDoSave); @@ -138,11 +140,24 @@ async function feLoadDir(path) { if (entry.type === 'dir') { item.classList.add('fe-dir'); - item.innerHTML = ' ' + esc(entry.name); - item.addEventListener('click', () => { + const label = document.createElement('span'); + label.className = 'fe-dir-label'; + label.innerHTML = ' ' + esc(entry.name); + label.addEventListener('click', () => { _feState.history.push(path); feLoadDir(entry.path); }); + item.appendChild(label); + + const delBtn = document.createElement('button'); + delBtn.className = 'fe-btn fe-dir-del'; + delBtn.title = 'Ordner löschen (nur wenn leer)'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + feDeleteDir(entry.path, entry.name); + }); + item.appendChild(delBtn); } else { item.classList.add('fe-file'); const icon = entry.isText ? '' : getFileIcon(entry.ext); @@ -505,6 +520,7 @@ function openFolderBrowser(opts = {}) { /
+
@@ -522,6 +538,7 @@ function openFolderBrowser(opts = {}) { document.body.appendChild(overlay); document.getElementById('feBack').addEventListener('click', fbGoBack); + document.getElementById('feNewDir').addEventListener('click', fbCreateDir); document.getElementById('feClose').addEventListener('click', closeFileExplorer); document.getElementById('feSelectBtn').addEventListener('click', () => { if (_feState && _feState.onSelect) _feState.onSelect(_feState.currentAbsPath); @@ -587,6 +604,83 @@ function fbGoBack() { fbLoadDir(prev); } +// ── Folder Browser: Create Directory ── + +async function fbCreateDir() { + if (!_feState) return; + + const name = prompt('Ordnername:'); + if (!name || !name.trim()) return; + + const dirName = name.trim(); + if (/[\/\\:*?"<>|]/.test(dirName)) { + alert('Ungültiger Ordnername'); + return; + } + + const absPath = _feState.currentAbsPath.replace(/\/$/, '') + '/' + dirName; + + try { + const resp = await fetch(`/api/workspace/browse/mkdir?path=${encodeURIComponent(absPath)}`, { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + alert(err.error || 'Fehler beim Erstellen'); + return; + } + fbLoadDir(_feState.currentAbsPath); + } catch (e) { + alert('Netzwerk-Fehler'); + } +} + +// ── File Explorer: Create / Delete Directory ── + +async function feCreateDir() { + if (!_feState) return; + + const name = prompt('Ordnername:'); + if (!name || !name.trim()) return; + + const dirName = name.trim(); + if (/[\/\\:*?"<>|]/.test(dirName)) { + alert('Ungültiger Ordnername'); + return; + } + + const newPath = _feState.currentPath ? _feState.currentPath + '/' + dirName : dirName; + + try { + const url = _feApiUrl('mkdir', `path=${encodeURIComponent(newPath)}`); + const resp = await fetch(url, { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + alert(err.error || 'Fehler beim Erstellen'); + return; + } + feLoadDir(_feState.currentPath); + } catch (e) { + alert('Netzwerk-Fehler'); + } +} + +async function feDeleteDir(dirPath, dirName) { + if (!_feState) return; + if (!confirm(`Ordner "${dirName}" löschen?\n(Nur möglich wenn leer)`)) return; + + try { + const url = _feApiUrl('dir', `path=${encodeURIComponent(dirPath)}`); + const resp = await fetch(url, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + alert(err.error || 'Fehler beim Löschen'); + return; + } + feLoadDir(_feState.currentPath); + } catch (e) { + alert('Netzwerk-Fehler'); + } +} + // ── Helpers ── function getFileIcon(ext) {