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,document.baseURI||location.href); const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000; // Retry up to 2 times on network errors (e.g. stale keep-alive after long idle). // Server errors (4xx/5xx) and client-side timeouts are NOT retried. let lastErr; for(let attempt=0;attempt<3;attempt++){ let controller=null; let timeoutId=null; let didTimeout=false; let upstreamSignal=null; let upstreamAbort=null; try{ const fetchOpts={...opts}; delete fetchOpts.timeoutMs; const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0; if(useTimeout&&typeof AbortController!=='undefined'){ controller=new AbortController(); upstreamSignal=fetchOpts.signal||null; if(upstreamSignal){ upstreamAbort=()=>controller.abort(upstreamSignal.reason); if(upstreamSignal.aborted) upstreamAbort(); else upstreamSignal.addEventListener('abort',upstreamAbort,{once:true}); } fetchOpts.signal=controller.signal; } const requestPromise=(async()=>{ const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...fetchOpts}); if(!res.ok){ // 401 means the auth session expired. Redirect to login so the user can // re-authenticate. This is especially important for iOS PWA (standalone mode) // and for subpath mounts like /hermes/, where /login escapes to the site root. if(res.status===401){window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;} 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."} let message=text; try{const j=JSON.parse(text);message=j.error||j.message||text;}catch(e){} // Attach the raw HTTP context so callers can branch on status (404 stale-session // cleanup, 401 redirect, 503 retry, etc.) without re-parsing the message string. const err=new Error(message); err.status=res.status; err.statusText=res.statusText; err.body=text; throw err; } const ct=res.headers.get('content-type')||''; return ct.includes('application/json')?await res.json():await res.text(); })(); return useTimeout?await Promise.race([ requestPromise, new Promise((_,reject)=>{ timeoutId=setTimeout(()=>{ didTimeout=true; if(controller) controller.abort(); const err=new Error('Request timed out. Please try again.'); err.name='TimeoutError'; err.timeout=true; reject(err); },Number(timeoutMs)); }) ]):await requestPromise; }catch(e){ lastErr=e; const isTimeout=didTimeout||(e&&(e.timeout===true||e.name==='TimeoutError')); if(isTimeout){ const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.'); err.name='TimeoutError'; err.timeout=true; if(typeof showToast==='function') showToast('Request timed out. Please try again.',5000,'error'); throw err; } // Only retry on network errors (TypeError from fetch), not on HTTP errors // that were already thrown above. Re-throw 401 redirects immediately. if(e.message&&/401/.test(e.message)) throw e; if(attempt<2 && e instanceof TypeError) continue; throw e; }finally{ if(timeoutId) clearTimeout(timeoutId); if(upstreamSignal&&upstreamAbort) upstreamSignal.removeEventListener('abort',upstreamAbort); } } throw lastErr; } function recordClientSSEError(source, details={}){ try{ const payload={ event:'sse_error', source:String(source||'unknown'), ready_state:details.ready_state, session_id:details.session_id||null, stream_id:details.stream_id||null, visibility_state:(typeof document!=='undefined'&&document.visibilityState)||'unknown', online:(typeof navigator!=='undefined'&&typeof navigator.onLine==='boolean')?navigator.onLine:null, url_path:(typeof location!=='undefined'&&location.pathname)||'/', reason:details.reason||'EventSource.onerror', }; void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{}); }catch(_){} } // 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();} } let _workspacePanelActiveTab = 'files'; let _renderSessionArtifactsTimer = null; function _setWorkspacePanelTabDataset(){ const panel = document.querySelector('.rightpanel'); if(panel) panel.dataset.activeTab = _workspacePanelActiveTab; } function scheduleRenderSessionArtifacts(){ if(_renderSessionArtifactsTimer) clearTimeout(_renderSessionArtifactsTimer); _renderSessionArtifactsTimer = setTimeout(()=>{ _renderSessionArtifactsTimer = null; renderSessionArtifacts(); }, 100); } if(typeof document !== 'undefined'){ if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true}); else _setWorkspacePanelTabDataset(); } function switchWorkspacePanelTab(tab){ _workspacePanelActiveTab = tab === 'artifacts' ? 'artifacts' : 'files'; _setWorkspacePanelTabDataset(); const filesTab = $('workspaceFilesTab'); const artifactsTab = $('workspaceArtifactsTab'); if(filesTab){ filesTab.classList.toggle('active', _workspacePanelActiveTab === 'files'); filesTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'files' ? 'true' : 'false'); } if(artifactsTab){ artifactsTab.classList.toggle('active', _workspacePanelActiveTab === 'artifacts'); artifactsTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'artifacts' ? 'true' : 'false'); } const artifacts = $('workspaceArtifacts'); if(artifacts) artifacts.hidden = _workspacePanelActiveTab !== 'artifacts'; if(_workspacePanelActiveTab === 'artifacts') renderSessionArtifacts(); } const ARTIFACT_IGNORE_RE = /(^|\/)(?:\.git|\.hg|\.svn|node_modules|\.venv|venv|__pycache__|dist|build|\.next|\.cache)(?:\/|$)/; // Canonical Hermes mutators plus MCP filesystem aliases that can create/edit files. const ARTIFACT_MUTATION_TOOLS = new Set(['write_file','patch','edit_file','create_file','mcp_filesystem_write_file','mcp_filesystem_edit_file']); function _normalizeArtifactPath(path){ if(!path) return ''; path = String(path).trim().replace(/[\`"'<>),.;:]+$/g,'').replace(/^[\`"'(<]+/g,''); if(!path || path.length > 240 || path.includes('://')) return ''; if(ARTIFACT_IGNORE_RE.test(path)) return ''; if(!/[./]/.test(path)) return ''; return path; } function _artifactCandidatesFromText(text){ if(!text || typeof text !== 'string') return []; const out = []; const seen = new Set(); const add = (path) => { path = _normalizeArtifactPath(path); if(!path || seen.has(path)) return; seen.add(path); out.push({path, kind:'diff'}); }; // Fallback text mining is intentionally narrow: only diff/patch fences imply // the session changed a file. Prose mentions such as "edited package.json" are // too noisy for an Artifacts list that should track write/edit outputs. const fenced = /```(?:diff|patch)\s*\n[\s\S]*?```/gi; let m; while((m = fenced.exec(text))){ const block = m[0]; const fm = block.match(/(?:^|\n)(?:\+\+\+|---)\s+(?:[ab]\/)?([^\n\t]+)/); if(fm) add(fm[1].trim()); } return out; } function _artifactCandidatesFromToolCall(tc){ if(!tc) return []; const name = String(tc.name || '').replace(/^functions\./,''); const args = tc.arguments || tc.args || tc.input || {}; const result = tc.result || tc.output || tc.snippet || ''; const out = []; const add = (path, source=name || 'tool') => { path = _normalizeArtifactPath(path); if(path) out.push({path, kind:source}); }; if(ARTIFACT_MUTATION_TOOLS.has(name) && args && typeof args === 'object'){ for(const key of ['path','file_path','source','destination']) add(args[key]); if(Array.isArray(args.paths)) args.paths.forEach(p=>add(p)); if(Array.isArray(args.edits)) args.edits.forEach(e=>add(e&&e.path)); } const resultText = typeof result === 'string' ? result : (result ? JSON.stringify(result) : ''); // Tool results may include unified diffs from patch-style tools; scan those // narrowly after structured args so diff headers can still contribute paths. for(const a of _artifactCandidatesFromText(resultText)) out.push(a); if(!out.length && ARTIFACT_MUTATION_TOOLS.has(name)){ const argsText = typeof args === 'string' ? args : JSON.stringify(args || {}); for(const a of _artifactCandidatesFromText(argsText)) out.push(a); } return out; } function collectSessionArtifacts(){ const items = []; const seen = new Set(); const push = (path, source) => { path = _normalizeArtifactPath(path); if(!path || seen.has(path)) return; seen.add(path); items.push({path, source}); }; for(const tc of (S.toolCalls || [])){ for(const a of _artifactCandidatesFromToolCall(tc)) push(a.path, a.kind || tc.name || 'tool'); } for(const msg of (S.messages || [])){ const text = msg && (msg.content || msg.text || msg.message || ''); for(const a of _artifactCandidatesFromText(text)) push(a.path, a.kind); } return items.slice(0, 50); } function renderSessionArtifacts(){ const root = $('workspaceArtifacts'); const count = $('workspaceArtifactsCount'); if(!root) return; const items = collectSessionArtifacts(); if(count) count.textContent = String(items.length); if(!S.session){ root.innerHTML = '
Open a conversation to see files changed in this session.
'; return; } if(!items.length){ root.innerHTML = '
No artifacts detected yet. Files created or edited during this session will appear here.
'; return; } root.innerHTML = items.map(item => ``).join(''); } function openArtifactPath(path){ if(!path) return; switchWorkspacePanelTab('files'); const rel = path.replace(/^~\//,'').replace(/^\.\//,''); openFile(rel); } async function loadDir(path){ if(!S.session)return; const sessionId=S.session.session_id; 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(sessionId)}&path=${encodeURIComponent(path)}`); if(!S.session||S.session.session_id!==sessionId)return; S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); // #2673 — refresh Artifacts tab when its source data (the file tree) updates. if(typeof renderSessionArtifacts==='function') renderSessionArtifacts(); // Pre-fetch contents of restored expanded dirs so they render without a second click // (parallelized — avoids serial waterfall when multiple dirs are expanded) if(!path||path==='.'){ const expanded=S._expandedDirs||new Set(); const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]); if(pending.length){ const results=await Promise.all(pending.map(dirPath=> api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`) .then(dc=>({dirPath,entries:dc.entries||[]})) .catch(()=>({dirPath,entries:[]})) )); if(!S.session||S.session.session_id!==sessionId)return; for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries; } if(expanded.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({keepPanelOpen:true});}); }else{ clearPreview({keepPanelOpen:true}); } } // 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; const sessionId=S.session.session_id; try{ const data=await api(`/api/git-info?session_id=${encodeURIComponent(sessionId)}`); if(!S.session||S.session.session_id!==sessionId)return; 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){ if(!S.session||S.session.session_id!==sessionId)return; 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']); const HTML_EXTS = new Set(['.html','.htm']); const PDF_EXTS = new Set(['.pdf']); const AUDIO_EXTS = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']); const VIDEO_EXTS = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']); const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024; const MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500; // Binary formats that should download rather than preview const DOWNLOAD_EXTS = new Set([ '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp', '.zip','.tar','.gz','.bz2','.7z','.rar', '.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():''; } function markdownPreviewByteLength(content){ const text=String(content||''); if(typeof Blob==='function') return new Blob([text]).size; if(typeof TextEncoder==='function') return new TextEncoder().encode(text).length; return unescape(encodeURIComponent(text)).length; } function markdownPreviewLineCount(content){ const text=String(content||''); if(!text) return 1; return text.split('\n').length; } function shouldRenderMarkdownPreviewAsPlainText(content){ return markdownPreviewByteLength(content)>MD_PREVIEW_RICH_RENDER_MAX_BYTES || markdownPreviewLineCount(content)>MD_PREVIEW_RICH_RENDER_MAX_LINES; } function largeMarkdownPlainTextStatus(content){ const bytes=markdownPreviewByteLength(content); const lines=markdownPreviewLineCount(content); const sizeLabel=bytes>=1024?`${Math.round(bytes/1024)} KB`:`${bytes} B`; return `Large markdown file (${sizeLabel}, ${lines} lines) shown as plain text. Click Edit to view raw.`; } let _previewCurrentPath = ''; // relative path of currently previewed file let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video' let _previewDirty = false; // true when edits are unsaved function showPreview(mode){ // mode: 'code' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video' $('previewCode').style.display = mode==='code' ? '' : 'none'; $('previewImgWrap').style.display = mode==='image' ? '' : 'none'; const mediaWrap=$('previewMediaWrap'); if(mediaWrap) mediaWrap.style.display = (mode==='audio'||mode==='video') ? '' : 'none'; const pdfWrap=$('previewPdfWrap'); if(pdfWrap) pdfWrap.style.display = mode==='pdf' ? '' : 'none'; $('previewMd').style.display = mode==='md' ? '' : 'none'; $('previewHtmlWrap').style.display = mode==='html' ? '' : 'none'; $('previewEditArea').style.display = 'none'; // start in read-only const badge=$('previewBadge'); badge.className='preview-badge '+mode; badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text'; _previewCurrentMode = mode; _previewDirty = false; updateEditBtn(); // Show "Open in browser" button for iframe-backed document previews const openBtn=$('btnOpenInBrowser'); if(openBtn) openBtn.style.display = (mode==='html'||mode==='pdf')?'inline-flex':'none'; } 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'; _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(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){ const mode=VIDEO_EXTS.has(ext)?'video':'audio'; showPreview(mode); const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; const wrap=$('previewMediaWrap'); if(wrap){ wrap.innerHTML=(typeof _mediaPlayerHtml==='function') ? _mediaPlayerHtml(mode,url,path.split('/').pop()||path) : `<${mode} src="${url.replace(/"/g,'%22')}" controls preload="metadata">`; if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(wrap); } } else if(PDF_EXTS.has(ext)){ showPreview('pdf'); const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; const frame=$('previewPdfFrame'); if(frame){ frame.src=''; // clear first to avoid stale content frame.src=url; frame.title=`PDF preview: ${path.split('/').pop()||path}`; } } 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)}`); _previewRawContent = data.content; if(shouldRenderMarkdownPreviewAsPlainText(data.content)){ showPreview('code'); $('previewCode').textContent=data.content; setStatus(largeMarkdownPlainTextStatus(data.content)); return; } showPreview('md'); $('previewMd').innerHTML=renderMd(data.content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }catch(e){setStatus(t('file_open_failed'));} } else if(HTML_EXTS.has(ext)){ // HTML: render in sandboxed iframe via raw endpoint. // SECURITY TRADEOFF: We use sandbox="allow-scripts" which lets inline JS run // but prevents access to the parent frame (origin isolation). This is a // deliberate choice — the user is previewing their own workspace files, so // blocking scripts entirely would break most HTML documents. The sandbox // still prevents the preview from navigating the parent, accessing cookies, // or reading other origin data. If a stricter mode is needed, remove // allow-scripts (or add sandbox="") to disable all JS execution. showPreview('html'); const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; const iframe=$('previewHtmlIframe'); if(iframe){ iframe.src=''; // clear first to avoid stale content iframe.src=url; } } 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'); $('previewCode').textContent=data.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 = () => { 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 = () => { loadDir(target); }; } else { seg.className = 'breadcrumb-seg breadcrumb-current'; } bar.appendChild(seg); } } function openInBrowser(){ if(!_previewCurrentPath||!S.session) return; const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}`; window.open(url,'_blank'); }