File Explorer Modal: Workspace-Browser mit Textvorschau, Zeilennummern, Selektion als File-Picker im Chat
This commit is contained in:
1 parent
663f2c2a9a
commit
491d3b916e
6 files changed
+682
No files matched your search
@@ -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<string> 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<string> 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<Gott.Services.DatabaseService>();
|
||||
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<object>();
|
||||
|
||||
// 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<Gott.Services.DatabaseService>();
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,6 @@ app.MapChatEndpoint();
|
||||
app.MapHistoryEndpoint();
|
||||
app.MapSessionEndpoints();
|
||||
app.MapAgentEndpoints();
|
||||
app.MapWorkspaceEndpoint();
|
||||
|
||||
app.Run("http://0.0.0.0:19000");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="css/dashboard.css">
|
||||
<link rel="stylesheet" href="css/chat.css">
|
||||
<link rel="stylesheet" href="css/placeholder.css">
|
||||
<link rel="stylesheet" href="css/file-explorer.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -125,6 +126,7 @@
|
||||
<div id="filePreview" class="file-preview" hidden></div>
|
||||
<div class="input-area">
|
||||
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" multiple hidden>
|
||||
<button id="explorerBtn" class="file-btn" title="Workspace durchsuchen">📂</button>
|
||||
<button id="fileBtn" class="file-btn" title="Datei anhängen">📎</button>
|
||||
<textarea id="input" placeholder="Nachricht eingeben... (Ctrl+Enter)" rows="2"></textarea>
|
||||
<button id="send">Senden</button>
|
||||
@@ -149,6 +151,7 @@
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/session.js"></script>
|
||||
<script src="js/file-explorer.js"></script>
|
||||
<script src="js/chat.js"></script>
|
||||
<script src="js/history.js"></script>
|
||||
<script src="js/router.js"></script>
|
||||
|
||||
@@ -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 = `
|
||||
<div class="fe-header">
|
||||
<div class="fe-nav">
|
||||
<button class="fe-btn" id="feBack" title="Zurück" disabled>←</button>
|
||||
<span class="fe-path" id="fePath">/</span>
|
||||
</div>
|
||||
<button class="fe-btn" id="feClose">✕</button>
|
||||
</div>
|
||||
<div class="fe-body">
|
||||
<div class="fe-sidebar" id="feSidebar">
|
||||
<div class="fe-loading">Laden...</div>
|
||||
</div>
|
||||
<div class="fe-main" id="feMain">
|
||||
<div class="fe-placeholder">Datei auswählen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fe-footer" id="feFooter" hidden>
|
||||
<span class="fe-info" id="feInfo"></span>
|
||||
<button class="fe-btn fe-select-btn" id="feSelectBtn">Einfügen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="fe-loading">Laden...</div>';
|
||||
|
||||
// Preview zurücksetzen
|
||||
const main = document.getElementById('feMain');
|
||||
main.innerHTML = '<div class="fe-placeholder">Datei auswählen</div>';
|
||||
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 = `<div class="fe-error">${err.error || 'Fehler'}</div>`;
|
||||
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 = '<div class="fe-empty">Leer</div>';
|
||||
}
|
||||
|
||||
backBtn.disabled = path === '';
|
||||
} catch (e) {
|
||||
sidebar.innerHTML = '<div class="fe-error">Netzwerk-Fehler</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div class="fe-placeholder">${getFileIcon(entry.ext)} ${entry.name}<br><small>${formatSize(entry.size)} - Vorschau nicht verfügbar</small></div>`;
|
||||
footer.hidden = true;
|
||||
_feState.selectedFile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
main.innerHTML = '<div class="fe-loading">Laden...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/workspace/file?sessionId=${_feState.sessionId}&path=${encodeURIComponent(entry.path)}`);
|
||||
if (!resp.ok) {
|
||||
main.innerHTML = '<div class="fe-error">Fehler beim Laden</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
_feState.selectedFile = data;
|
||||
|
||||
// Textarea mit Zeilennummern
|
||||
main.innerHTML = `
|
||||
<div class="fe-preview">
|
||||
<div class="fe-line-numbers" id="feLineNums"></div>
|
||||
<textarea class="fe-textarea" id="feTextarea" readonly spellcheck="false">${escHtml(data.content)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<div class="fe-error">Netzwerk-Fehler</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLineNumbers(content, container) {
|
||||
const lines = content.split('\n');
|
||||
container.innerHTML = lines.map((_, i) => `<div>${i + 1}</div>`).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, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user