Files
gott/wwwroot/js/file-explorer.js
T
Meleki 9e4ed6dada Add image preview support: Krea-tag parsing with modal, file-explorer preview, skill output endpoint, increase execute timeout to 180s
- SkillEndpoint: GET /api/skills/{slug}/output/{filename} for skill output files
- WorkspaceEndpoint: GET /api/workspace/raw for raw file serving (images)
- File-Explorer: Image preview for png/jpg/gif/webp/svg/bmp/ico
- Chat UI: Parse [Krea: filename] tags into clickable chips with image modal
- ToolService: Increase execute timeout from 30s to 180s for long-running skills
2026-03-01 20:00:35 +01:00

618 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── File Explorer Modal ──
// Wiederverwendbares Modal zum Durchsuchen des Workspaces.
// Nutzung: openFileExplorer({ sessionId, onSelect(path, from, to) })
let _feState = null;
function _feApiUrl(action, params = '') {
if (_feState && _feState.agentId) {
return `/api/workspace/agent/${action}?agentId=${_feState.agentId}${params ? '&' + params : ''}`;
}
return `/api/workspace/${action}?sessionId=${_feState.sessionId}${params ? '&' + params : ''}`;
}
function openFileExplorer(opts = {}) {
if (_feState) closeFileExplorer();
_feState = {
sessionId: opts.sessionId || (!opts.agentId ? getCurrentSessionId() : null),
agentId: opts.agentId || null,
onSelect: opts.onSelect || null,
currentPath: '',
history: [],
selectedFile: null,
initialFile: opts.initialFile || null // z.B. "src/foo.cs" – Datei direkt öffnen
};
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 icon-btn" id="feBack" title="Zurück" disabled><i class="fa fa-arrow-left"></i></button>
<span class="fe-path" id="fePath">/</span>
</div>
<button class="fe-btn icon-btn" id="feClose"><i class="fa fa-times"></i></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>
<div class="fe-footer-actions">
<button class="fe-btn fe-save-btn" id="feSaveBtn" disabled hidden>Speichern</button>
<button class="fe-btn fe-select-btn" id="feSelectBtn">Einfügen</button>
</div>
</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);
document.getElementById('feSaveBtn').addEventListener('click', feDoSave);
// ESC
_feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); };
document.addEventListener('keydown', _feState._keyHandler);
// Zum Verzeichnis der initialFile navigieren, oder Root
if (_feState.initialFile) {
const filePath = _feState.initialFile;
const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : '';
feLoadDir(dir);
} else {
feLoadDir('');
}
}
function closeFileExplorer() {
feDestroyEditor();
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
feDestroyEditor();
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(_feApiUrl('tree', `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.innerHTML = '<i class="fa fa-folder"></i> ..';
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.innerHTML = '<i class="fa fa-folder"></i> ' + esc(entry.name);
item.addEventListener('click', () => {
_feState.history.push(path);
feLoadDir(entry.path);
});
} else {
item.classList.add('fe-file');
const icon = entry.isText ? '<i class="fa fa-file-text-o"></i>' : getFileIcon(entry.ext);
item.innerHTML = icon + ' ' + esc(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 === '';
// Wenn initialFile gesetzt, Datei automatisch selektieren
if (_feState && _feState.initialFile) {
const targetPath = _feState.initialFile;
_feState.initialFile = null; // Nur einmal triggern
// data-path Selektor sicher aufbauen
const allFiles = sidebar.querySelectorAll('.fe-file');
let targetItem = null;
allFiles.forEach(el => { if (el.dataset.path === targetPath) targetItem = el; });
if (targetItem) {
const entry = entries.find(e => e.path === targetPath);
if (entry) {
feSelectFile(entry, targetItem);
}
}
}
} 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');
// Vorherige Editor-Instanz aufräumen
feDestroyEditor();
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'];
if (!entry.isText && imageExts.includes((entry.ext || '').toLowerCase())) {
const rawUrl = _feApiUrl('raw', `path=${encodeURIComponent(entry.path)}`);
main.innerHTML = `<div class="fe-image-preview"><img src="${rawUrl}" alt="${esc(entry.name)}" /></div>`;
footer.hidden = true;
_feState.selectedFile = null;
return;
}
if (!entry.isText) {
main.innerHTML = `<div class="fe-placeholder">${getFileIcon(entry.ext)} ${esc(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(_feApiUrl('file', `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;
const isMarkdown = entry.ext === '.md' || entry.ext === '.markdown';
if (isMarkdown) {
feInitEasyMDE(main, data);
} else {
feInitCodeMirror(main, data, entry.ext);
}
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) {
main.innerHTML = '<div class="fe-error">Netzwerk-Fehler</div>';
}
}
function feDestroyEditor() {
if (_feState && _feState._editor) {
if (_feState._editorType === 'easymde') {
try { _feState._editor.toTextArea(); } catch (_) {}
}
// CodeMirror wird durch innerHTML-Reset automatisch verworfen
_feState._editor = null;
_feState._editorType = null;
}
}
function feInitCodeMirror(container, data, ext) {
container.innerHTML = '<div class="fe-cm-wrap"><div id="feCmHost"></div></div>';
const host = document.getElementById('feCmHost');
const mode = getCmMode(ext);
_feState._originalContent = data.content;
const cm = CodeMirror(host, {
value: data.content,
mode: mode,
theme: 'default',
lineNumbers: true,
readOnly: false,
lineWrapping: false,
tabSize: 4,
indentWithTabs: false,
extraKeys: {
'Ctrl-S': () => feDoSave(),
'Cmd-S': () => feDoSave()
}
});
// Selektion-Tracking für Zeilen-Info
cm.on('cursorActivity', () => {
if (!_feState || !_feState.selectedFile) return;
feUpdateSelectionInfo(cm, data);
});
// Änderungs-Tracking
cm.on('change', () => {
const saveBtn = document.getElementById('feSaveBtn');
if (saveBtn) saveBtn.disabled = cm.getValue() === _feState._originalContent;
});
// Refresh nötig wenn CM in einem Container ohne finale Größe initialisiert wird
requestAnimationFrame(() => cm.refresh());
_feState._editor = cm;
_feState._editorType = 'codemirror';
}
function feInitEasyMDE(container, data) {
container.innerHTML = '<div class="fe-mde-wrap"><textarea id="feMdeTextarea"></textarea></div>';
const textarea = document.getElementById('feMdeTextarea');
_feState._originalContent = data.content;
const mde = new EasyMDE({
element: textarea,
initialValue: data.content,
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'code', '|',
'preview', 'side-by-side', '|',
'guide'
],
status: false,
autofocus: false,
spellChecker: false,
autoDownloadFontAwesome: false,
previewClass: ['editor-preview', 'markdown'],
minHeight: '100%',
maxHeight: '100%'
});
mde.codemirror.setOption('theme', 'default');
mde.codemirror.setOption('extraKeys', {
'Ctrl-S': () => feDoSave(),
'Cmd-S': () => feDoSave()
});
mde.codemirror.on('cursorActivity', () => {
if (!_feState || !_feState.selectedFile) return;
feUpdateSelectionInfo(mde.codemirror, data);
});
// Änderungs-Tracking
mde.codemirror.on('change', () => {
const saveBtn = document.getElementById('feSaveBtn');
if (saveBtn) saveBtn.disabled = mde.value() === _feState._originalContent;
});
_feState._editor = mde;
_feState._editorType = 'easymde';
}
function getCmMode(ext) {
const map = {
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
'.ts': 'javascript', '.jsx': 'javascript', '.tsx': 'javascript',
'.json': 'javascript',
'.css': 'css', '.scss': 'css', '.less': 'css',
'.html': 'htmlmixed', '.htm': 'htmlmixed',
'.xml': 'xml', '.svg': 'xml', '.csproj': 'xml', '.sln': 'xml',
'.py': 'python',
'.sh': 'shell', '.bash': 'shell', '.zsh': 'shell',
'.yaml': 'yaml', '.yml': 'yaml',
'.cs': 'clike', '.java': 'clike', '.cpp': 'clike', '.c': 'clike', '.h': 'clike'
};
return map[ext?.toLowerCase()] || 'null';
}
function feGetCm() {
if (!_feState || !_feState._editor) return null;
if (_feState._editorType === 'easymde') return _feState._editor.codemirror;
if (_feState._editorType === 'codemirror') return _feState._editor;
return null;
}
function feGetSelection(cm) {
const sel = cm.listSelections()[0];
const anchorLine = sel.anchor.line;
const headLine = sel.head.line;
const anchorCh = sel.anchor.ch;
const headCh = sel.head.ch;
// Echte Selektion: unterschiedliche Position (line oder ch)
const hasSelection = anchorLine !== headLine || anchorCh !== headCh;
if (!hasSelection) return null;
const fromLine = Math.min(anchorLine, headLine) + 1;
const toLine = Math.max(anchorLine, headLine) + 1;
return { from: fromLine, to: toLine };
}
function feUpdateSelectionInfo(cm, data) {
const info = document.getElementById('feInfo');
if (!info) return;
const sel = feGetSelection(cm);
if (sel && sel.from !== sel.to) {
info.textContent = `${data.path}:${sel.from}-${sel.to} (${sel.to - sel.from + 1} Zeilen ausgewählt)`;
} else if (sel) {
info.textContent = `${data.path}:${sel.from} (1 Zeile ausgewählt)`;
} else {
info.textContent = `${data.path} — ${data.lineCount} Zeilen, ${formatSize(data.size)}`;
}
}
function feDoSelect() {
if (!_feState || !_feState.selectedFile) return;
const data = _feState.selectedFile;
let ref = data.path;
const cm = feGetCm();
if (cm) {
const sel = feGetSelection(cm);
if (sel) {
ref = sel.from === sel.to
? `${data.path}:${sel.from}`
: `${data.path}:${sel.from}-${sel.to}`;
}
}
if (_feState.onSelect) {
_feState.onSelect(ref);
}
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(
_feApiUrl('file', `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;
_feState._originalContent = content;
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.
function openFolderBrowser(opts = {}) {
if (_feState) closeFileExplorer();
_feState = {
mode: 'folder',
onSelect: opts.onSelect || null,
currentAbsPath: opts.startPath || '/',
history: []
};
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 icon-btn" id="feBack" title="Zurück" disabled><i class="fa fa-arrow-left"></i></button>
<span class="fe-path" id="fePath">/</span>
</div>
<button class="fe-btn icon-btn" id="feClose"><i class="fa fa-times"></i></button>
</div>
<div class="fe-body">
<div class="fe-sidebar fe-sidebar-full" id="feSidebar">
<div class="fe-loading">Laden...</div>
</div>
</div>
<div class="fe-footer" id="feFooter">
<span class="fe-info" id="feInfo"></span>
<button class="fe-btn fe-select-btn" id="feSelectBtn">Diesen Ordner wählen</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.getElementById('feBack').addEventListener('click', fbGoBack);
document.getElementById('feClose').addEventListener('click', closeFileExplorer);
document.getElementById('feSelectBtn').addEventListener('click', () => {
if (_feState && _feState.onSelect) _feState.onSelect(_feState.currentAbsPath);
closeFileExplorer();
});
_feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); };
document.addEventListener('keydown', _feState._keyHandler);
fbLoadDir(opts.startPath || '/');
}
async function fbLoadDir(absPath) {
if (!_feState) return;
const pathEl = document.getElementById('fePath');
const sidebar = document.getElementById('feSidebar');
const info = document.getElementById('feInfo');
pathEl.textContent = absPath;
info.textContent = absPath;
sidebar.innerHTML = '<div class="fe-loading">Laden...</div>';
try {
const resp = await fetch(`/api/workspace/browse?path=${encodeURIComponent(absPath)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
sidebar.innerHTML = `<div class="fe-error">${err.error || 'Fehler'}</div>`;
return;
}
const data = await resp.json();
_feState.currentAbsPath = data.current;
pathEl.textContent = data.current;
info.textContent = data.current;
sidebar.innerHTML = '';
for (const entry of data.entries) {
const item = document.createElement('div');
item.className = 'fe-item fe-dir';
item.innerHTML = '<i class="fa fa-folder"></i> ' + esc(entry.name);
item.addEventListener('click', () => {
_feState.history.push(_feState.currentAbsPath);
fbLoadDir(entry.path);
});
sidebar.appendChild(item);
}
if (data.entries.length === 0) {
sidebar.innerHTML = '<div class="fe-empty">Leer</div>';
}
document.getElementById('feBack').disabled = _feState.history.length === 0;
} catch (e) {
sidebar.innerHTML = '<div class="fe-error">Netzwerk-Fehler</div>';
}
}
function fbGoBack() {
if (!_feState || _feState.history.length === 0) return;
const prev = _feState.history.pop();
fbLoadDir(prev);
}
// ── Helpers ──
function getFileIcon(ext) {
const icons = {
'.png': '<i class="fa fa-image"></i>', '.jpg': '<i class="fa fa-image"></i>',
'.jpeg': '<i class="fa fa-image"></i>', '.gif': '<i class="fa fa-image"></i>',
'.webp': '<i class="fa fa-image"></i>', '.svg': '<i class="fa fa-image"></i>',
'.pdf': '<i class="fa fa-file-pdf-o"></i>',
'.zip': '<i class="fa fa-archive"></i>', '.gz': '<i class="fa fa-archive"></i>',
'.tar': '<i class="fa fa-archive"></i>',
'.mp3': '<i class="fa fa-file-audio-o"></i>', '.wav': '<i class="fa fa-file-audio-o"></i>',
'.mp4': '<i class="fa fa-file-video-o"></i>', '.avi': '<i class="fa fa-file-video-o"></i>',
'.exe': '<i class="fa fa-cog"></i>', '.dll': '<i class="fa fa-cog"></i>',
'.so': '<i class="fa fa-cog"></i>',
'.db': '<i class="fa fa-database"></i>', '.sqlite': '<i class="fa fa-database"></i>'
};
return icons[ext?.toLowerCase()] || '<i class="fa fa-file-text-o"></i>';
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}