/* workspace.ts — File browser, preview, and workspace management */
///
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 {
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 {
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 {
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 {
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 {
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, '"');
}
function _highlightWithLineNumbers(text: string): string {
return text.split('\n').map(line => '' + _escHtml(line) + '').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$2$3$4$5');
line = line.replace(/(:\s*)("[^&]*?")/g, '$1$2');
line = line.replace(/:\s*(\d+\.?\d*)/g, ': $1');
line = line.replace(/:\s*(true|false|null)/g, ': $1');
return '' + line + '';
}).join('\n');
} catch { return _highlightWithLineNumbers(text); }
}
let _wsSearchTimer: ReturnType | null = null;
function filterWsFiles(): void {
clearTimeout(_wsSearchTimer ?? undefined);
_wsSearchTimer = setTimeout(_doWsSearch, 300);
}
async function _doWsSearch(): Promise {
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 = 'Suche...
';
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 = 'Keine Dateien gefunden
'; 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 = 'Keine Dateien gefunden
';
else {
for (const item of matches) {
const el = document.createElement('div');
el.className = 'file-item'; el.style.paddingLeft = '10px';
el.innerHTML = '' + fileIcon(item.name, item.type) + '' + item.name + '';
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 };