/* 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 };