From 491d3b916e57b06f3f7e31732cf943f439308e0f Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 27 Feb 2026 08:57:35 +0100 Subject: [PATCH] File Explorer Modal: Workspace-Browser mit Textvorschau, Zeilennummern, Selektion als File-Picker im Chat --- Endpoints/WorkspaceEndpoint.cs | 167 +++++++++++++++++++ Program.cs | 1 + wwwroot/css/file-explorer.css | 211 ++++++++++++++++++++++++ wwwroot/index.html | 3 + wwwroot/js/file-explorer.js | 282 +++++++++++++++++++++++++++++++++ wwwroot/js/pages/chat-page.js | 18 +++ 6 files changed, 682 insertions(+) create mode 100644 Endpoints/WorkspaceEndpoint.cs create mode 100644 wwwroot/css/file-explorer.css create mode 100644 wwwroot/js/file-explorer.js diff --git a/Endpoints/WorkspaceEndpoint.cs b/Endpoints/WorkspaceEndpoint.cs new file mode 100644 index 0000000..092d5a8 --- /dev/null +++ b/Endpoints/WorkspaceEndpoint.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Gott.Endpoints; + +public static class WorkspaceEndpoint +{ + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".csproj", ".sln", ".json", ".xml", ".yaml", ".yml", ".toml", + ".md", ".txt", ".log", ".csv", ".tsv", + ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", + ".sh", ".bash", ".bat", ".ps1", ".cmd", + ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", + ".c", ".cpp", ".h", ".hpp", + ".sql", ".graphql", + ".env", ".gitignore", ".dockerignore", ".editorconfig", + ".razor", ".cshtml", ".vue", ".svelte", + ".ini", ".cfg", ".conf", ".properties", + ".dockerfile", ".makefile", + "" // Dateien ohne Extension (Makefile, Dockerfile, etc.) + }; + + private static readonly HashSet IgnoredDirs = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", "node_modules", ".git", ".vs", ".idea", + "publish", "dist", "out", "build", "target", ".cache" + }; + + public static void MapWorkspaceEndpoint(this IEndpointRouteBuilder app) + { + app.MapGet("/api/workspace/tree", HandleTree); + app.MapGet("/api/workspace/file", HandleFile); + } + + private static async Task HandleTree(HttpContext ctx) + { + var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault(); + var subPath = ctx.Request.Query["path"].FirstOrDefault() ?? ""; + + // Workspace aus Session holen + 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, subPath)); + + // Sicherheit: nicht aus dem Workspace ausbrechen + if (!targetPath.StartsWith(basePath)) + { + ctx.Response.StatusCode = 403; + return; + } + + if (!Directory.Exists(targetPath)) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Verzeichnis nicht gefunden" }); + return; + } + + var entries = new List(); + + // Ordner + foreach (var dir in Directory.GetDirectories(targetPath).OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)) + { + var name = Path.GetFileName(dir); + if (IgnoredDirs.Contains(name)) continue; + + var relPath = Path.GetRelativePath(basePath, dir).Replace('\\', '/'); + entries.Add(new { name, path = relPath, type = "dir" }); + } + + // Dateien + foreach (var file in Directory.GetFiles(targetPath).OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)) + { + var name = Path.GetFileName(file); + var ext = Path.GetExtension(file); + var relPath = Path.GetRelativePath(basePath, file).Replace('\\', '/'); + var size = new FileInfo(file).Length; + var isText = TextExtensions.Contains(ext) || IsLikelyTextFile(name); + + entries.Add(new { name, path = relPath, type = "file", ext, size, isText }); + } + + await ctx.Response.WriteAsJsonAsync(entries); + } + + private static async Task HandleFile(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; + } + + var info = new FileInfo(targetPath); + + // Max 2MB für Textdateien + if (info.Length > 2 * 1024 * 1024) + { + ctx.Response.StatusCode = 413; + await ctx.Response.WriteAsJsonAsync(new { error = "Datei zu groß (max. 2MB)" }); + return; + } + + var content = await File.ReadAllTextAsync(targetPath); + var lineCount = content.Split('\n').Length; + + await ctx.Response.WriteAsJsonAsync(new + { + path = filePath, + name = info.Name, + size = info.Length, + lineCount, + content + }); + } + + private static bool IsLikelyTextFile(string name) + { + var lower = name.ToLowerInvariant(); + return lower is "makefile" or "dockerfile" or "rakefile" or "gemfile" + or "procfile" or "vagrantfile" or "license" or "readme" + or "changelog" or "authors" or "contributors"; + } +} diff --git a/Program.cs b/Program.cs index 62a7f71..8ba799e 100644 --- a/Program.cs +++ b/Program.cs @@ -21,5 +21,6 @@ app.MapChatEndpoint(); app.MapHistoryEndpoint(); app.MapSessionEndpoints(); app.MapAgentEndpoints(); +app.MapWorkspaceEndpoint(); app.Run("http://0.0.0.0:19000"); diff --git a/wwwroot/css/file-explorer.css b/wwwroot/css/file-explorer.css new file mode 100644 index 0000000..08b954e --- /dev/null +++ b/wwwroot/css/file-explorer.css @@ -0,0 +1,211 @@ +/* ── File Explorer Modal ── */ + +.fe-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.fe-modal { + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + width: 90vw; + max-width: 1100px; + height: 80vh; + display: flex; + flex-direction: column; +} + +.fe-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s2) var(--s3); + border-bottom: var(--border-width) solid var(--f1-border); + background: var(--f1-bg); + flex-shrink: 0; +} + +.fe-nav { + display: flex; + align-items: center; + gap: var(--s2); + flex: 1; + overflow: hidden; +} + +.fe-path { + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fe-btn { + background: var(--f3-bg); + border: var(--border-width) solid var(--f3-border); + padding: var(--s1) var(--s2); + cursor: pointer; + color: var(--text); + font-family: monospace; +} + +.fe-btn:hover:not(:disabled) { + border-color: var(--primary); +} + +.fe-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.fe-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.fe-sidebar { + width: 280px; + min-width: 200px; + border-right: var(--border-width) solid var(--f1-border); + overflow-y: auto; + background: var(--f1-bg); + flex-shrink: 0; +} + +.fe-main { + flex: 1; + overflow: hidden; + display: flex; +} + +.fe-item { + padding: var(--s1) var(--s3); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fe-item:hover { + background: var(--f2-bg); +} + +.fe-item.active { + background: var(--f3-bg); + border-left: 2px solid var(--primary); +} + +.fe-dir { + color: var(--text); +} + +.fe-file { + color: var(--text); + opacity: 0.85; +} + +.fe-loading, .fe-error, .fe-empty, .fe-placeholder { + padding: var(--s4); + text-align: center; + opacity: 0.5; + align-self: center; + margin: auto; +} + +.fe-error { + color: #ff6b6b; + opacity: 1; +} + +/* Preview */ + +.fe-preview { + display: flex; + flex: 1; + overflow: hidden; + font-size: 0.9em; +} + +.fe-line-numbers { + padding: var(--s2); + padding-right: var(--s3); + background: var(--f1-bg); + color: var(--text); + opacity: 0.35; + text-align: right; + overflow: hidden; + user-select: none; + line-height: 1.5; + font-family: monospace; + font-size: 1em; + border-right: var(--border-width) solid var(--f1-border); + flex-shrink: 0; + min-width: 3em; +} + +.fe-line-numbers div { + height: 1.5em; +} + +.fe-textarea { + flex: 1; + resize: none; + border: none; + background: var(--f2-bg); + color: var(--text); + font-family: monospace; + font-size: 1em; + line-height: 1.5; + padding: var(--s2); + outline: none; + overflow-y: auto; + white-space: pre; + tab-size: 4; + min-height: unset; + height: auto; +} + +.fe-textarea:hover { + border-color: transparent; +} + +.fe-textarea::selection { + background: rgba(180, 68, 17, 0.35); +} + +/* Footer */ + +.fe-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--s2) var(--s3); + border-top: var(--border-width) solid var(--f1-border); + background: var(--f1-bg); + flex-shrink: 0; + gap: var(--s3); +} + +.fe-info { + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.fe-select-btn { + background: var(--primary); + border-color: var(--primary); + white-space: nowrap; +} + +.fe-select-btn:hover { + opacity: 0.85; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index ef9f303..0c9ad85 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -12,6 +12,7 @@ + @@ -125,6 +126,7 @@
+ @@ -149,6 +151,7 @@ + diff --git a/wwwroot/js/file-explorer.js b/wwwroot/js/file-explorer.js new file mode 100644 index 0000000..f3fa62d --- /dev/null +++ b/wwwroot/js/file-explorer.js @@ -0,0 +1,282 @@ +// ── File Explorer Modal ── +// Wiederverwendbares Modal zum Durchsuchen des Workspaces. +// Nutzung: openFileExplorer({ sessionId, onSelect(path, from, to) }) + +let _feState = null; + +function openFileExplorer(opts = {}) { + if (_feState) closeFileExplorer(); + + _feState = { + sessionId: opts.sessionId || getCurrentSessionId(), + onSelect: opts.onSelect || null, + currentPath: '', + history: [], + selectedFile: null + }; + + const overlay = document.createElement('div'); + overlay.id = 'feOverlay'; + overlay.className = 'fe-overlay'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) closeFileExplorer(); }); + + const modal = document.createElement('div'); + modal.className = 'fe-modal'; + modal.innerHTML = ` +
+
+ + / +
+ +
+
+
+
Laden...
+
+
+
Datei auswählen
+
+
+ + `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + document.getElementById('feBack').addEventListener('click', feGoBack); + document.getElementById('feClose').addEventListener('click', closeFileExplorer); + document.getElementById('feSelectBtn').addEventListener('click', feDoSelect); + + // ESC + _feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); }; + document.addEventListener('keydown', _feState._keyHandler); + + feLoadDir(''); +} + +function closeFileExplorer() { + const overlay = document.getElementById('feOverlay'); + if (overlay) overlay.remove(); + if (_feState && _feState._keyHandler) { + document.removeEventListener('keydown', _feState._keyHandler); + } + _feState = null; +} + +async function feLoadDir(path) { + if (!_feState) return; + + _feState.currentPath = path; + _feState.selectedFile = null; + + const pathEl = document.getElementById('fePath'); + pathEl.textContent = '/' + (path || ''); + + const backBtn = document.getElementById('feBack'); + backBtn.disabled = _feState.history.length === 0 && path === ''; + + const sidebar = document.getElementById('feSidebar'); + sidebar.innerHTML = '
Laden...
'; + + // Preview zurücksetzen + const main = document.getElementById('feMain'); + main.innerHTML = '
Datei auswählen
'; + document.getElementById('feFooter').hidden = true; + + try { + const resp = await fetch(`/api/workspace/tree?sessionId=${_feState.sessionId}&path=${encodeURIComponent(path)}`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + sidebar.innerHTML = `
${err.error || 'Fehler'}
`; + return; + } + + const entries = await resp.json(); + sidebar.innerHTML = ''; + + if (path) { + const upItem = document.createElement('div'); + upItem.className = 'fe-item fe-dir'; + upItem.textContent = '📁 ..'; + upItem.addEventListener('click', () => { + _feState.history.push(path); + const parent = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''; + feLoadDir(parent); + }); + sidebar.appendChild(upItem); + } + + for (const entry of entries) { + const item = document.createElement('div'); + item.className = 'fe-item'; + + if (entry.type === 'dir') { + item.classList.add('fe-dir'); + item.textContent = '📁 ' + entry.name; + item.addEventListener('click', () => { + _feState.history.push(path); + feLoadDir(entry.path); + }); + } else { + item.classList.add('fe-file'); + const icon = entry.isText ? '📄' : getFileIcon(entry.ext); + item.textContent = icon + ' ' + entry.name; + item.dataset.path = entry.path; + item.dataset.isText = entry.isText; + item.addEventListener('click', () => feSelectFile(entry, item)); + } + + sidebar.appendChild(item); + } + + if (entries.length === 0) { + sidebar.innerHTML = '
Leer
'; + } + + backBtn.disabled = path === ''; + } catch (e) { + sidebar.innerHTML = '
Netzwerk-Fehler
'; + } +} + +function feGoBack() { + if (!_feState || _feState.history.length === 0) { + if (_feState && _feState.currentPath) feLoadDir(''); + return; + } + const prev = _feState.history.pop(); + feLoadDir(prev); +} + +async function feSelectFile(entry, itemEl) { + if (!_feState) return; + + // Active-State setzen + document.querySelectorAll('.fe-item.active').forEach(el => el.classList.remove('active')); + itemEl.classList.add('active'); + + const main = document.getElementById('feMain'); + const footer = document.getElementById('feFooter'); + const info = document.getElementById('feInfo'); + + if (!entry.isText) { + main.innerHTML = `
${getFileIcon(entry.ext)} ${entry.name}
${formatSize(entry.size)} - Vorschau nicht verfügbar
`; + footer.hidden = true; + _feState.selectedFile = null; + return; + } + + main.innerHTML = '
Laden...
'; + + try { + const resp = await fetch(`/api/workspace/file?sessionId=${_feState.sessionId}&path=${encodeURIComponent(entry.path)}`); + if (!resp.ok) { + main.innerHTML = '
Fehler beim Laden
'; + return; + } + + const data = await resp.json(); + _feState.selectedFile = data; + + // Textarea mit Zeilennummern + main.innerHTML = ` +
+
+ +
+ `; + + const textarea = document.getElementById('feTextarea'); + const lineNums = document.getElementById('feLineNums'); + + // Zeilennummern generieren + renderLineNumbers(data.content, lineNums); + + // Sync scroll + textarea.addEventListener('scroll', () => { + lineNums.scrollTop = textarea.scrollTop; + }); + + // Selection-Tracking für Zeilen-Info + textarea.addEventListener('mouseup', () => updateSelectionInfo()); + textarea.addEventListener('keyup', () => updateSelectionInfo()); + + info.textContent = `${data.path} - ${data.lineCount} Zeilen, ${formatSize(data.size)}`; + footer.hidden = false; + + } catch (e) { + main.innerHTML = '
Netzwerk-Fehler
'; + } +} + +function renderLineNumbers(content, container) { + const lines = content.split('\n'); + container.innerHTML = lines.map((_, i) => `
${i + 1}
`).join(''); +} + +function updateSelectionInfo() { + if (!_feState || !_feState.selectedFile) return; + + const textarea = document.getElementById('feTextarea'); + const info = document.getElementById('feInfo'); + const data = _feState.selectedFile; + + if (textarea.selectionStart !== textarea.selectionEnd) { + const from = getLineNumber(textarea.value, textarea.selectionStart); + const to = getLineNumber(textarea.value, textarea.selectionEnd); + info.textContent = `${data.path}:${from}-${to} (${to - from + 1} Zeilen ausgewählt)`; + } else { + info.textContent = `${data.path} - ${data.lineCount} Zeilen, ${formatSize(data.size)}`; + } +} + +function getLineNumber(text, pos) { + return text.substring(0, pos).split('\n').length; +} + +function feDoSelect() { + if (!_feState || !_feState.selectedFile) return; + + const data = _feState.selectedFile; + const textarea = document.getElementById('feTextarea'); + + let ref = data.path; + if (textarea && textarea.selectionStart !== textarea.selectionEnd) { + const from = getLineNumber(textarea.value, textarea.selectionStart); + const to = getLineNumber(textarea.value, textarea.selectionEnd); + ref = `${data.path}:${from}-${to}`; + } + + if (_feState.onSelect) { + _feState.onSelect(ref); + } + + closeFileExplorer(); +} + +// ── Helpers ── + +function getFileIcon(ext) { + const icons = { + '.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.webp': '🖼️', '.svg': '🖼️', + '.pdf': '📑', '.zip': '📦', '.gz': '📦', '.tar': '📦', + '.mp3': '🎵', '.wav': '🎵', '.mp4': '🎬', '.avi': '🎬', + '.exe': '⚙️', '.dll': '⚙️', '.so': '⚙️', + '.db': '🗃️', '.sqlite': '🗃️' + }; + return icons[ext?.toLowerCase()] || '📄'; +} + +function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +function escHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index d93b615..4442228 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -43,6 +43,24 @@ registerPage('chat', { }); } + // Workspace Explorer + const explorerBtn = document.getElementById('explorerBtn'); + if (explorerBtn) { + explorerBtn.addEventListener('click', () => { + openFileExplorer({ + sessionId, + onSelect(ref) { + const input = document.getElementById('input'); + if (!input) return; + const tag = `[Datei: ${ref}]`; + const pos = input.selectionStart || input.value.length; + input.value = input.value.substring(0, pos) + tag + input.value.substring(pos); + input.focus(); + } + }); + }); + } + // File Upload const fileBtn = document.getElementById('fileBtn'); const fileInput = document.getElementById('fileInput');