483 lines
14 KiB
JavaScript
483 lines
14 KiB
JavaScript
// ── 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() {
|
|
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(`/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');
|
|
|
|
// Vorherige Editor-Instanz aufräumen
|
|
feDestroyEditor();
|
|
|
|
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;
|
|
|
|
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)}`;
|
|
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"><textarea id="feCmTextarea"></textarea></div>';
|
|
|
|
const textarea = document.getElementById('feCmTextarea');
|
|
textarea.value = data.content;
|
|
|
|
const mode = getCmMode(ext);
|
|
|
|
const cm = CodeMirror.fromTextArea(textarea, {
|
|
mode: mode,
|
|
theme: 'default',
|
|
lineNumbers: true,
|
|
readOnly: true,
|
|
lineWrapping: false,
|
|
tabSize: 4,
|
|
indentWithTabs: false,
|
|
extraKeys: {}
|
|
});
|
|
|
|
// Selektion-Tracking für Zeilen-Info
|
|
cm.on('cursorActivity', () => {
|
|
if (!_feState || !_feState.selectedFile) return;
|
|
feUpdateSelectionInfo(cm, data);
|
|
});
|
|
|
|
_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');
|
|
|
|
const mde = new EasyMDE({
|
|
element: textarea,
|
|
initialValue: data.content,
|
|
toolbar: false,
|
|
status: false,
|
|
autofocus: false,
|
|
spellChecker: false,
|
|
autoDownloadFontAwesome: false,
|
|
minHeight: '100%',
|
|
maxHeight: '100%'
|
|
});
|
|
|
|
// Readonly via CodeMirror-Instanz
|
|
mde.codemirror.setOption('readOnly', true);
|
|
mde.codemirror.setOption('theme', 'default');
|
|
|
|
mde.codemirror.on('cursorActivity', () => {
|
|
if (!_feState || !_feState.selectedFile) return;
|
|
feUpdateSelectionInfo(mde.codemirror, data);
|
|
});
|
|
|
|
_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();
|
|
}
|
|
|
|
// ── 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" 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 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.textContent = '📁 ' + 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': '🖼️', '.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, '>');
|
|
}
|