mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-08 16:52:02 +00:00
4e5eebd0d6
Surfaces the Hermes agent's LLM Wiki as a read-only knowledge-base observability section in Insights (status counts + browsable read-only pages). Unconfigured shows a clear "Unavailable" empty state. Strictly read-only, auth-gated. The wiki file-serving path is hardened against the full traversal/symlink/TOCTOU class (Codex + Opus review, each vector regression-tested + toothless-checked): - _llm_wiki_page_files resolves the wiki root once as the trust base; skips a section whose resolved path escapes the root; requires each *.md target's RESOLVED path under both real root and real section; rejects dot-prefixed segments on lexical AND resolved-relative paths (closes symlink-to-hidden/out-of-tree + symlinked-section escapes). Browse + read share this allowlist. - /api/wiki/page rejects abs paths + a real '..' SEGMENT (legit v1..v2.md still opens); requires the resolved target in the allowlist; reads the resolved path with O_NOFOLLOW; and fstat-verifies the open fd's (st_dev, st_ino) against the identity captured at allowlist time — closing final-component AND parent-directory symlink-swap TOCTOU races. Any mismatch/vanish -> clean 404, never a 500. Co-authored-by: Rod Boev <rod.boev@gmail.com>
1125 lines
47 KiB
JavaScript
1125 lines
47 KiB
JavaScript
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;
|
|
const maxAttempts=Object.prototype.hasOwnProperty.call(opts,'retries')?Math.max(0,Number(opts.retries)||0)+1:3;
|
|
const retryTimeouts=opts.retryTimeouts===true;
|
|
const retryStatuses=Array.isArray(opts.retryStatuses)?opts.retryStatuses.map(Number).filter(Number.isFinite):[];
|
|
const retryDelayMs=Object.prototype.hasOwnProperty.call(opts,'retryDelayMs')?Math.max(0,Number(opts.retryDelayMs)||0):350;
|
|
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
|
|
// Callers may opt into retrying timeouts / transient server statuses for idempotent GETs.
|
|
let lastErr;
|
|
for(let attempt=0;attempt<maxAttempts;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;
|
|
delete fetchOpts.retries;
|
|
delete fetchOpts.retryTimeouts;
|
|
delete fetchOpts.retryStatuses;
|
|
delete fetchOpts.retryDelayMs;
|
|
|
|
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){
|
|
if(retryTimeouts&&attempt<2&&attempt<maxAttempts-1){
|
|
if(retryDelayMs) await new Promise(resolve=>setTimeout(resolve,retryDelayMs*Math.pow(2,attempt)));
|
|
continue;
|
|
}
|
|
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&&attempt<maxAttempts-1 && (e instanceof TypeError || retryStatuses.includes(Number(e.status)))){
|
|
if(retryDelayMs) await new Promise(resolve=>setTimeout(resolve,retryDelayMs*Math.pow(2,attempt)));
|
|
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 = '<div style="padding:24px 12px;text-align:center;color:var(--muted);font-size:12px">No active tasks</div>';
|
|
return;
|
|
}
|
|
const statusIcon = (s) => {
|
|
if(s === 'completed') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
if(s === 'in_progress') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--blue)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
if(s === 'cancelled') return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
// pending
|
|
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/></svg>';
|
|
};
|
|
const items = todos.map(t => {
|
|
const s = t.status || 'pending';
|
|
const isDone = s === 'completed' || s === 'cancelled';
|
|
return `<div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">`+
|
|
`<span style="flex-shrink:0;margin-top:2px">${statusIcon(s)}</span>`+
|
|
`<span style="font-size:12px;color:${isDone?'var(--muted)':'var(--text)'};text-decoration:${s==='cancelled'?'line-through':'none'}">${_escHtml(t.content||t.text||'')}</span>`+
|
|
`</div>`;
|
|
}).join('');
|
|
panel.innerHTML = `<div style="padding:4px 0">${items}</div>`;
|
|
}
|
|
|
|
function _escHtml(s){
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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 '';
|
|
// Canonicalize workspace-relative prefixes so a file-tree open ("foo.md") and a
|
|
// tool arg recorded as "./foo.md" or "~/foo.md" compare equal for mutation
|
|
// tracking; otherwise an agent edit via a ./-prefixed path leaves the open
|
|
// preview stale (#3262 / pre-release regression-gate finding).
|
|
path = path.replace(/^~\//,'').replace(/^(?:\.\/)+/,'');
|
|
if(!path) 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;
|
|
}
|
|
|
|
const _turnMutatedPreviewPaths = new Set();
|
|
|
|
function resetTurnWorkspaceMutations(){
|
|
_turnMutatedPreviewPaths.clear();
|
|
}
|
|
|
|
function noteWorkspaceMutationsFromToolCall(tc){
|
|
for(const a of _artifactCandidatesFromToolCall(tc)){
|
|
const path=_normalizeArtifactPath(a.path);
|
|
if(path) _turnMutatedPreviewPaths.add(path);
|
|
}
|
|
}
|
|
|
|
function noteWorkspaceMutationsFromToolCalls(toolCalls){
|
|
if(!Array.isArray(toolCalls)) return;
|
|
for(const tc of toolCalls) noteWorkspaceMutationsFromToolCall(tc);
|
|
}
|
|
|
|
function _isOpenPreviewPathMutated(){
|
|
if(!_previewCurrentPath) return false;
|
|
const current=_normalizeArtifactPath(_previewCurrentPath);
|
|
return !!(current&&_turnMutatedPreviewPaths.has(current));
|
|
}
|
|
|
|
async function refreshOpenPreviewIfMutated(){
|
|
if(typeof _previewDirty!=='undefined'&&_previewDirty) return;
|
|
if(!_isOpenPreviewPathMutated()) return;
|
|
if(!_previewCurrentPath||!S.session) return;
|
|
await openFile(_previewCurrentPath, { bustCache: true });
|
|
}
|
|
|
|
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});
|
|
};
|
|
// Source 1: session-level tool call summaries (may be empty when messages
|
|
// carry their own tool metadata — see _syncToolCallsForLoadedMessages).
|
|
for(const tc of (S.toolCalls || [])){
|
|
for(const a of _artifactCandidatesFromToolCall(tc)) push(a.path, a.kind || tc.name || 'tool');
|
|
}
|
|
// Source 2 & 3: message-level data — both text-mined diffs and structured
|
|
// tool_calls / tool_use content blocks that survive the S.toolCalls clear.
|
|
for(const msg of (S.messages || [])){
|
|
if(!msg) continue;
|
|
const text = msg.content || msg.text || msg.message || '';
|
|
// Text-mined diff/patch fences (existing path).
|
|
if(typeof text === 'string'){
|
|
for(const a of _artifactCandidatesFromText(text)) push(a.path, a.kind);
|
|
}
|
|
// Structured tool_calls array (OpenAI format: {function:{name,arguments}}).
|
|
if(Array.isArray(msg.tool_calls)){
|
|
for(const tc of msg.tool_calls){
|
|
if(!tc || typeof tc !== 'object') continue;
|
|
const fn = (tc.function && typeof tc.function === 'object') ? tc.function : tc;
|
|
const name = fn.name || tc.name || '';
|
|
let args = fn.arguments || tc.arguments || tc.args || tc.input || {};
|
|
if(typeof args === 'string'){ try{ args = JSON.parse(args); }catch(_){} }
|
|
const fakeTc = {name, args, result: tc.result || tc.output || ''};
|
|
for(const a of _artifactCandidatesFromToolCall(fakeTc)) push(a.path, a.kind || name || 'tool');
|
|
}
|
|
}
|
|
// Structured content array with tool_use blocks (Anthropic format).
|
|
if(Array.isArray(msg.content)){
|
|
for(const block of msg.content){
|
|
if(!block || block.type !== 'tool_use') continue;
|
|
let inp = block.input || {};
|
|
if(typeof inp === 'string'){ try{ inp = JSON.parse(inp); }catch(_){} }
|
|
const fakeTc = {name: block.name || '', args: inp, result: block.result || ''};
|
|
for(const a of _artifactCandidatesFromToolCall(fakeTc)) push(a.path, a.kind || block.name || 'tool');
|
|
}
|
|
}
|
|
}
|
|
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 = '<div class="workspace-artifact-empty">Open a conversation to see files changed in this session.</div>';
|
|
return;
|
|
}
|
|
if(!items.length){
|
|
root.innerHTML = '<div class="workspace-artifact-empty">No artifacts detected yet. Files created or edited during this session will appear here.</div>';
|
|
return;
|
|
}
|
|
// Strip workspace prefix for display so long absolute paths don't clutter the list.
|
|
const ws = S.session && S.session.workspace;
|
|
const normWs = ws ? ws.replace(/\/+$/,'') + '/' : '';
|
|
const displayPath = (p) => {
|
|
if(normWs && p.startsWith(normWs)) return p.slice(normWs.length);
|
|
return p;
|
|
};
|
|
root.innerHTML = items.map(item => `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-path">${esc(displayPath(item.path))}</div><div class="workspace-artifact-meta">${esc(item.source || 'session')}</div></button>`).join('');
|
|
}
|
|
|
|
async function _workspacePathExists(path){
|
|
if(!S.session||!path) return false;
|
|
const parts=String(path).split('/').filter(Boolean);
|
|
const name=parts.pop();
|
|
if(!name) return false;
|
|
const dir=parts.length?parts.join('/'):'.';
|
|
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dir)}`);
|
|
return (data.entries||[]).some(entry=>entry&&((entry.path===path)||entry.name===name));
|
|
}
|
|
|
|
async function openArtifactPath(path){
|
|
if(!path) return;
|
|
switchWorkspacePanelTab('files');
|
|
let rel = path.replace(/^~\//,'').replace(/^\.\/+/,'');
|
|
// Strip workspace prefix so /api/list receives a workspace-relative path.
|
|
const ws = S.session && S.session.workspace;
|
|
if(ws){
|
|
const normWs = ws.replace(/\/+$/,'') + '/';
|
|
if(rel.startsWith(normWs)) rel = rel.slice(normWs.length);
|
|
else if(rel === ws.replace(/\/+$/,'')) rel = '.';
|
|
}
|
|
if(!rel) rel = '.';
|
|
try{
|
|
if(!(await _workspacePathExists(rel))){
|
|
setStatus(t('file_open_failed'));
|
|
return;
|
|
}
|
|
}catch(_){
|
|
setStatus(t('file_open_failed'));
|
|
return;
|
|
}
|
|
openFile(rel);
|
|
}
|
|
|
|
async function loadDir(path, opts={}){
|
|
const preservePreview=!!(opts&&opts.preservePreview);
|
|
const refreshExpanded=!!(opts&&opts.refreshExpanded);
|
|
if(!S.session)return;
|
|
const sessionId=S.session.session_id;
|
|
try{
|
|
if(!path||path==='.'||refreshExpanded){
|
|
S._dirCache={};
|
|
_restoreExpandedDirs(); // restore per-workspace expanded state after root and refresh resets
|
|
}
|
|
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==='.'||refreshExpanded){
|
|
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(!preservePreview&&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});
|
|
}
|
|
}else if(preservePreview){
|
|
await refreshOpenPreviewIfMutated();
|
|
}
|
|
// Fetch git info for workspace root (non-blocking)
|
|
if(!path||path==='.') _refreshGitBadge();
|
|
}catch(e){console.warn('loadDir',e);}
|
|
}
|
|
|
|
function refreshWorkspacePanel(){
|
|
if(!S.session)return;
|
|
const targetDir = S.currentDir || '.';
|
|
loadDir(targetDir,{refreshExpanded:true});
|
|
}
|
|
|
|
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 = 256 * 1024;
|
|
const MD_PREVIEW_RICH_RENDER_MAX_LINES = 5000;
|
|
// 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 "Render as markdown anyway" to force rich rendering, or Edit to view raw.`;
|
|
}
|
|
|
|
function setLargeMarkdownForceRenderVisible(visible){
|
|
const btn=$('btnRenderMarkdownAnyway');
|
|
if(btn) btn.style.display=visible?'inline-flex':'none';
|
|
}
|
|
|
|
function renderMarkdownPreviewContent(data){
|
|
const target=data&&data.el?data.el:$('previewMd');
|
|
if(!data||!data.el) showPreview('md');
|
|
target.innerHTML=renderMd(data.content);
|
|
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
|
}
|
|
|
|
function renderCodePreviewContent(path, content){
|
|
showPreview('code');
|
|
const codeEl=document.createElement('code');
|
|
codeEl.textContent=content;
|
|
const lang=_prismLanguageForPath(path);
|
|
if(lang) codeEl.className='language-'+lang;
|
|
const pre=$('previewCode');
|
|
pre.textContent='';
|
|
// Prism.highlightElement() propagates the language-* class onto the
|
|
// parent <pre>, so a previously-previewed code file leaves e.g.
|
|
// "language-css" on #previewCode. A subsequent plain-text file builds a
|
|
// class-less <code>, and Prism walks up to that stale ancestor class and
|
|
// mis-highlights prose. Strip any inherited language-* token from the
|
|
// <pre> 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 <code> 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 <img>
|
|
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"],.file-item[data-ws-is-dir="true"],.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"],.file-item[data-ws-is-dir="true"],.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();
|
|
}
|
|
}
|