Files
gott/wwwroot/js/file-explorer.js
T
Meleki 4e1ed81f37 feat: DefaultPath als Base-Path für User-Workspaces
- WorkspaceHelper.cs: Zentrale Workspace-Auflösung (relativ → absolut)
- Session-Workspace wird relativ zum User-DefaultPath gespeichert
- System-Prompt bekommt aufgelösten absoluten Pfad
- Path-Traversal-Schutz: '..' in Workspace-Pfaden blockiert
- Browse-API gibt '..' nicht mehr als Eintrag zurück
- Folder-Browser: basePath-Limit, Back-Button navigiert zum Parent
- DefaultPath nur durch Admin bearbeitbar (Profil: readonly)
- Legacy: Absolute Pfade in session.Workspace weiterhin kompatibel
2026-03-05 22:49:09 +01:00

734 lines
23 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="feNewDir" title="Neuer Ordner"><i class="fa fa-folder-open"></i> <i class="fa fa-plus"></i></button>
<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('feNewDir').addEventListener('click', feCreateDir);
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');
const label = document.createElement('span');
label.className = 'fe-dir-label';
label.innerHTML = '<i class="fa fa-folder"></i> ' + 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 = '<i class="fa fa-trash-o"></i>';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
feDeleteDir(entry.path, entry.name);
});
item.appendChild(delBtn);
} 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 || '/',
basePath: (opts.basePath || '').replace(/\/+$/, '') || null,
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="feNewDir" title="Neuer Ordner"><i class="fa fa-folder-open"></i> <i class="fa fa-plus"></i></button>
<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('feNewDir').addEventListener('click', fbCreateDir);
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>';
}
const currentNorm = data.current.replace(/\/+$/, '');
const atBase = _feState.basePath && currentNorm === _feState.basePath;
const atRoot = currentNorm === '' || currentNorm === '/';
document.getElementById('feBack').disabled = atBase || (atRoot && _feState.history.length === 0);
} catch (e) {
sidebar.innerHTML = '<div class="fe-error">Netzwerk-Fehler</div>';
}
}
function fbGoBack() {
if (!_feState) return;
// Nicht über den basePath hinaus navigieren
const currentNorm = _feState.currentAbsPath.replace(/\/+$/, '');
if (_feState.basePath && currentNorm === _feState.basePath) return;
if (_feState.history.length > 0) {
const prev = _feState.history.pop();
// Prüfe ob der vorherige Pfad über dem basePath liegt
if (_feState.basePath && !prev.replace(/\/+$/, '').startsWith(_feState.basePath)) {
_feState.history.length = 0;
fbLoadDir(_feState.basePath);
return;
}
fbLoadDir(prev);
} else {
// Kein History-Eintrag → zum Parent navigieren
const parent = currentNorm.includes('/') ? currentNorm.substring(0, currentNorm.lastIndexOf('/')) || '/' : '/';
if (parent !== currentNorm) {
fbLoadDir(parent);
}
}
}
// ── 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) {
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;');
}