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; const timeoutToast=opts.timeoutToast!==false; // 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; delete fetchOpts.timeoutToast; 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(timeoutToast&&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,timeoutToast:false}).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' : tab === 'todos' ? 'todos' : 'files'; _setWorkspacePanelTabDataset(); const filesTab = $('workspaceFilesTab'); const artifactsTab = $('workspaceArtifactsTab'); const todosTab = $('workspaceTodosTab'); 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'); } if(todosTab){ todosTab.classList.toggle('active', _workspacePanelActiveTab === 'todos'); todosTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'todos' ? 'true' : 'false'); } const artifacts = $('workspaceArtifacts'); if(artifacts) artifacts.hidden = _workspacePanelActiveTab !== 'artifacts'; const todosPanel = $('workspaceTodosPanel'); if(todosPanel) todosPanel.hidden = _workspacePanelActiveTab !== 'todos'; if(_workspacePanelActiveTab === 'artifacts') renderSessionArtifacts(); if(_workspacePanelActiveTab === 'todos') _loadWorkspacePanelTodos(); } function _loadWorkspacePanelTodos(){ const panel = $('workspaceTodosPanel'); if(!panel) return; let todos = []; try{ if(S && Array.isArray(S.todos)){ todos = S.todos; } else if(S && S.session && S.session.todo_state && Array.isArray(S.session.todo_state.todos)){ todos = S.session.todo_state.todos; } else if(typeof _legacyTodosFromMessages === 'function'){ todos = _legacyTodosFromMessages() || []; } }catch(e){ todos = []; } if(!todos.length){ panel.innerHTML = '
, so a previously-previewed code file leaves e.g.
// "language-css" on #previewCode. A subsequent plain-text file builds a
// class-less , and Prism walks up to that stale ancestor class and
// mis-highlights prose. Strip any inherited language-* token from the
// before each render so highlighting never leaks across files.
pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
pre.appendChild(codeEl);
// Only invoke Prism when we actually assigned a language; otherwise the
// class-less would inherit any ancestor language-* class.
if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
Prism.highlightElement(codeEl);
}
}
function renderCsvPreviewContent(path, content){
if(typeof buildCsvTablePreview!=='function') return false;
const preview=buildCsvTablePreview(path, content);
if(!preview) return false;
showPreview('csv');
// Preserve the raw CSV text so the Edit flow can repopulate the textarea and
// a save can re-render the table from the edited source (#4025 review, Codex).
if(typeof content==='string'){
_previewRawContent = content;
_previewRawContentPath = path;
}
if(preview.html){
$('previewMd').innerHTML=preview.html;
return true;
}
if(preview.errorKey&&typeof _csvPreviewErrorHtml==='function'){
$('previewMd').innerHTML=_csvPreviewErrorHtml(path, preview.errorKey);
return true;
}
return false;
}
function forceRenderMarkdownPreview(){
// #3378 review (Codex): don't force-render from a dirty/open editor — the
// cached raw content would not reflect the unsaved edit. Require a saved,
// non-dirty state and cached content that belongs to the current file.
if(_previewDirty || $('previewEditArea').style.display!=='none') return;
if(!_previewRawContent || _previewRawContentPath!==_previewCurrentPath) return;
openFile(_previewCurrentPath,{forceRichMarkdown:true});
setStatus('Markdown rendered for this file.');
}
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'csv' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'csv' | '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'||mode==='csv') ? '' : '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==='csv'?'csv':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';
setLargeMarkdownForceRenderVisible(false);
}
function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv';
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 AND the cached raw content so a later
// "Render as markdown anyway" force-render reflects the just-saved text
// (not the stale pre-edit fetch). #3378 review (Codex).
_previewRawContent = content;
_previewRawContentPath = _previewCurrentPath;
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, content);
else renderMarkdownPreviewContent({content});
$('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)
let _previewRawContentPath = ''; // path that _previewRawContent belongs to (#3378 force-render cache guard)
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();
}
// Map file extensions to Prism.js language identifiers.
// Prism autoloader fetches missing language components from CDN on demand.
const _PRISM_LANG_MAP={
js:'javascript',mjs:'javascript',jsx:'jsx',ts:'typescript',tsx:'tsx',
py:'python',pyw:'python',pyi:'python',
rb:'ruby',go:'go',rs:'rust',java:'java',kt:'kotlin',kts:'kotlin',
c:'c',h:'c',cpp:'cpp',cxx:'cpp',hpp:'cpp',cc:'cpp',
cs:'csharp',swift:'swift',scala:'scala',
php:'php',pl:'perl',pm:'perl',r:'r',lua:'lua',
sh:'bash',bash:'bash',zsh:'bash',fish:'bash',
ps1:'powershell',psm1:'powershell',
sql:'sql',graphql:'graphql',
json:'json',yaml:'yaml',yml:'yaml',toml:'toml',xml:'xml',
html:'markup',htm:'markup',svg:'markup',vue:'markup',
css:'css',scss:'scss',sass:'sass',less:'less',
md:'markdown',markdown:'markdown',
dockerfile:'docker',makefile:'makefile',cmake:'cmake',
ini:'ini',cfg:'ini',conf:'ini',properties:'properties',
diff:'diff',patch:'diff',
txt:'',log:'',csv:'',tsv:'',
};
const _PRISM_BASENAME_LANG_MAP={
'dockerfile':'docker','makefile':'makefile','gnumakefile':'makefile',
'cmakelists.txt':'cmake',
'.gitignore':'ignore','.dockerignore':'ignore',
};
function _prismLanguageForPath(path){
const base=String(path||'').split(/[\\/]/).pop().toLowerCase();
if(base.startsWith('dockerfile.')) return 'docker';
if(_PRISM_BASENAME_LANG_MAP[base]!==undefined) return _PRISM_BASENAME_LANG_MAP[base];
const ext=fileExt(path).replace(/^\./,'');
return _PRISM_LANG_MAP[ext]!==undefined?_PRISM_LANG_MAP[ext]:'plaintext';
}
async function openFile(path, opts={}){
if(!S.session)return;
const ext=fileExt(path);
const bustCache=!!(opts&&opts.bustCache);
const forceRichMarkdown=!!(opts&&opts.forceRichMarkdown);
const cacheBust=bustCache?`&_=${Date.now()}`:'';
// 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)}${cacheBust}`;
$('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${cacheBust}`;
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">${mode}>`;
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${cacheBust}`;
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{
// #3378 review (Codex): only reuse cached raw content when it actually
// belongs to the requested path. `path===_previewCurrentPath` is tautological
// here (_previewCurrentPath was just assigned above), so guard on the
// dedicated _previewRawContentPath instead — otherwise a force-render after a
// file switch could re-render the previous file's cached content.
const data=forceRichMarkdown&&path===_previewRawContentPath&&_previewRawContent
? {content:_previewRawContent}
: await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
_previewRawContent = data.content;
_previewRawContentPath = path;
if(!forceRichMarkdown && shouldRenderMarkdownPreviewAsPlainText(data.content)){
showPreview('code');
$('previewCode').textContent=data.content;
setLargeMarkdownForceRenderVisible(true);
setStatus(largeMarkdownPlainTextStatus(data.content));
return;
}
renderMarkdownPreviewContent(data);
}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${cacheBust}`;
const iframe=$('previewHtmlIframe');
if(iframe){
iframe.src=''; // clear first to avoid stale content
iframe.src=url;
}
} else if(ext==='.csv'){
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
if(data.binary){
downloadFile(path);
return;
}
if(renderCsvPreviewContent(path, data.content)) return;
renderCodePreviewContent(path, data.content);
}catch(e){
downloadFile(path);
}
} 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;
}
renderCodePreviewContent(path, 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)}&inline=1`;
window.open(url,'_blank','noopener');
}
// ── Workspace upload ──────────────────────────────────────────────────
function triggerWorkspaceUpload() {
const input = $('workspaceFileInput');
if (!input) return;
input.value = '';
input.onchange = async () => {
const files = input.files;
if (!files || !files.length) return;
for (const file of files) {
await uploadToWorkspace(file, S.currentDir || '.');
}
if (S.session) loadDir(S.currentDir);
};
input.click();
}
async function uploadToWorkspace(file, dir) {
if (!S.session) return;
const formData = new FormData();
formData.append('session_id', S.session.session_id);
formData.append('path', dir || '.');
formData.append('file', file, file.name);
try {
showToast(t('uploading') || 'Uploading\u2026', 2000);
const data = await api('/api/workspace/upload', {
method: 'POST',
body: formData,
headers: {},
timeoutMs: 120000,
});
if (data && data.error) {
showToast(data.error, 5000, 'error');
} else if (data && (data.extract_error || (Array.isArray(data.files) && data.files.some(function(f){return f && f.extract_error;})))) {
// Archive was rejected (zip-slip / zip-bomb / corrupt / too-many-members):
// the file uploaded but extraction failed. Surface it as an error instead
// of a misleading "Uploaded" success toast.
var msg = data.extract_error
|| (data.files.find(function(f){return f && f.extract_error;}) || {}).extract_error
|| 'Archive extraction failed';
showToast(msg, 5000, 'error');
} else {
showToast(t('uploaded') || ('Uploaded ' + (data.filename || file.name)), 2000);
}
} catch (e) {
showToast(t('upload_failed') || ('Upload failed: ' + e.message), 5000, 'error');
}
}
function _isOsFilesDrag(e) {
return !!(e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files'));
}
function _joinWorkspacePath(base, rel) {
const b = base || '.';
const r = (rel || '').replace(/^\/+|\/+$/g, '');
if (!r) return b;
return b === '.' ? r : `${b}/${r}`;
}
function _targetDirForRelDir(destDir, relDir) {
const dirPart = (relDir || '').replace(/\/+$/, '');
if (!dirPart) return destDir || '.';
return _joinWorkspacePath(destDir, dirPart);
}
async function _readAllDirectoryEntries(reader) {
const entries = [];
while (true) {
const batch = await new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
if (!batch.length) break;
entries.push(...batch);
}
return entries;
}
async function _collectFilesFromEntry(entry, relPrefix) {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
return [{ file, relDir: relPrefix || '' }];
}
if (!entry.isDirectory) return [];
const reader = entry.createReader();
const children = await _readAllDirectoryEntries(reader);
const dirPrefix = `${relPrefix || ''}${entry.name}/`;
let out = [];
for (const child of children) {
out = out.concat(await _collectFilesFromEntry(child, dirPrefix));
}
return out;
}
async function _collectOsDropUploads(dataTransfer) {
const out = [];
const items = dataTransfer.items ? [...dataTransfer.items] : [];
if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
for (const item of items) {
if (item.kind !== 'file') continue;
const entry = item.webkitGetAsEntry();
if (!entry) continue;
out.push(...await _collectFilesFromEntry(entry, ''));
}
if (out.length) return out;
}
for (const file of dataTransfer.files) {
out.push({ file, relDir: '' });
}
return out;
}
async function uploadOsDropToWorkspace(dataTransfer, destDir) {
if (!S.session || !dataTransfer) return;
const uploads = await _collectOsDropUploads(dataTransfer);
for (const { file, relDir } of uploads) {
await uploadToWorkspace(file, _targetDirForRelDir(destDir, relDir));
}
if (S.session) await loadDir(S.currentDir);
}
function _clearWorkspaceOsUploadDragOver() {
document.querySelectorAll('.file-item.drag-over-upload,.breadcrumb-seg.drag-over-upload').forEach((el) => {
el.classList.remove('drag-over-upload');
});
}
function _bindWorkspaceOsUploadDropTarget(el, destDir) {
// Use addEventListener (not on-property assignment) so these OS-upload
// handlers COMPOSE with the workspace tree-MOVE handlers bound by
// _bindWorkspaceMoveDropTarget() on the same element. A property assignment
// for the drop handler here would overwrite the move handler, and a
// workspace-file drag would fall through to the document drop (inserting
// @path into the composer) instead of moving the file. Each handler gates on
// its own drag type (_isOsFilesDrag vs _isWorkspaceTreeMoveDrag), so only the
// matching one acts.
el.addEventListener('dragenter', (e) => {
if (!_isOsFilesDrag(e)) return;
e.preventDefault();
e.stopPropagation();
el.classList.add('drag-over-upload');
});
el.addEventListener('dragover', (e) => {
if (!_isOsFilesDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
el.classList.add('drag-over-upload');
});
el.addEventListener('dragleave', (e) => {
if (el.contains(e.relatedTarget)) return;
el.classList.remove('drag-over-upload');
});
el.addEventListener('drop', async (e) => {
if (!_isOsFilesDrag(e)) return;
e.preventDefault();
e.stopPropagation();
el.classList.remove('drag-over-upload');
await uploadOsDropToWorkspace(e.dataTransfer, destDir);
});
}
// Drag-and-drop files onto workspace file tree
if (typeof document !== 'undefined') {
const _wsUploadInit = () => {
const tree = $('fileTree');
if (!tree) return;
tree.addEventListener('dragenter', (e) => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
}
});
tree.addEventListener('dragover', (e) => {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
e.dataTransfer.dropEffect = 'copy';
tree.classList.add('drag-over-upload');
}
});
tree.addEventListener('dragleave', (e) => {
if (tree.contains(e.relatedTarget)) return;
tree.classList.remove('drag-over-upload');
});
tree.addEventListener('drop', async (e) => {
tree.classList.remove('drag-over-upload');
if (!e.dataTransfer || !e.dataTransfer.types || !e.dataTransfer.types.includes('Files')) return;
if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
e.preventDefault();
e.stopPropagation();
await uploadOsDropToWorkspace(e.dataTransfer, S.currentDir || '.');
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _wsUploadInit, {once: true});
} else {
_wsUploadInit();
}
}