From 8155fd259e29df94927b8a03cdd24402d80a4912 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 03:08:35 +0800 Subject: [PATCH] feat: complete file manager core operations --- .../internal/api/permission_matrix_test.go | 43 +++++++++++++++++++ chfs-ng/internal/api/web/index.html | 28 +++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/chfs-ng/internal/api/permission_matrix_test.go b/chfs-ng/internal/api/permission_matrix_test.go index d516dbb..b2a7433 100644 --- a/chfs-ng/internal/api/permission_matrix_test.go +++ b/chfs-ng/internal/api/permission_matrix_test.go @@ -6,8 +6,10 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" "chfs-ng/internal/auth" @@ -149,3 +151,44 @@ func TestHTTPPermissionMatrix(t *testing.T) { }) } } + +func TestHTTPBatchDeleteAndTransferConflict(t *testing.T) { + s, root := permissionTestServer(t, auth.Read|auth.Write|auth.Delete) + h := s.Handler() + if err := os.WriteFile(filepath.Join(root, "second.txt"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + var b bytes.Buffer + mw := multipart.NewWriter(&b) + _ = mw.WriteField("filepath", "file.txt") + _ = mw.WriteField("filepath", "second.txt") + _ = mw.Close() + rr := doRequest(h, http.MethodDelete, "/chfs/rmfiles", &b, mw.FormDataContentType()) + if rr.Code != http.StatusNoContent { + t.Fatalf("batch delete: got %d", rr.Code) + } + if _, err := os.Stat(filepath.Join(root, "file.txt")); !os.IsNotExist(err) { + t.Fatalf("file.txt still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "second.txt")); !os.IsNotExist(err) { + t.Fatalf("second.txt still exists: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil { + t.Fatal(err) + } + form := url.Values{"source": {"source.txt"}, "destination": {"target.txt"}} + copyReq := doRequest(h, http.MethodPost, "/chfs/copy", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") + if copyReq.Code != http.StatusBadRequest { + t.Fatalf("copy conflict: got %d", copyReq.Code) + } + data, err := os.ReadFile(filepath.Join(root, "target.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "target" { + t.Fatalf("target overwritten: %q", data) + } +} diff --git a/chfs-ng/internal/api/web/index.html b/chfs-ng/internal/api/web/index.html index 5b00ab2..08bd4de 100644 --- a/chfs-ng/internal/api/web/index.html +++ b/chfs-ng/internal/api/web/index.html @@ -72,30 +72,38 @@ function fileDragStart(e,p){const paths=selected.has(p)?[...selected]:[p];e.data function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.querySelectorAll('.drop-target').forEach(el=>el.classList.remove('drop-target'))} function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();if(target===current||target===""||[...selected].some(p=>p===target||target.startsWith(p+'/')))return;e.dataTransfer.dropEffect="move";e.currentTarget.classList.add('drop-target')} function folderDragLeave(e){e.currentTarget.classList.remove('drop-target')} -async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');toast('正在移动 '+paths.length+' 项…');let done=0,failed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length)fail('移动完成 '+done+' 项,失败 '+failed.length+' 项');else toast('已移动 '+done+' 项')} +async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');await runOperation('移动 '+paths.length+' 项',async()=>{let done=0,failed=[],renamed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop());done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('

移动完成

'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'
');else toast('已移动 '+done+' 项')})} function activateItem(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)} function toggleTile(p){selectItem(p,!selected.has(p))} function openSelected(){if(selected.size!==1)return;const p=[...selected][0],f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)} function toggleAll(v){lastItems.forEach(f=>{const p=current?current+"/"+f.name:f.name;if(v)selected.add(p);else selected.delete(p)});render(lastItems)} function clearSelection(){selected.clear();updateSelection();renderGrid(lastItems);renderList(lastItems)} -function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";document.querySelectorAll("#files .file-row").forEach(row=>{const p=row.dataset.searchPath||row.dataset.filePath;row.classList.toggle("selected",!!p&&selected.has(p));const cb=row.querySelector(".selectbox");if(cb)cb.checked=!!p&&selected.has(p)});$("grid").querySelectorAll(".tile").forEach((el,i)=>{const f=lastItems[i];if(f){const p=current?current+"/"+f.name:f.name,yes=selected.has(p);el.classList.toggle("selected",yes);const cb=el.querySelector(".selectbox");if(cb)cb.checked=yes}})} +function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";const header=$("files").closest("table")?.querySelector("thead .selectbox");if(header){const total=lastItems.length;header.checked=total>0&&n===total;header.indeterminate=n>0&&n{const p=row.dataset.searchPath||row.dataset.filePath;row.classList.toggle("selected",!!p&&selected.has(p));const cb=row.querySelector(".selectbox");if(cb)cb.checked=!!p&&selected.has(p)});$("grid").querySelectorAll(".tile").forEach((el,i)=>{const f=lastItems[i];if(f){const p=current?current+"/"+f.name:f.name,yes=selected.has(p);el.classList.toggle("selected",yes);const cb=el.querySelector(".selectbox");if(cb)cb.checked=yes}})} function setView(v){localStorage.setItem('chfs-view',v);$("files").parentElement.hidden=v==='grid';$("grid").hidden=v!=='grid';document.querySelectorAll('.viewbtn').forEach(b=>b.classList.remove('active'));$(v+'Btn').classList.add('active')} function itemMenu(e,p){e.preventDefault();if(!selected.has(p)){selected.clear();selected.add(p);updateSelection();renderList(lastItems);renderGrid(lastItems)}const f=lastItems.find(x=>(current?current+'/':'')+x.name===p);const canRead=(f?.mask||'').includes('R'),canWrite=(f?.mask||'').includes('W'),canDelete=(f?.mask||'').includes('D');let html='';if(canRead)html+='';if(f?.dir&&canRead)html+='';if(canWrite)html+='';if(canDelete)html+='';if(f?.guestMask?.includes('R'))html+='';if(!html)html='
没有可用操作
';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+'px';$("context").style.top=Math.min(e.clientY,innerHeight-220)+'px';$("context").style.display='block'} function closeContext(){$("context").style.display='none'} addEventListener('click',e=>{if(!e.target.closest('#context')&&!e.target.closest('.tilemenu'))closeContext()}); function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q} -async function chooseDestination(title){return await ask(title,"目标目录(留空表示当前目录)",current)} -async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop();const base=String(dstDir).replace(/^\/+|\/+$/g,"");const destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";toast(action+"中…");try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});await refresh();toast(action+"完成")}catch(e){show('

'+action+'失败

'+esc(e.message)+'
')}} +let destinationResolve=null,destinationPath=""; +async function chooseDestination(title){destinationPath=current;return await new Promise(async resolve=>{destinationResolve=resolve;try{const d=await api("/chfs/roots"),roots=d.roots||[];const writableCurrent=currentMask.includes("W");if(!writableCurrent){const first=roots.find(r=>String(r.mask||"").includes("W"));if(first)destinationPath=first.path||""}await renderDestinationPicker(title)}catch(e){destinationResolve=null;fail("无法打开目标目录:"+e.message);resolve(null)}})} +async function renderDestinationPicker(title){const data=await api("/chfs/files?filepath="+encodeURIComponent(destinationPath));const dirs=(data.files||[]).filter(f=>f.dir&&(f.mask||"").includes("W"));const parent=destinationPath?destinationPath.split("/").slice(0,-1).join("/"):null;let html='

'+esc(title)+'

当前目标:'+esc(destinationPath||"全部文件")+'
';if(parent!==null)html+='';for(const d of dirs){const p=destinationPath?(destinationPath+"/"+d.name):d.name;html+=''}if(!dirs.length)html+='
这里没有可写入的子目录。
';html+='
';show(html)} +function resolveDestination(path){const resolve=destinationResolve;destinationResolve=null;closeModal();if(resolve)resolve(path)} +let operationBusy=false; +async function runOperation(label,fn){if(operationBusy)return null;operationBusy=true;toast(label+"…");try{return await fn()}finally{operationBusy=false}} +function destinationName(name,index){const dot=name.lastIndexOf("."),base=dot>0?name.slice(0,dot):name,ext=dot>0?name.slice(dot):"";return base+" ("+index+")"+ext} +async function transferWithConflict(source,destination,copyOnly){if(source===destination){if(!copyOnly)return destination;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;destination=dir?dir+"/"+destinationName(name,2):destinationName(name,2)}if(destination.startsWith(source+"/")){const e=Error("不能移动或复制到自身或子目录");e.status=400;throw e}const endpoint=copyOnly?"/chfs/copy":"/chfs/move";try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});return destination}catch(e){if(e.status!==400)throw e;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;for(let i=2;i<=20;i++){const candidate=dir?dir+"/"+destinationName(name,i):destinationName(name,i);try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination:candidate})});return candidate}catch(err){if(err.status!==400)throw err}}throw e}} +async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop(),base=String(dstDir).replace(/^\/+|\/+$/g,""),destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";await runOperation(action+"中",async()=>{try{const actual=await transferWithConflict(source,destination,copyOnly);await refresh();toast(actual===destination?action+"完成":action+"完成,已自动重命名为 "+actual.split("/").pop())}catch(e){fail(action+"失败:"+e.message)}})} async function bulkMove(){await bulkTransfer(false)} async function bulkCopy(){await bulkTransfer(true)} -async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=await chooseDestination(copyOnly?"复制选中项目到":"移动选中项目到");if(dstDir===null)return;const sources=[...selected],base=String(dstDir).replace(/^\/+|\/+$/g,""),action=copyOnly?"复制":"移动";toast(action+" "+sources.length+" 项…");const failed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});done++}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('

部分操作失败

'+esc(action+"完成 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'
');else toast(action+"完成,共 "+done+" 项")} +async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=await chooseDestination(copyOnly?"复制选中项目到":"移动选中项目到");if(dstDir===null)return;const sources=[...selected],base=String(dstDir).replace(/^\/+|\/+$/g,""),action=copyOnly?"复制":"移动";await runOperation(action+" "+sources.length+" 项",async()=>{const failed=[],renamed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{const actual=await transferWithConflict(source,destination,copyOnly);done++;if(actual!==destination)renamed.push(name+" → "+actual.split("/").pop())}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length||renamed.length){show('

'+action+'完成

'+esc(action+"成功 "+done+" 项,失败 "+failed.length+" 项"+(renamed.length?"。\n\n自动重命名:\n"+renamed.join("\n"):"。")+(failed.length?"。\n\n失败:\n"+failed.join("\n"):""))+'
')}else toast(action+"完成,共 "+done+" 项")})} +async function bulkDelete(){if(!selected.size)return;const paths=[...selected];if(!await confirmBox("确认删除",paths.length===1?"确定删除 "+paths[0]+"?":"确定删除这 "+paths.length+" 项?删除文件夹时会同时删除其中内容。"))return;await runOperation("删除中",async()=>{try{const body=new URLSearchParams();paths.forEach(p=>body.append("filepath",p));await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body});selected.clear();await refresh();toast(paths.length===1?"已删除":"已删除 "+paths.length+" 项")}catch(e){fail("删除失败:"+e.message)}})} + -async function bulkDelete(){if(!selected.size)return;if(!await confirmBox("批量删除","确定删除已选择的 "+selected.size+" 项?"))return;const targets=[...selected];toast("正在删除 "+targets.length+" 项…");const failed=[];let done=0;for(const p of targets){try{await api('/chfs/rmfiles',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath:p})});done++}catch(e){failed.push(p+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('

部分删除失败

'+esc("已删除 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'
');else toast("删除完成,共 "+done+" 项")} async function loadSideRoots(){try{const d=await api("/chfs/roots"),roots=d.roots||[];$("sideRoots").innerHTML=roots.map(r=>'').join('')||'
没有可进入的位置
'}catch{}} async function showRoots(){try{const d=await api("/chfs/roots");const roots=d.roots||[];let html='

选择位置

';for(const r of roots)html+='';if(!roots.length)html+='
没有可进入的共享位置
';html+='
';show(html)}catch(e){fail(e.message)}} function sideDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.dataTransfer.dropEffect='move';e.currentTarget.classList.add('drop-target')} function sideDragLeave(e){e.currentTarget.classList.remove('drop-target')} -async function sideDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};if(!paths.length)return;toast('正在移动 '+paths.length+' 项…');let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch{}}selected.clear();await refresh();toast('已移动 '+done+' 项')} +async function sideDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};if(!paths.length)return;await runOperation('正在移动 '+paths.length+' 项',async()=>{const failed=[],renamed=[];let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);done++;if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop())}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('

移动完成

'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'
');else toast('已移动 '+done+' 项')})} function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()} function renderBreadcrumb(){const el=$("crumb"),parts=current?current.split("/").filter(Boolean):[];const rootName=navigationRoot?(navigationRoot===me?.home?'我的空间':'当前位置'): '全部文件';let html='';let acc='';for(let i=0;i'+esc(parts[i])+''}el.innerHTML=html} @@ -104,8 +112,8 @@ function openDir(p){current=p;refresh()}function goUp(){if(!current||current===n function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")} function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)} async function shareItem(p){const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;try{await navigator.clipboard.writeText(url);toast("分享链接已复制")}catch{show("

分享链接

")}} -async function renameItem(p){const name=p.split("/").pop();const n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");toast("正在重命名…");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});await refresh();toast("已重命名")}catch(e){fail(e.message)}} -async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;toast("正在删除…");try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("已删除")}catch(e){fail(e.message)}} +async function renameItem(p){const name=p.split("/").pop(),n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");await runOperation("正在重命名",async()=>{try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});selected.clear();await refresh();toast("已重命名")}catch(e){if(e.status===400){const retry=await ask("目标已存在","请输入新的名称",n);if(retry&&retry!==n){try{const retryDest=(p.split("/").slice(0,-1).concat(retry)).join("/");await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:retryDest})});selected.clear();await refresh();toast("已重命名")}catch(err){fail("重命名失败:"+err.message)}}}else fail("重命名失败:"+e.message)}})} +async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;await runOperation("正在删除",async()=>{try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});selected.delete(p);await refresh();toast("已删除")}catch(e){fail("删除失败:"+e.message)}})} async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;toast("正在创建文件夹…");try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("文件夹已创建")}catch(e){fail(e.message)}} async function newText(){const n=await ask("新建文本","文件名","new.txt");if(!n)return;const text=await ask("新建文本","文本内容");if(text===null)return;toast("正在创建文本…");try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});await refresh();toast("文本已创建")}catch(e){fail(e.message)}} $("upload").addEventListener("change",async e=>{await uploadFiles(e.target.files);e.target.value=""}) @@ -149,7 +157,7 @@ async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{" async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()} function toast(message,error=false){const host=$("toastHost"),el=document.createElement("div");el.className='toast'+(error?' error':'');el.textContent=message;host.appendChild(el);setTimeout(()=>el.remove(),2800)} function fail(message){toast(message,true)} -function keyboardNavigation(e){const tag=(e.target?.tagName||"").toLowerCase();if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||tag==="a"||e.target?.isContentEditable)return;if(e.key==="Escape"){if($("modal").classList.contains("show"))closeModal();else closeContext();return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="a"){e.preventDefault();toggleAll(true);return}if(e.key==="Enter"){e.preventDefault();openSelected();return}if(e.key==="Delete"&&selected.size){e.preventDefault();bulkDelete();return}if(e.key==="Backspace"&¤t){e.preventDefault();goUp()}} +function keyboardNavigation(e){const tag=(e.target?.tagName||"").toLowerCase();if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||tag==="a"||e.target?.isContentEditable)return;if(e.key==="Escape"){if($("modal").classList.contains("show"))closeModal();else closeContext();return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="a"){e.preventDefault();toggleAll(true);return}if(e.key==="Enter"){e.preventDefault();openSelected();return}if(e.key==="Delete"&&selected.size){e.preventDefault();bulkDelete();return}if(e.key==="Backspace"&¤t){e.preventDefault();goUp();return}if((e.key==="ArrowDown"||e.key==="ArrowUp")&&lastItems.length){e.preventDefault();const paths=selectablePaths(),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],idx=Math.max(0,paths.indexOf(anchor)),next=Math.max(0,Math.min(paths.length-1,idx+(e.key==="ArrowDown"?1:-1)));selectItem(paths[next],true,e);if(e.shiftKey)selectionAnchor=anchor;return}if(e.key==="Home"&&lastItems.length){e.preventDefault();selectItem(selectablePaths()[0],true,e);return}if(e.key==="End"&&lastItems.length){e.preventDefault();selectItem(selectablePaths().at(-1),true,e)}} addEventListener("keydown",keyboardNavigation) function show(html,preview=false){$("dialog").innerHTML=html;$("dialog").classList.toggle("preview-dialog",preview);$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show");$("dialog").classList.remove("preview-dialog")} function ask(title,label,value="",type="text"){return new Promise(resolve=>{show('

'+esc(title)+'

');window._askResolve=v=>{closeModal();resolve(v)}})}