397 lines
17 KiB
TypeScript
397 lines
17 KiB
TypeScript
/* workspace.ts — File browser, preview, and workspace management */
|
|
/// <reference path="./global.d.ts" />
|
|
|
|
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
|
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
|
const DOWNLOAD_EXTS = new Set([
|
|
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
|
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
|
|
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
|
|
'.exe','.dmg','.pkg','.deb','.rpm',
|
|
'.woff','.woff2','.ttf','.otf','.eot',
|
|
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
|
|
]);
|
|
|
|
function fileExt(p: string): string { const i = p.lastIndexOf('.'); return i >= 0 ? p.slice(i).toLowerCase() : ''; }
|
|
|
|
async function api(path: string, opts: RequestInit = {}): Promise<unknown> {
|
|
const rel = path.startsWith('/') ? path.slice(1) : path;
|
|
const url = new URL(rel, location.href);
|
|
const res = await fetch(url.href, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...opts });
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
try { const j = JSON.parse(text); throw new Error(j.error || j.message || text); }
|
|
catch (e) { if (e instanceof SyntaxError) throw new Error(text); throw e; }
|
|
}
|
|
const ct = res.headers.get('content-type') || '';
|
|
return ct.includes('application/json') ? res.json() : res.text();
|
|
}
|
|
|
|
function _wsExpandKey(): string | null {
|
|
const ws = S.session && S.session.workspace;
|
|
return ws ? 'hermes-webui-expanded:' + ws : null;
|
|
}
|
|
|
|
function _saveExpandedDirs(): void {
|
|
const key = _wsExpandKey(); if (!key) return;
|
|
try { localStorage.setItem(key, JSON.stringify([...(S._expandedDirs || new Set())])); } catch (e) { /* noop */ }
|
|
}
|
|
|
|
function _restoreExpandedDirs(): void {
|
|
const key = _wsExpandKey();
|
|
if (!key) { S._expandedDirs = new Set(); return; }
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
S._expandedDirs = raw ? new Set(JSON.parse(raw)) : new Set();
|
|
} catch (e) { S._expandedDirs = new Set(); }
|
|
}
|
|
|
|
async function loadDir(path: string): Promise<void> {
|
|
if (!S.session) return;
|
|
try {
|
|
if (!path || path === '.') {
|
|
S._dirCache = {};
|
|
_restoreExpandedDirs();
|
|
}
|
|
S.currentDir = path || '.';
|
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { entries?: WsEntry[] };
|
|
S.entries = data.entries || [];
|
|
renderBreadcrumb();
|
|
renderFileTree();
|
|
if (!path || path === '.') {
|
|
for (const dirPath of (S._expandedDirs || [])) {
|
|
if (!S._dirCache[dirPath]) {
|
|
try {
|
|
const dc = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`) as { entries?: WsEntry[] };
|
|
S._dirCache[dirPath] = dc.entries || [];
|
|
} catch (e2) { S._dirCache[dirPath] = []; }
|
|
}
|
|
}
|
|
if (S._expandedDirs && S._expandedDirs.size > 0) renderFileTree();
|
|
}
|
|
if (typeof clearPreview === 'function') {
|
|
if (typeof _previewDirty !== 'undefined' && _previewDirty) {
|
|
showConfirmDialog({ title: t('unsaved_confirm'), message: '', confirmLabel: 'Discard', danger: true, focusCancel: true }).then(ok => { if (ok) clearPreview(); });
|
|
} else {
|
|
clearPreview();
|
|
}
|
|
}
|
|
if (!path || path === '.') _refreshGitBadge();
|
|
} catch (e) { console.warn('loadDir', e); }
|
|
}
|
|
|
|
async function _refreshGitBadge(): Promise<void> {
|
|
const badge = $('gitBadge');
|
|
if (!badge || !S.session) return;
|
|
try {
|
|
const data = await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`) as { git?: { is_git?: boolean; branch?: string; dirty?: number; behind?: number; ahead?: number } };
|
|
if (data.git && data.git.is_git) {
|
|
const g = data.git;
|
|
let text = g.branch || 'git';
|
|
if ((g.dirty ?? 0) > 0) text += ` \u00b7 ${g.dirty}\u2206`;
|
|
if ((g.behind ?? 0) > 0) text += ` \u2193${g.behind}`;
|
|
if ((g.ahead ?? 0) > 0) text += ` \u2191${g.ahead}`;
|
|
badge.textContent = text;
|
|
badge.className = 'git-badge' + ((g.dirty ?? 0) > 0 ? ' dirty' : '');
|
|
badge.style.display = '';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
badge.textContent = '';
|
|
}
|
|
} catch (e) { if (badge) badge.style.display = 'none'; }
|
|
}
|
|
|
|
function navigateUp(): void {
|
|
if (!S.session || S.currentDir === '.') return;
|
|
const parts = S.currentDir.split('/');
|
|
parts.pop();
|
|
loadDir(parts.length ? parts.join('/') : '.');
|
|
}
|
|
|
|
let _previewCurrentPath = '';
|
|
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
|
let _previewDirty = false;
|
|
let _previewRawContent = '';
|
|
|
|
function showPreview(mode: 'code' | 'md' | 'image'): void {
|
|
const codeEl = $('previewCode') as HTMLElement | null;
|
|
const imgWrap = $('previewImgWrap') as HTMLElement | null;
|
|
const mdEl = $('previewMd') as HTMLElement | null;
|
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
|
const badge = $('previewBadge');
|
|
if (codeEl) codeEl.style.display = mode === 'code' ? '' : 'none';
|
|
if (imgWrap) imgWrap.style.display = mode === 'image' ? '' : 'none';
|
|
if (mdEl) mdEl.style.display = mode === 'md' ? '' : 'none';
|
|
if (editEl) editEl.style.display = 'none';
|
|
if (badge) {
|
|
badge.className = 'preview-badge ' + mode;
|
|
const pathText = $('previewPathText');
|
|
badge.textContent = mode === 'image' ? 'image' : mode === 'md' ? 'md' : fileExt(pathText?.textContent || '');
|
|
}
|
|
_previewCurrentMode = mode;
|
|
_previewDirty = false;
|
|
updateEditBtn();
|
|
}
|
|
|
|
function updateEditBtn(): void {
|
|
const btn = $('btnEditFile') as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
const editable = _previewCurrentMode === 'code' || _previewCurrentMode === 'md';
|
|
btn.style.display = editable ? '' : 'none';
|
|
const editing = ($('previewEditArea') as HTMLTextAreaElement | null)?.style.display !== 'none';
|
|
btn.innerHTML = editing ? `💾 ${t('save')}` : `✎ ${t('edit')}`;
|
|
btn.title = editing ? t('save_title') : t('edit_title');
|
|
btn.style.color = editing ? 'var(--blue)' : '';
|
|
if (_previewDirty) btn.innerHTML = '💾 Save*';
|
|
}
|
|
|
|
async function toggleEditMode(): Promise<void> {
|
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
|
const editing = editEl && editEl.style.display !== 'none';
|
|
if (editing) {
|
|
if (!S.session || !_previewCurrentPath) return;
|
|
const content = editEl.value;
|
|
try {
|
|
await api('/api/file/save', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, path: _previewCurrentPath, content }) });
|
|
_previewDirty = false;
|
|
const codeEl = $('previewCode');
|
|
const mdEl = $('previewMd');
|
|
if (_previewCurrentMode === 'code' && codeEl) codeEl.textContent = content;
|
|
else if (mdEl) { mdEl.innerHTML = renderMd(content); requestAnimationFrame(() => { if (typeof renderKatexBlocks === 'function') renderKatexBlocks(); }); }
|
|
if (editEl) editEl.style.display = 'none';
|
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = '';
|
|
else if (mdEl) mdEl.style.display = '';
|
|
showToast(t('saved'));
|
|
} catch (e: unknown) { setStatus(t('save_failed') + (e instanceof Error ? e.message : String(e))); }
|
|
} else {
|
|
const currentText = _previewCurrentMode === 'code'
|
|
? ($('previewCode')?.textContent || '')
|
|
: _previewRawContent || '';
|
|
if (editEl) {
|
|
editEl.value = currentText;
|
|
editEl.style.display = '';
|
|
}
|
|
const codeEl = $('previewCode');
|
|
const mdEl = $('previewMd');
|
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = 'none';
|
|
else if (mdEl) mdEl.style.display = 'none';
|
|
editEl!.onkeydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { e.preventDefault(); cancelEditMode(); }
|
|
};
|
|
}
|
|
updateEditBtn();
|
|
}
|
|
|
|
function cancelEditMode(): void {
|
|
const editEl = $('previewEditArea') as HTMLTextAreaElement | null;
|
|
if (editEl) {
|
|
editEl.style.display = 'none';
|
|
editEl.onkeydown = null;
|
|
}
|
|
const codeEl = $('previewCode');
|
|
const mdEl = $('previewMd');
|
|
if (codeEl && _previewCurrentMode === 'code') codeEl.style.display = '';
|
|
else if (mdEl) mdEl.style.display = '';
|
|
_previewDirty = false;
|
|
updateEditBtn();
|
|
}
|
|
|
|
async function openFile(path: string): Promise<void> {
|
|
if (!S.session) return;
|
|
const ext = fileExt(path);
|
|
if (DOWNLOAD_EXTS.has(ext)) { downloadFile(path); return; }
|
|
|
|
const previewPathText = $('previewPathText');
|
|
const previewArea = $('previewArea');
|
|
const fileTree = $('fileTree');
|
|
const wsSearch = $('wsSearchWrap');
|
|
if (previewPathText) previewPathText.textContent = path;
|
|
previewArea?.classList.add('visible');
|
|
if (fileTree) fileTree.style.display = 'none';
|
|
if (wsSearch) wsSearch.style.display = 'none';
|
|
|
|
_previewCurrentPath = path;
|
|
renderFileBreadcrumb(path);
|
|
|
|
if (IMAGE_EXTS.has(ext)) {
|
|
showPreview('image');
|
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
|
const previewImg = $('previewImg') as HTMLImageElement | null;
|
|
if (previewImg) { previewImg.alt = path; previewImg.src = url; previewImg.onerror = () => setStatus(t('image_load_failed')); }
|
|
} else if (MD_EXTS.has(ext)) {
|
|
try {
|
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { content?: string };
|
|
showPreview('md');
|
|
_previewRawContent = data.content || '';
|
|
const mdEl = $('previewMd');
|
|
if (mdEl) mdEl.innerHTML = renderMd(data.content || '');
|
|
requestAnimationFrame(() => { if (typeof renderKatexBlocks === 'function') renderKatexBlocks(); });
|
|
} catch (e: unknown) { setStatus(t('file_open_failed')); }
|
|
} else {
|
|
try {
|
|
const data = await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`) as { content?: string; binary?: boolean };
|
|
if (data.binary) { downloadFile(path); return; }
|
|
showPreview('code');
|
|
const content = data.content || '';
|
|
const codeEl = $('previewCode');
|
|
if (['yml','yaml'].includes(ext)) {
|
|
if (codeEl) { codeEl.className = 'preview-code hl-yaml'; codeEl.innerHTML = typeof highlightYAML === 'function' ? highlightYAML(content) : _highlightWithLineNumbers(content); }
|
|
} else if (ext === 'json') {
|
|
if (codeEl) { codeEl.className = 'preview-code hl-json'; codeEl.innerHTML = _highlightJSON(content); }
|
|
} else if (['py','js','ts','sh','bash','zsh','rb','go','rs','java','c','cpp','h','css','scss','html','xml','sql','r','lua','pl','php','swift','kt','dart'].includes(ext)) {
|
|
if (codeEl) { codeEl.className = 'preview-code'; codeEl.textContent = content; requestAnimationFrame(() => { if (typeof highlightCode === 'function') highlightCode(); }); }
|
|
} else {
|
|
if (codeEl) { codeEl.className = 'preview-code hl-text'; codeEl.innerHTML = _highlightWithLineNumbers(content); }
|
|
}
|
|
} catch (e: unknown) { downloadFile(path); }
|
|
}
|
|
}
|
|
|
|
function downloadFile(path: string): void {
|
|
if (!S.session) return;
|
|
const url = `api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
|
const filename = path.split('/').pop() || path;
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a as Node); a.click();
|
|
setTimeout(() => document.body.removeChild(a as Node), 100);
|
|
showToast(t('downloading', filename), 2000);
|
|
}
|
|
|
|
function renderFileBreadcrumb(filePath: string): void {
|
|
const bar = $('breadcrumbBar');
|
|
if (!bar) return;
|
|
bar.style.display = 'flex';
|
|
const upBtn = $('btnUpDir');
|
|
if (upBtn) upBtn.style.display = '';
|
|
bar.innerHTML = '';
|
|
const root = document.createElement('span');
|
|
root.className = 'breadcrumb-seg breadcrumb-link';
|
|
root.textContent = '~';
|
|
root.onclick = () => { clearPreview(); loadDir('.'); };
|
|
bar.appendChild(root);
|
|
const parts = filePath.split('/');
|
|
let accumulated = '';
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const sep = document.createElement('span');
|
|
sep.className = 'breadcrumb-sep';
|
|
sep.textContent = '/';
|
|
bar.appendChild(sep);
|
|
accumulated += (accumulated ? '/' : '') + parts[i];
|
|
const seg = document.createElement('span');
|
|
seg.textContent = parts[i];
|
|
if (i < parts.length - 1) {
|
|
seg.className = 'breadcrumb-seg breadcrumb-link';
|
|
const target = accumulated;
|
|
seg.onclick = () => { clearPreview(); loadDir(target); };
|
|
} else {
|
|
seg.className = 'breadcrumb-seg breadcrumb-current';
|
|
}
|
|
bar.appendChild(seg);
|
|
}
|
|
}
|
|
|
|
function _escHtml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _highlightWithLineNumbers(text: string): string {
|
|
return text.split('\n').map(line => '<span class="code-line">' + _escHtml(line) + '</span>').join('\n');
|
|
}
|
|
|
|
function _highlightJSON(text: string): string {
|
|
try {
|
|
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
return pretty.split('\n').map(raw => {
|
|
let line = _escHtml(raw);
|
|
line = line.replace(/^(\s*)(")([\w\s.\/\-_@:]+)(")(\s*:)/, '$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
|
|
line = line.replace(/(:\s*)("[^&]*?")/g, '$1<span class="hl-string">$2</span>');
|
|
line = line.replace(/:\s*(\d+\.?\d*)/g, ': <span class="hl-number">$1</span>');
|
|
line = line.replace(/:\s*(true|false|null)/g, ': <span class="hl-bool">$1</span>');
|
|
return '<span class="code-line">' + line + '</span>';
|
|
}).join('\n');
|
|
} catch { return _highlightWithLineNumbers(text); }
|
|
}
|
|
|
|
let _wsSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function filterWsFiles(): void {
|
|
clearTimeout(_wsSearchTimer ?? undefined);
|
|
_wsSearchTimer = setTimeout(_doWsSearch, 300);
|
|
}
|
|
|
|
async function _doWsSearch(): Promise<void> {
|
|
const input = $('wsSearchInput') as HTMLInputElement | null;
|
|
const clearBtn = $('wsSearchClear');
|
|
const tree = $('fileTree');
|
|
if (!input || !tree) return;
|
|
const query = input.value.trim().toLowerCase();
|
|
if (clearBtn) clearBtn.classList.toggle('visible', query.length > 0);
|
|
const oldNoRes = tree.querySelector('.ws-no-results');
|
|
if (oldNoRes) oldNoRes.remove();
|
|
if (!query) { if (typeof renderFileTree === 'function') renderFileTree(); return; }
|
|
if (!S.session || !S.session.workspace) return;
|
|
tree.innerHTML = '<div class="ws-no-results" style="opacity:.5">Suche...</div>';
|
|
try {
|
|
const data = await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`) as { entries?: WsEntry[] };
|
|
const results = data.entries || [];
|
|
if (!results.length) { tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>'; return; }
|
|
tree.innerHTML = '';
|
|
for (const item of results) {
|
|
const el = document.createElement('div');
|
|
el.className = 'file-item';
|
|
el.style.paddingLeft = '10px';
|
|
const iconEl = document.createElement('span');
|
|
iconEl.className = 'file-icon';
|
|
iconEl.innerHTML = fileIcon(item.name, item.type);
|
|
el.appendChild(iconEl as Node);
|
|
const nameEl = document.createElement('span');
|
|
nameEl.className = 'file-name';
|
|
nameEl.textContent = item.name;
|
|
el.appendChild(nameEl as Node);
|
|
if (item.path) {
|
|
const pathEl = document.createElement('span');
|
|
pathEl.className = 'file-size';
|
|
pathEl.style.opacity = '.4';
|
|
const dir = item.path.substring(0, item.path.lastIndexOf('/'));
|
|
pathEl.textContent = dir || '.';
|
|
el.appendChild(pathEl as Node);
|
|
}
|
|
if (item.type === 'dir') el.onclick = () => { clearWsSearch(); loadDir(item.path); };
|
|
else el.onclick = () => openFile(item.path);
|
|
tree.appendChild(el);
|
|
}
|
|
} catch {
|
|
tree.innerHTML = '';
|
|
const allItems = S.entries || [];
|
|
const matches = allItems.filter((it: WsEntry) => it.name.toLowerCase().includes(query));
|
|
if (!matches.length) tree.innerHTML = '<div class="ws-no-results">Keine Dateien gefunden</div>';
|
|
else {
|
|
for (const item of matches) {
|
|
const el = document.createElement('div');
|
|
el.className = 'file-item'; el.style.paddingLeft = '10px';
|
|
el.innerHTML = '<span class="file-icon">' + fileIcon(item.name, item.type) + '</span><span class="file-name">' + item.name + '</span>';
|
|
el.onclick = item.type === 'dir' ? () => { clearWsSearch(); loadDir(item.path); } : () => openFile(item.path);
|
|
tree.appendChild(el);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleWsSearch(): void {
|
|
const wrap = $('wsSearchWrap');
|
|
if (!wrap) return;
|
|
wrap.style.display = wrap.style.display === 'none' ? 'flex' : 'none';
|
|
setTimeout(() => { if (wrap.style.display !== 'none') { const inp = $('wsSearchInput') as HTMLInputElement | null; if (inp) inp.focus(); } }, 50);
|
|
}
|
|
|
|
function clearWsSearch(): void {
|
|
const input = $('wsSearchInput') as HTMLInputElement | null;
|
|
if (input) input.value = '';
|
|
const clearBtn = $('wsSearchClear');
|
|
if (clearBtn) clearBtn.classList.remove('visible');
|
|
if (typeof renderFileTree === 'function') renderFileTree();
|
|
}
|
|
|
|
export { api, loadDir, openFile, downloadFile, filterWsFiles, toggleWsSearch, clearWsSearch, navigateUp, showPreview, toggleEditMode, cancelEditMode, _restoreExpandedDirs, _saveExpandedDirs };
|