🔧 Initial dev copy from live
This commit is contained in:
438
static/workspace.js
Normal file
438
static/workspace.js
Normal file
@@ -0,0 +1,438 @@
|
||||
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 <img>
|
||||
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,'>').replace(/"/g,'"');}
|
||||
|
||||
function _highlightWithLineNumbers(text){
|
||||
return text.split('\n').map(line=>'<span class="code-line">'+_escHtml(line)+'</span>').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<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
|
||||
// Highlight string values (after colon)
|
||||
line=line.replace(/(:\s*)("[^&]*?")/g,'$1<span class="hl-string">$2</span>');
|
||||
// Highlight numbers
|
||||
line=line.replace(/:\s*(\d+\.?\d*)/g,': <span class="hl-number">$1</span>');
|
||||
// Highlight booleans / null
|
||||
line=line.replace(/:\s*(true|false|null)/g,': <span class="hl-bool">$1</span>');
|
||||
return '<span class="code-line">'+line+'</span>';
|
||||
}).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='<div class="ws-no-results" style="opacity:.5">Suche...</div>';
|
||||
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='<div class="ws-no-results">Keine Dateien gefunden</div>';
|
||||
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='<div class="ws-no-results">Keine Dateien gefunden</div>';
|
||||
}else{
|
||||
for(const item of matches){
|
||||
const el=document.createElement('div');
|
||||
el.className='file-item';el.style.paddingLeft='10px';
|
||||
el.innerHTML='<span class="file-icon">'+fileIcon(item.name,item.type)+'</span><span class="file-name">'+item.name+'</span>';
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user