','Delete',
async()=>{ await delNode(n.id); persistTree(true); renderTree(); refreshApps(); toast('Deleted'); });
});
act('download',async()=>{
const n=TREE[C().id];
if(n.type==='folder') return downloadZip(n.id);
saveBlob(await readBlob(n.id),n.name);
});
act('pick', el=>{ importTarget=targetFolder(); $(el.dataset.kind==='dir'?'#dirInput':'#fileInput').click(); });
act('pickFiles',()=>{ importTarget=C().id; $('#fileInput').click(); });
act('pickDir', ()=>{ importTarget=C().id; $('#dirInput').click(); });
$('#fileInput').onchange=e=>{ importItems(fromInput(e.target.files,false),importTarget); e.target.value=''; };
$('#dirInput') .onchange=e=>{ importItems(fromInput(e.target.files,true ),importTarget); e.target.value=''; };
act('exportZip',()=>downloadZip('root'));
/* ═══ §6 TABS + VIEWS ══════════════════════════════════════════════════
One tab system. openFile() picks a view from the VIEWS table and that
view owns its pane; nothing else in the app knows what a .png is. */
let tabs=[], activeKey=null;
const panesEl=$('#panes'), tabsEl=$('#tabs');
const activeTab=()=>tabs.find(t=>t.key===activeKey);
const allPanes=fn=>tabs.forEach(fn);
function renderTabs(){
tabsEl.innerHTML=tabs.map(t=>
`
${ico(t.icon)}${esc(t.title)}${ico('x')}
`).join('');
}
act('tab', el=>setActive(el.dataset.key));
act('tabClose',el=>closeTab(el.dataset.key));
function setActive(key){
activeKey=key;
$$('.pane',panesEl).forEach(p=>p.classList.toggle('on',p.dataset.key===key));
$('#welcome').classList.toggle('on',!key);
renderTabs();
const t=activeTab(); if(t&&t.focus) t.focus();
}
async function closeTab(key){
const i=tabs.findIndex(t=>t.key===key); if(i<0) return;
const t=tabs[i];
if(t.flush) await t.flush();
if(t.destroy) t.destroy();
t.el.remove(); tabs.splice(i,1);
setActive(activeKey===key?(tabs[Math.min(i,tabs.length-1)]||{}).key||null:activeKey);
}
act('closeTab',()=>{ if(activeKey) closeTab(activeKey); });
/* Open (or focus) a tab. `mode` names the VIEWS entry that fills the pane. */
async function openTab(mode,id,extra={}){
const key=mode+':'+id;
const found=tabs.find(t=>t.key===key);
if(found) return setActive(key);
const n=TREE[id]||{name:extra.title||'untitled'};
const el=document.createElement('div');
el.className='pane'; el.dataset.key=key; panesEl.appendChild(el);
const t=Object.assign({key,id,mode,el,title:n.name,sub:TREE[id]?pathOf(id):'',
icon:VIEWS[mode].icon||type(n.name).i},extra);
tabs.push(t);
await VIEWS[mode].mount(t);
setActive(key);
return t;
}
/* The one entry point for opening anything. Binary sniffing means an
unknown extension still does the right thing without another table. */
async function openFile(id){
const n=TREE[id]; if(!n||n.type!=='file') return;
let mode=type(n.name).v||'code';
if(mode==='code'&&n.size>0){
const head=new Uint8Array(await (await readBlob(id)).slice(0,2048).arrayBuffer());
if(head.includes(0)) mode='blob'; // NUL byte ⇒ not text
if(n.size>4e6) mode='blob'; // too big to edit comfortably
}
return openTab(mode,id);
}
/* ── syntax highlighting ───────────────────────────────────────────────
One sticky-regex scanner over a per-language rule table. The last rule
in each list is the catch-all that keeps it linear over plain text. */
const GRAMMAR={
js:[
[/\/\*[\s\S]*?\*\/|\/\/[^\n]*/y,'com'],
[/`(?:\\.|[^`\\])*`|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/y,'str'],
[/\b(?:0[xXbo][\da-fA-F_]+|\d[\d_]*\.?\d*(?:[eE][+-]?\d+)?)\b/y,'num'],
[/\b(?:true|false|null|undefined|this|new|typeof|instanceof|void|delete|in|of)\b/y,'kw2'],
[/\b(?:const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|class|extends|super|import|export|from|as|default|try|catch|finally|throw|async|await|yield|static|get|set)\b/y,'kw'],
[/[A-Za-z_$][\w$]*(?=\s*\()/y,'fn'],
[/[+\-*/%=<>!&|^~?:]+/y,'op'],
[/[{}()[\];,.]/y,'punc'],
[/[A-Za-z_$][\w$]*|\s+|[^]/y,''],
],
css:[
[/\/\*[\s\S]*?\*\//y,'com'],
[/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/y,'str'],
[/@[\w-]+/y,'at'],
[/[#.]?[\w-]+(?=[^{};]*\{)/y,'sel'],
[/[\w-]+(?=\s*:)/y,'prop'],
[/#[\da-fA-F]{3,8}\b|\b\d*\.?\d+(?:px|em|rem|%|vh|vw|s|ms|deg|fr)?\b/y,'num'],
[/[{}();:,]/y,'punc'],
[/[\w-]+|\s+|[^]/y,'val'],
],
html:[
[//y,'com'],
[/]*>/iy,'at'],
[/<\/?[\w:-]+/y,'tag'],
[/'(?:\\.|[^'\\])*'|"(?:[^"]*)"/y,'str'],
[/[\w:-]+(?=\s*=)/y,'attr'],
[/\/?>/y,'tag'],
[/[^<]+|[^]/y,''],
],
};
function highlight(text,lang){
const rules=GRAMMAR[lang];
if(!rules||!SET.highlight||text.length>400000) return esc(text);
let out='',i=0;
scan: while(i${esc(m[0])}`:esc(m[0]); i+=m[0].length||1; continue scan; }
}
out+=esc(text[i++]);
}
return out;
}
/* ── the views table ───────────────────────────────────────────────────
Each entry owns one kind of pane. Adding a viewer means adding a row. */
const VIEWS={};
/* shared pane chrome, so every view looks like the others for free */
const shell=(t,toolbar,body)=>`
`);
hydrate(t.el);
}};
/* image / audio / video are the same view with a different element */
for(const [mode,tag] of [['image','img'],['audio','audio'],['video','video']]){
VIEWS[mode]={icon:mode==='image'?'img':mode==='audio'?'music':'video',async mount(t){
const n=TREE[t.id], url=URL.createObjectURL(await readBlob(t.id));
t.url=url; t.destroy=()=>URL.revokeObjectURL(url);
const attrs=tag==='img'?'':' controls'+(tag==='video'?' playsinline':'');
t.el.innerHTML=shell(t,`${bytes(n.size||0)}
`,
`