async function api(path,opts={}){ // Strip leading slash so URL resolves relative to location.href (supports subpath mounts) 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(); // Parse JSON error body and surface the human-readable message, // rather than showing raw JSON like {"error":"Profile 'x' does not exist."} 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(); } // Persist/restore expanded directory state per workspace in localStorage function _wsExpandKey(){ const ws=S.session&&S.session.workspace; return ws?'hermes-webui-expanded:'+ws:null; } function _saveExpandedDirs(){ const key=_wsExpandKey();if(!key)return; try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){} } function _restoreExpandedDirs(){ 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){ if(!S.session)return; try{ if(!path||path==='.'){ S._dirCache={}; _restoreExpandedDirs(); // restore per-workspace expanded state on root load } S.currentDir=path||'.'; const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); // Pre-fetch contents of restored expanded dirs so they render without a second click 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)}`); 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(); } } // Fetch git info for workspace root (non-blocking) if(!path||path==='.') _refreshGitBadge(); }catch(e){console.warn('loadDir',e);} } async function _refreshGitBadge(){ const badge=$('gitBadge'); if(!badge||!S.session)return; try{ const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`); if(data.git&&data.git.is_git){ const g=data.git; let text=g.branch||'git'; if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta if(g.behind>0) text+=` \u2193${g.behind}`; if(g.ahead>0) text+=` \u2191${g.ahead}`; badge.textContent=text; badge.className='git-badge'+(g.dirty>0?' dirty':''); badge.style.display=''; } else { badge.style.display='none'; badge.textContent=''; } }catch(e){badge.style.display='none';} } function navigateUp(){ if(!S.session||S.currentDir==='.')return; const parts=S.currentDir.split('/'); parts.pop(); loadDir(parts.length?parts.join('/'):'.'); } // File extension sets for preview routing (must match server-side sets) const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']); const MD_EXTS = new Set(['.md','.markdown','.mdown']); // Binary formats that should download rather than preview 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){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; } let _previewCurrentPath = ''; // relative path of currently previewed file let _previewCurrentMode = ''; // 'code' | 'md' | 'image' let _previewDirty = false; // true when edits are unsaved function showPreview(mode){ // mode: 'code' | 'image' | 'md' $('previewCode').style.display = mode==='code' ? '' : 'none'; $('previewImgWrap').style.display = mode==='image' ? '' : 'none'; $('previewMd').style.display = mode==='md' ? '' : 'none'; $('previewEditArea').style.display = 'none'; // start in read-only const badge=$('previewBadge'); badge.className='preview-badge '+mode; badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text'; _previewCurrentMode = mode; _previewDirty = false; updateEditBtn(); } function updateEditBtn(){ const btn=$('btnEditFile'); if(!btn)return; const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'; btn.style.display = editable?'':'none'; const editing = $('previewEditArea').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(){ const editing = $('previewEditArea').style.display!=='none'; if(editing){ // Save if(!S.session||!_previewCurrentPath)return; const content=$('previewEditArea').value; try{ await api('/api/file/save',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, path:_previewCurrentPath, content })}); _previewDirty=false; // Update read-only views if(_previewCurrentMode==='code') $('previewCode').textContent=content; else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); } $('previewEditArea').style.display='none'; if(_previewCurrentMode==='code') $('previewCode').style.display=''; else $('previewMd').style.display=''; showToast(t('saved')); }catch(e){setStatus(t('save_failed')+e.message);} }else{ // Enter edit mode: populate textarea with current content const currentText = _previewCurrentMode==='code' ? $('previewCode').textContent : _previewRawContent||''; $('previewEditArea').value=currentText; $('previewEditArea').style.display=''; if(_previewCurrentMode==='code') $('previewCode').style.display='none'; else $('previewMd').style.display='none'; // Escape cancels the edit without saving $('previewEditArea').onkeydown=e=>{ if(e.key==='Escape'){e.preventDefault();cancelEditMode();} }; } updateEditBtn(); } let _previewRawContent = ''; // raw text for md files (to populate editor) function cancelEditMode(){ // Discard changes and return to read-only view $('previewEditArea').style.display='none'; $('previewEditArea').onkeydown=null; if(_previewCurrentMode==='code') $('previewCode').style.display=''; else $('previewMd').style.display=''; _previewDirty=false; updateEditBtn(); } async function openFile(path){ if(!S.session)return; const ext=fileExt(path); // Binary/download-only formats: trigger browser download, don't preview if(DOWNLOAD_EXTS.has(ext)){ downloadFile(path); return; } $('previewPathText').textContent=path; $('previewArea').classList.add('visible'); $('fileTree').style.display='none'; const wsSearch=$('wsSearchWrap');if(wsSearch)wsSearch.style.display='none'; _previewCurrentPath = path; renderFileBreadcrumb(path); if(IMAGE_EXTS.has(ext)){ // Image: load via raw endpoint, show as showPreview('image'); const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; $('previewImg').alt=path; $('previewImg').src=url; $('previewImg').onerror=()=>setStatus(t('image_load_failed')); } else if(MD_EXTS.has(ext)){ // Markdown: fetch text, render with renderMd, display as formatted HTML try{ const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); showPreview('md'); _previewRawContent = data.content; $('previewMd').innerHTML=renderMd(data.content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }catch(e){setStatus(t('file_open_failed'));} } else { // Plain code / text -- but fall back to download if server signals binary try{ const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); if(data.binary){ // Server flagged this as binary content downloadFile(path); return; } showPreview('code'); // Apply syntax highlighting based on file extension const content = data.content || ''; if(['yml','yaml'].includes(ext)){ $('previewCode').className='preview-code hl-yaml'; if(typeof highlightYAML==='function'){ $('previewCode').innerHTML=highlightYAML(content); }else{ $('previewCode').innerHTML=_highlightWithLineNumbers(content); } }else if(ext==='json'){ $('previewCode').className='preview-code hl-json'; $('previewCode').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)){ $('previewCode').className='preview-code'; $('previewCode').textContent=content; requestAnimationFrame(()=>{if(typeof highlightCode==='function')highlightCode();}); }else{ // txt, toml, cfg, ini, conf, env, log, etc — readable with line numbers $('previewCode').className='preview-code hl-text'; $('previewCode').innerHTML=_highlightWithLineNumbers(content); } }catch(e){ // If it's a 400/too-large error, offer download instead downloadFile(path); } } } function downloadFile(path){ if(!S.session)return; // Trigger browser download via the raw file endpoint with content-disposition attachment const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; const filename=path.split('/').pop(); const a=document.createElement('a'); a.href=url;a.download=filename; document.body.appendChild(a);a.click(); setTimeout(()=>document.body.removeChild(a),100); showToast(t('downloading',filename),2000); } // ── Render breadcrumb for file preview mode ────────────────────────────────── function renderFileBreadcrumb(filePath) { const bar = $('breadcrumbBar'); if (!bar) return; bar.style.display = 'flex'; const upBtn = $('btnUpDir'); if (upBtn) upBtn.style.display = ''; bar.innerHTML = ''; // Root 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); } } // ── Syntax highlighting helpers for file preview ── function _escHtml(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function _highlightWithLineNumbers(text){ return text.split('\n').map(line=>''+_escHtml(line)+'').join('\n'); } function _highlightJSON(text){ try{ const pretty=JSON.stringify(JSON.parse(text),null,2); return pretty.split('\n').map(raw=>{ let line=_escHtml(raw); // Highlight keys line=line.replace(/^(\s*)(")([\w\s.\/\-_@:]+)(")(\s*:)/,'$1$2$3$4$5'); // Highlight string values (after colon) line=line.replace(/(:\s*)("[^&]*?")/g,'$1$2'); // Highlight numbers line=line.replace(/:\s*(\d+\.?\d*)/g,': $1'); // Highlight booleans / null line=line.replace(/:\s*(true|false|null)/g,': $1'); return ''+line+''; }).join('\n'); }catch(e){ return _highlightWithLineNumbers(text); } } // ── Workspace file search (server-side recursive) ── let _wsSearchTimer=null; function filterWsFiles(){ // Debounce: wait 300ms after last keystroke clearTimeout(_wsSearchTimer); _wsSearchTimer=setTimeout(_doWsSearch,300); } async function _doWsSearch(){ const input=$('wsSearchInput'); 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); // Remove any stale "no results" message const oldNoRes=tree.querySelector('.ws-no-results'); if(oldNoRes)oldNoRes.remove(); // If empty query, restore original file tree if(!query){ if(typeof renderFileTree==='function')renderFileTree(); return; } // Not searchable without a workspace if(!S.session||!S.session.workspace)return; // Show loading indicator tree.innerHTML='
Suche...
'; try{ // Ask server to search recursively const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`); const results=data.entries||[]; if(!results.length){ tree.innerHTML='
Keine Dateien gefunden
'; return; } // Render flat result list with path info 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); const nameEl=document.createElement('span'); nameEl.className='file-name'; nameEl.textContent=item.name; el.appendChild(nameEl); // Show relative path as hint 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); } if(item.type==='dir'){ el.onclick=()=>{clearWsSearch();loadDir(item.path);}; }else{ el.onclick=()=>openFile(item.path); } tree.appendChild(el); } }catch(e){ // Fallback: client-side filter on currently visible items tree.innerHTML=''; const allItems=S.entries||[]; const matches=allItems.filter(it=>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); } } } } // Toggle workspace search visibility function toggleWsSearch(){ const wrap=$('wsSearchWrap'); if(!wrap)return; // Show/hide the search bar wrap.style.display=wrap.style.display==='none'?'flex':'none'; // Focus input when showing setTimeout(()=>{if(wrap.style.display!=='none'){const inp=$('wsSearchInput');if(inp)inp.focus();}},50); } function clearWsSearch(){ const input=$('wsSearchInput'); if(input)input.value=''; const clearBtn=$('wsSearchClear'); if(clearBtn)clearBtn.classList.remove('visible'); if(typeof renderFileTree==='function')renderFileTree(); }