From d8b8012e40075304c80977bf0dc93e0b78fa8843 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Wed, 23 Sep 2026 18:50:21 -0700 Subject: [PATCH] Add an opt-in context menu for files and directories Apps set browser.contextMenu to a list of { label, action, when?, danger? } items; when(entry, path) scopes an item to directories, files, or any other match. Right-click, the Menu key, or a touch long press opens it. The demo adds sample items, including a .json-only one. Co-Authored-By: Claude Opus 5.5 --- demo/assets/index-BJSID0yX.js | 1 + ...{index-rQJBLWWV.css => index-Bx92WdqS.css} | 2 +- demo/assets/index-CRttpLjx.js | 1 - demo/index.html | 5 +- index.html | 1 + octopus-file-browser.d.ts | 10 +- src/demo.css | 7 + src/demo.js | 91 +++++++++++-- src/file-browser-utils.js | 4 + src/octopus-file-browser.css | 46 +++++++ src/octopus-file-browser.js | 128 +++++++++++++++++- test/file-browser-utils.test.js | 14 +- 12 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 demo/assets/index-BJSID0yX.js rename demo/assets/{index-rQJBLWWV.css => index-Bx92WdqS.css} (61%) delete mode 100644 demo/assets/index-CRttpLjx.js diff --git a/demo/assets/index-BJSID0yX.js b/demo/assets/index-BJSID0yX.js new file mode 100644 index 0000000..9f7cf8e --- /dev/null +++ b/demo/assets/index-BJSID0yX.js @@ -0,0 +1 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=`:host{--ofb-border:#d0d7de;--ofb-text:#181c21;--ofb-muted:#57606a;--ofb-link:#1f2328;--ofb-folder:#54aeff;--ofb-hover:#f6f8fa;--ofb-focus:#0969da;--ofb-recent:#d15700;--ofb-danger:#cf222e;color:var(--ofb-text);min-width:0;font:14px/20px -apple-system,BlinkMacSystemFont,Segoe UI,system-ui,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif;display:block}*{box-sizing:border-box}.list{border:1px solid var(--ofb-border);border-radius:5px;margin:0;padding:0;list-style:none;overflow:hidden}.entry{appearance:none;border:0;border-top:1px solid var(--ofb-border);color:inherit;cursor:pointer;font:inherit;text-align:left;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;background:#fff;grid-template-columns:minmax(0,1fr) 120px;width:100%;min-height:37px;margin:0;padding:0;display:grid}li:first-child .entry{border-top:0}.entry:hover{background:var(--ofb-hover)}.entry:active{background:#eef1f4}.entry:focus-visible{box-shadow:inset 0 0 0 2px var(--ofb-focus);outline:0}.cell{align-items:center;min-width:0;padding:8px 10px;display:flex}.name{gap:8px}.icon{object-fit:contain;flex:none;width:18px;height:18px}.label,.modified{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.label{color:var(--ofb-link);min-width:0}.size{color:var(--ofb-muted);white-space:nowrap;flex:none;margin-left:8px;font-size:12px}.modified{color:var(--ofb-muted)}.modified.recent{color:var(--ofb-recent)}.modified{justify-content:flex-end}.empty{color:var(--ofb-muted);text-align:center;margin:0;padding:24px}.menu{border:1px solid var(--ofb-border);color:var(--ofb-text);font:inherit;z-index:2147483647;background:#fff;border-radius:6px;min-width:180px;max-width:min(280px,100vw - 8px);max-height:calc(100vh - 8px);margin:0;padding:4px;position:fixed;inset:auto;overflow-y:auto;box-shadow:0 8px 24px #8c959f40}.menu-item{appearance:none;color:inherit;cursor:pointer;font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap;background:0 0;border:0;border-radius:4px;width:100%;padding:6px 10px;display:block;overflow:hidden}.menu-item:hover,.menu-item:focus-visible{background:var(--ofb-hover);outline:0}.menu-item.danger{color:var(--ofb-danger)}@media (pointer:coarse){.menu-item{padding:11px 12px}}@media (width<=640px){.entry{grid-template-columns:minmax(0,1fr) 104px;min-height:44px}.cell{padding:10px 8px}.name{gap:7px}.icon{width:17px;height:17px}}@media (prefers-reduced-motion:no-preference){.entry{transition:background-color 80ms}}`,t=[`B`,`KB`,`MB`,`GB`,`TB`];function n(e=[]){return[...e].sort((e,t)=>Number(t.type===`directory`)-Number(e.type===`directory`)||e.name.localeCompare(t.name,void 0,{numeric:!0,sensitivity:`base`}))}function r(e){if(!Number.isFinite(e)||e<0)return`—`;if(!e)return`0 B`;let n=Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1),r=e/1024**n;return`${r.toFixed(n&&r<10?1:0)} ${t[n]}`}function i(e,t=Date.now()){let n=new Date(e);if(Number.isNaN(n.getTime()))return`—`;let r=n.getTime()-t,i=Math.abs(r),a=i<36e5?[`minute`,6e4]:i<864e5?[`hour`,36e5]:i<6048e5?[`day`,864e5]:i<2592e6?[`week`,6048e5]:i<31536e6?[`month`,2592e6]:[`year`,31536e6];return new Intl.RelativeTimeFormat(void 0,{numeric:`auto`}).format(Math.round(r/a[1]),a[0])}function a(e,t=Date.now()){let n=new Date(e).getTime(),r=t-n;return Number.isFinite(n)&&r>=0&&r<2592e6}function o(e=``){let t=e.toLowerCase();if(t===`license`||t.startsWith(`license.`))return`License`;if(t===`third_party_notices`)return`Notices`;if(t.startsWith(`.git`))return`Git`;let n=t.includes(`.`)?t.split(`.`).pop():``;return{css:`CSS`,html:`HTML`,js:`JavaScript`,json:`JSON`,md:`Markdown`,png:`Image`,svg:`SVG`,yml:`YAML`,yaml:`YAML`,ts:`TypeScript`}[n]||(n?n.toUpperCase():`File`)}function s(e=[],t,n){return e.filter(e=>!e.when||e.when(t,n))}var c=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%237e57c2'%20d='M20%2018h-2v-2h-2v2c0%20.193%200%20.703%201.254%201.033A3.345%203.345%200%200%201%2020%2022h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356%2020.34%2020%2018.84%2020%2018m-3.254%202.966C14.356%2020.34%2014%2018.84%2014%2018h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034'/%3e%3cpath%20fill='%237e57c2'%20d='M24%204H4v20a4%204%200%200%200%204%204h16.16A3.84%203.84%200%200%200%2028%2024.16V8a4%204%200%200%200-4-4m2%2014h-2v-2h-2v2c0%20.193%200%20.703%201.254%201.033A3.345%203.345%200%200%201%2026%2022v2a2%202%200%200%201-2%202h-2a2%202%200%200%201-2-2%202%202%200%200%201-2%202h-2a2%202%200%200%201-2-2%202%202%200%200%201-2%202h-2a2%202%200%200%201-2-2v-8a2%202%200%200%201%202-2h2a2%202%200%200%201%202%202%202%202%200%200%201%202-2h2a2%202%200%200%201%202%202%202%202%200%200%201%202-2h2a2%202%200%200%201%202%202Z'/%3e%3c/svg%3e`,l=`data:image/svg+xml,%3csvg%20viewBox='0%200%2016%2016'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='m6.922%203.768-.644-.536A1%201%200%200%200%205.638%203H2a1%201%200%200%200-1%201v8a1%201%200%200%200%201%201h12a1%201%200%200%200%201-1V5a1%201%200%200%200-1-1H7.562a1%201%200%200%201-.64-.232'%20fill='%2390a4ae'%20/%3e%3c/svg%3e`,u=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e64a19'%20d='M13.172%202.828%2011.78%204.22l1.91%201.91%202%202A2.986%202.986%200%200%201%2020%2010.81a3.25%203.25%200%200%201-.31%201.31l2.06%202a2.68%202.68%200%200%201%203.37.57%202.86%202.86%200%200%201%20.88%202.117%203.02%203.02%200%200%201-.856%202.109A2.9%202.9%200%200%201%2023%2019.81a2.93%202.93%200%200%201-2.13-.87%202.694%202.694%200%200%201-.56-3.38l-2-2.06a3%203%200%200%201-.31.12V20a3%203%200%200%201%201.44%201.09%202.92%202.92%200%200%201%20.56%201.72%202.88%202.88%200%200%201-.878%202.128%202.98%202.98%200%200%201-2.048.871%202.981%202.981%200%200%201-2.514-4.719A3%203%200%200%201%2016%2020v-6.38a2.96%202.96%200%200%201-1.44-1.09%202.9%202.9%200%200%201-.56-1.72%202.9%202.9%200%200%201%20.31-1.31l-3.9-3.9-7.579%207.572a4%204%200%200%200-.001%205.658l10.342%2010.342a4%204%200%200%200%205.656%200l10.344-10.344a4%204%200%200%200%200-5.656L18.828%202.828a4%204%200%200%200-5.656%200'/%3e%3c/svg%3e`,d=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e65100'%20d='m4%204%202%2022%2010%202%2010-2%202-22Zm19.72%207H11.28l.29%203h11.86l-.802%209.335L15.99%2025l-6.635-1.646L8.93%2019h3.02l.19%202%203.86.77%203.84-.77.29-4H8.84L8%208h16Z'/%3e%3c/svg%3e`,f=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%2326a69a'%20d='M8.5%206h4l-4-4zM3.875%201H9.5l4%204v8.6c0%20.773-.616%201.4-1.375%201.4h-8.25c-.76%200-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4%201.375-1.4M4%2013.6h8V8l-2.625%202.8L8%209.4zm1.25-7.7c-.76%200-1.375.627-1.375%201.4s.616%201.4%201.375%201.4c.76%200%201.375-.627%201.375-1.4S6.009%205.9%205.25%205.9'/%3e%3c/svg%3e`,p=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%23ffca28'%20d='M2%202v12h12V2zm6%206h1v4a1.003%201.003%200%200%201-1%201H7a1.003%201.003%200%200%201-1-1v-1h1v1h1zm3%200h2v1h-2v1h1a1.003%201.003%200%200%201%201%201v1a1.003%201.003%200%200%201-1%201h-2v-1h2v-1h-1a1.003%201.003%200%200%201-1-1V9a1.003%201.003%200%200%201%201-1'/%3e%3c/svg%3e`,m=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%20-960%20960%20960'%3e%3cpath%20fill='%23f9a825'%20d='M560-160v-80h120q17%200%2028.5-11.5T720-280v-80q0-38%2022-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50%200%2085%2035t35%2085v80q0%2017%2011.5%2028.5T840-560h40v160h-40q-17%200-28.5%2011.5T800-360v80q0%2050-35%2085t-85%2035zm-280%200q-50%200-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17%200%2028.5-11.5T160-600v-80q0-50%2035-85t85-35h120v80H280q-17%200-28.5%2011.5T240-680v80q0%2038-22%2069t-58%2044v14q36%2013%2058%2044t22%2069v80q0%2017%2011.5%2028.5T280-240h120v80z'/%3e%3c/svg%3e`,h=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%23ff5722'%20d='M8%201a5.5%205.5%200%200%200-4%209.26V15l4-1.5%204%201.5v-4.74A5.49%205.49%200%200%200%208%201m0%201.5a4%204%200%201%201%200%208%204%204%200%200%201%200-8m0%202a2%202%200%201%200%200%204%202%202%200%200%200%200-4'/%3e%3c/svg%3e`,g=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%2342a5f5'%20d='m14%2010-4%203.5L6%2010H4v12h4v-6l2%202%202-2v6h4V10zm12%206v-6h-4v6h-4l6%208%206-8z'/%3e%3c/svg%3e`,_=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e53935'%20d='M4%204v24h24V4Zm20%2020h-4V12h-4v12H8V8h16Z'/%3e%3c/svg%3e`,v=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23ffb300'%20d='M29.168%2014.03a2.7%202.7%200%200%200-1.968-.83%202.51%202.51%200%200%200-1.929.8h-4.443l3.078-3.078a2.835%202.835%200%200%200%202.857-2.842%202.6%202.6%200%200%200-.831-1.969%202.82%202.82%200%200%200-2.014-.788%202.67%202.67%200%200%200-1.968.788%202.36%202.36%200%200%200-.812%201.922L18%2011.17V6.726a2.51%202.51%200%200%200%20.8-1.929%202.7%202.7%200%200%200-.832-1.968%202.745%202.745%200%200%200-3.936%200%202.7%202.7%200%200%200-.832%201.968%202.51%202.51%200%200%200%20.8%201.93v4.443l-3.138-3.138a2.36%202.36%200%200%200-.812-1.922%202.66%202.66%200%200%200-1.968-.788%202.83%202.83%200%200%200-2.014.788%202.6%202.6%200%200%200-.831%201.969%202.74%202.74%200%200%200%20.831%202.013%202.8%202.8%200%200%200%202.026.829l3.078%203.078H6.729a2.51%202.51%200%200%200-1.929-.8%202.7%202.7%200%200%200-1.968.831%202.745%202.745%200%200%200%200%203.937%202.7%202.7%200%200%200%201.968.832%202.51%202.51%200%200%200%201.929-.8h4.443l-3.078%203.077a2.835%202.835%200%200%200-2.857%202.842%202.6%202.6%200%200%200%20.831%201.969%202.82%202.82%200%200%200%202.014.788%202.67%202.67%200%200%200%201.968-.788%202.36%202.36%200%200%200%20.812-1.922L14%2020.827v4.444a2.51%202.51%200%200%200-.8%201.929%202.784%202.784%200%200%200%204.768%201.968A2.7%202.7%200%200%200%2018.8%2027.2a2.51%202.51%200%200%200-.8-1.929v-4.444l3.138%203.138a2.36%202.36%200%200%200%20.812%201.922%202.66%202.66%200%200%200%201.968.788%202.83%202.83%200%200%200%202.014-.788%202.6%202.6%200%200%200%20.831-1.969%202.74%202.74%200%200%200-.831-2.013%202.8%202.8%200%200%200-2.026-.829L20.828%2018h4.443a2.51%202.51%200%200%200%201.93.8%202.784%202.784%200%200%200%201.967-4.769Z'/%3e%3c/svg%3e`,y=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xml:space='preserve'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%230288d1'%20d='M2%202v12h12V2zm4%206h3v1H8v4H7V9H6zm5%200h2v1h-2v1h1a1.003%201.003%200%200%201%201%201v1a1.003%201.003%200%200%201-1%201h-2v-1h2v-1h-1a1.003%201.003%200%200%201-1-1V9a1.003%201.003%200%200%201%201-1'/%3e%3c/svg%3e`,b=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23a0f'%20d='M29.313%2012h-6.664a1.427%201.427%200%200%201-1.1-2.24l4.398-6.676A.703.703%200%200%200%2025.397%202H8.428a.62.62%200%200%200-.55.289l-5.77%208.627A.703.703%200%200%200%202.658%2012h8.175a1.427%201.427%200%200%201%201.099%202.24l-4.48%206.676A.702.702%200%200%200%208%2022l6.695.002A1.34%201.34%200%200%201%2016%2023.375v5.934a.652.652%200%200%200%201.168.433l12.694-16.586a.725.725%200%200%200-.55-1.156'/%3e%3c/svg%3e`,x=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3e%3cpath%20fill='%23ff5252'%20d='M13%209h5.5L13%203.5zM6%202h8l6%206v12c0%201.1-.9%202-2%202H6c-1.1%200-2-.9-2-2V4c0-1.1.9-2%202-2m12%2016v-2H9v2zm-4-4v-2H6v2z'/%3e%3c/svg%3e`,S={css:c,htm:d,html:d,jpeg:f,jpg:f,js:p,json:m,md:g,mjs:p,png:f,svg:v,ts:y,yaml:x,yml:x};function C(e){if(e.icon)return e.icon;if(e.type===`directory`)return l;let t=e.name.toLowerCase();return t===`license`||t.startsWith(`license.`)?h:t===`package.json`||t===`package-lock.json`?_:t.startsWith(`vite.config.`)?b:t.startsWith(`.git`)?u:S[t.split(`.`).pop()]||`data:image/svg+xml,%3csvg%20viewBox='0%200%2016%2016'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='m8.668%206h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4%204v8c0%200.73828-0.59375%201.3359-1.332%201.3359h-8c-0.73828%200-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219%200.59375-1.3359%201.332-1.3359m3.332%201.3359h-3.332v10.664h8v-6h-4.668z'%20fill='%2390a4ae'%20/%3e%3c/svg%3e`}var w=globalThis.HTMLElement||class{},T=500,E=10;function D(e,t,n){let r=document.createElement(e);return t&&(r.className=t),n!==void 0&&(r.textContent=n),r}var O=class extends w{#e=[];#t=[];#n=[];#r=null;#i=null;#a=null;static get observedAttributes(){return[`empty-label`]}constructor(){super(),this.attachShadow({mode:`open`})}connectedCallback(){this.#g()}disconnectedCallback(){this.#l(),this.#d()}attributeChangedCallback(){this.isConnected&&this.#g()}set entries(e){this.#e=Array.isArray(e)?e:[],this.#g()}get entries(){return this.#e}set path(e){this.#t=Array.isArray(e)?[...e]:[],this.#g()}get path(){return[...this.#t]}set contextMenu(e){this.#n=Array.isArray(e)?e:[],this.#d()}get contextMenu(){return this.#n}#o(e,t){this.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:t}))}#s(e){if(e.parent)return this.#o(`octopus:navigate`,{path:this.#t.slice(0,-1)});if(e.type===`directory`)return this.#o(`octopus:navigate`,{path:[...this.#t,e.name]});this.#o(`octopus:open`,{entry:e,path:[...this.#t,e.name]})}#c(e,t){e.addEventListener(`contextmenu`,n=>{let r=s(this.#n,t,[...this.#t,t.name]);if(!r.length||(n.preventDefault(),this.#l(),this.#i===e))return;let i=!n.clientX&&!n.clientY,a=e.getBoundingClientRect();this.#u(e,t,r,i?a.left+16:n.clientX,i?a.bottom:n.clientY)}),e.addEventListener(`pointerdown`,n=>{if(n.pointerType!==`touch`||!this.#n.length)return;this.#l();let{clientX:r,clientY:i}=n,a=setTimeout(()=>{this.#a=null;let n=s(this.#n,t,[...this.#t,t.name]);n.length&&this.#i!==e&&this.#u(e,t,n,r,i)},T);this.#a={timer:a,x:r,y:i}}),e.addEventListener(`pointermove`,e=>{this.#a&&Math.hypot(e.clientX-this.#a.x,e.clientY-this.#a.y)>E&&this.#l()});for(let t of[`pointerup`,`pointercancel`,`pointerleave`])e.addEventListener(t,()=>this.#l())}#l(){this.#a&&=(clearTimeout(this.#a.timer),null)}#u(e,t,n,r,i){this.#d();let a=[...this.#t,t.name],o=D(`div`,`menu`);o.setAttribute(`role`,`menu`),o.setAttribute(`aria-label`,t.name),o.setAttribute(`popover`,`manual`);for(let e of n){let n=D(`button`,e.danger?`menu-item danger`:`menu-item`,e.label);n.type=`button`,n.tabIndex=-1,n.setAttribute(`role`,`menuitem`),n.addEventListener(`click`,()=>{this.#d(!0),e.action?.(t,a)}),o.append(n)}o.addEventListener(`keydown`,e=>this.#m(e)),this.shadowRoot.append(o),o.showPopover?.();let{width:s,height:c}=o.getBoundingClientRect();o.style.left=`${Math.max(4,Math.min(r,innerWidth-s-4))}px`,o.style.top=`${i+c>innerHeight-4?Math.max(4,i-c):i}px`,this.#r=o,this.#i=e,document.addEventListener(`pointerdown`,this.#f,!0),addEventListener(`scroll`,this.#p,!0),addEventListener(`resize`,this.#p),addEventListener(`blur`,this.#p),o.firstChild.focus({preventScroll:!0})}#d(e=!1){if(!this.#r)return;let t=this.#i;this.#r.remove(),this.#r=null,this.#i=null,document.removeEventListener(`pointerdown`,this.#f,!0),removeEventListener(`scroll`,this.#p,!0),removeEventListener(`resize`,this.#p),removeEventListener(`blur`,this.#p),e&&t.isConnected&&t.focus()}#f=e=>{e.composedPath().includes(this.#r)||this.#d()};#p=()=>this.#d();#m(e){let t=[...this.#r.children],n=t.indexOf(this.shadowRoot.activeElement),r={ArrowDown:n+1,ArrowUp:n-1,Home:0,End:t.length-1}[e.key];r===void 0?(e.key===`Escape`||e.key===`Tab`)&&(e.preventDefault(),this.#d(!0)):(e.preventDefault(),t[(r+t.length)%t.length].focus())}#h(e){let t=D(`li`),n=D(`button`,`entry`);n.type=`button`,n.title=e.parent?`Parent folder`:e.name,n.setAttribute(`aria-label`,e.parent?`Parent folder`:`${e.name}, ${e.type===`directory`?`folder`:o(e.name)}`),n.addEventListener(`click`,()=>{this.#r||this.#s(e)}),e.parent||this.#c(n,e);let s=D(`span`,`cell name`),c=D(`img`,`icon`);c.alt=``,c.src=C(e),s.append(c,D(`span`,`label`,e.name)),e.type!==`directory`&&s.append(D(`span`,`size`,r(e.size)));let l=D(`span`,`cell modified`,e.modified?i(e.modified):``);return e.modified&&(l.title=new Date(e.modified).toLocaleString(),a(e.modified)&&l.classList.add(`recent`)),n.append(s,l),t.append(n),t}#g(){if(!this.shadowRoot||typeof document>`u`)return;this.#l(),this.#d();let t=D(`style`);t.textContent=e;let r=D(`ul`,`list`);r.setAttribute(`aria-label`,`Files`);let i=n(this.#e);this.#t.length&&r.append(this.#h({name:`..`,type:`directory`,parent:!0})),i.length?i.forEach(e=>r.append(this.#h(e))):r.append(D(`li`,`empty`,this.getAttribute(`empty-label`)||`This folder is empty.`)),this.shadowRoot.replaceChildren(t,r)}};globalThis.customElements&&!customElements.get(`octopus-file-browser`)&&customElements.define(`octopus-file-browser`,O);var k=[{name:`.github`,type:`directory`,modified:`2026-09-23T19:58:44.810Z`,children:[{name:`FUNDING.yml`,type:`file`,modified:`2026-09-23T19:58:44.810Z`,size:20}]},{name:`.gitignore`,type:`file`,modified:`2026-09-23T20:00:50.728Z`,size:33},{name:`.suneignore`,type:`file`,modified:`2026-09-24T01:11:49.671Z`,size:19},{name:`AGENTS.md`,type:`file`,modified:`2026-09-23T18:32:40.331Z`,size:105},{name:`docs`,type:`directory`,modified:`2026-09-24T01:11:49.670Z`,children:[{name:`ofb.png`,type:`file`,modified:`2026-09-23T16:30:08.419Z`,size:907351},{name:`ui.png`,type:`file`,modified:`2026-09-23T19:43:55.773Z`,size:37253}]},{name:`index.html`,type:`file`,modified:`2026-09-24T01:47:04.834Z`,size:537},{name:`LICENSE`,type:`file`,modified:`2026-09-23T19:55:45.681Z`,size:4111},{name:`octopus-file-browser.d.ts`,type:`file`,modified:`2026-09-24T01:46:47.605Z`,size:1896},{name:`package-lock.json`,type:`file`,modified:`2026-09-23T19:59:32.000Z`,size:26439},{name:`package.json`,type:`file`,modified:`2026-09-23T19:58:44.809Z`,size:1472},{name:`README.md`,type:`file`,modified:`2026-09-24T01:11:49.701Z`,size:3165},{name:`src`,type:`directory`,modified:`2026-09-24T01:49:02.371Z`,children:[{name:`demo.css`,type:`file`,modified:`2026-09-24T01:47:04.865Z`,size:394},{name:`demo.js`,type:`file`,modified:`2026-09-24T01:47:10.498Z`,size:3011},{name:`file-browser-utils.js`,type:`file`,modified:`2026-09-24T01:46:45.662Z`,size:2231},{name:`icon-resolver.js`,type:`file`,modified:`2026-09-23T17:01:59.141Z`,size:1499},{name:`octopus-file-browser.css`,type:`file`,modified:`2026-09-24T01:46:53.228Z`,size:3066},{name:`octopus-file-browser.js`,type:`file`,modified:`2026-09-24T01:49:02.365Z`,size:8628}]},{name:`test`,type:`directory`,modified:`2026-09-24T01:47:04.805Z`,children:[{name:`file-browser-utils.test.js`,type:`file`,modified:`2026-09-24T01:47:04.794Z`,size:2232}]},{name:`THIRD_PARTY_NOTICES`,type:`file`,modified:`2026-09-23T17:05:01.941Z`,size:3905},{name:`vite.config.js`,type:`file`,modified:`2026-09-24T01:11:49.704Z`,size:1118},{name:`vite.lib.config.js`,type:`file`,modified:`2026-09-23T17:30:20.000Z`,size:407}],A=document.querySelector(`octopus-file-browser`),j=document.querySelector(`.demo-status`),M=e=>{j.textContent=e},N=e=>`/${e.join(`/`)}`,P=e=>e.type===`directory`;function F(e){return e.reduce((e,t)=>e.find(e=>P(e)&&e.name===t)?.children||[],k)}function I(e){return F(e).map(({children:e,...t})=>t)}function L(e){A.path=e,A.entries=I(e)}function R(e){return F(e.slice(0,-1)).find(t=>t.name===e.at(-1))}A.contextMenu=[{label:`Open`,when:P,action:(e,t)=>L(t)},{label:`New folder…`,when:P,action:(e,t)=>{let n=prompt(`New folder in ${N(t)}`)?.trim();n&&(F(t).push({name:n,type:`directory`,modified:new Date().toISOString(),children:[]}),M(`Created ${N([...t,n])}`))}},{label:`Download`,when:e=>e.type===`file`,action:(e,t)=>M(`"Download" on ${N(t)} (${r(e.size)})`)},{label:`Validate JSON`,when:e=>e.type===`file`&&e.name.toLowerCase().endsWith(`.json`),action:(e,t)=>M(`"Validate JSON" on ${N(t)}`)},{label:`Copy path`,action:async(e,t)=>{try{await navigator.clipboard.writeText(N(t)),M(`Copied ${N(t)}`)}catch{M(`Could not copy ${N(t)}`)}}},{label:`Rename…`,action:(e,t)=>{let n=prompt(`Rename ${e.name}`,e.name)?.trim();if(n&&n!==e.name){if(I(A.path).some(e=>e.name===n))return M(`${n} already exists`);R(t).name=n,A.entries=I(A.path),M(`Renamed ${N(t)} to ${n}`)}}},{label:`Delete`,danger:!0,action:(e,t)=>{if(!confirm(`Delete ${N(t)}?`))return;let n=F(t.slice(0,-1));n.splice(n.indexOf(R(t)),1),A.entries=I(A.path),M(`Deleted ${N(t)}`)}}],A.addEventListener(`octopus:navigate`,({detail:e})=>L(e.path)),A.addEventListener(`octopus:open`,({detail:e})=>M(`Opened ${N(e.path)}`)),L([]); \ No newline at end of file diff --git a/demo/assets/index-rQJBLWWV.css b/demo/assets/index-Bx92WdqS.css similarity index 61% rename from demo/assets/index-rQJBLWWV.css rename to demo/assets/index-Bx92WdqS.css index b529516..a29798f 100644 --- a/demo/assets/index-rQJBLWWV.css +++ b/demo/assets/index-Bx92WdqS.css @@ -1 +1 @@ -:root{color:#181c21;font-synthesis:none;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}body{min-width:280px;margin:0}main{max-width:1220px;margin:0 auto;padding:20px}@media (width<=640px){main{padding:8px}} +:root{color:#181c21;font-synthesis:none;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}body{min-width:280px;margin:0}main{max-width:1220px;margin:0 auto;padding:20px}@media (width<=640px){main{padding:8px}}.demo-status{color:#57606a;min-height:20px;margin:8px 2px 0;font-size:13px} diff --git a/demo/assets/index-CRttpLjx.js b/demo/assets/index-CRttpLjx.js deleted file mode 100644 index 992aca3..0000000 --- a/demo/assets/index-CRttpLjx.js +++ /dev/null @@ -1 +0,0 @@ -(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=`:host{--ofb-border:#d0d7de;--ofb-text:#181c21;--ofb-muted:#57606a;--ofb-link:#1f2328;--ofb-folder:#54aeff;--ofb-hover:#f6f8fa;--ofb-focus:#0969da;--ofb-recent:#d15700;color:var(--ofb-text);min-width:0;font:14px/20px -apple-system,BlinkMacSystemFont,Segoe UI,system-ui,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif;display:block}*{box-sizing:border-box}.list{border:1px solid var(--ofb-border);border-radius:5px;margin:0;padding:0;list-style:none;overflow:hidden}.entry{appearance:none;border:0;border-top:1px solid var(--ofb-border);color:inherit;cursor:pointer;font:inherit;text-align:left;background:#fff;grid-template-columns:minmax(0,1fr) 120px;width:100%;min-height:37px;margin:0;padding:0;display:grid}li:first-child .entry{border-top:0}.entry:hover{background:var(--ofb-hover)}.entry:active{background:#eef1f4}.entry:focus-visible{box-shadow:inset 0 0 0 2px var(--ofb-focus);outline:0}.cell{align-items:center;min-width:0;padding:8px 10px;display:flex}.name{gap:8px}.icon{object-fit:contain;flex:none;width:18px;height:18px}.label,.modified{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.label{color:var(--ofb-link);min-width:0}.size{color:var(--ofb-muted);white-space:nowrap;flex:none;margin-left:8px;font-size:12px}.modified{color:var(--ofb-muted)}.modified.recent{color:var(--ofb-recent)}.modified{justify-content:flex-end}.empty{color:var(--ofb-muted);text-align:center;margin:0;padding:24px}@media (width<=640px){.entry{grid-template-columns:minmax(0,1fr) 104px;min-height:44px}.cell{padding:10px 8px}.name{gap:7px}.icon{width:17px;height:17px}}@media (prefers-reduced-motion:no-preference){.entry{transition:background-color 80ms}}`,t=[`B`,`KB`,`MB`,`GB`,`TB`];function n(e=[]){return[...e].sort((e,t)=>Number(t.type===`directory`)-Number(e.type===`directory`)||e.name.localeCompare(t.name,void 0,{numeric:!0,sensitivity:`base`}))}function r(e){if(!Number.isFinite(e)||e<0)return`—`;if(!e)return`0 B`;let n=Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1),r=e/1024**n;return`${r.toFixed(n&&r<10?1:0)} ${t[n]}`}function i(e,t=Date.now()){let n=new Date(e);if(Number.isNaN(n.getTime()))return`—`;let r=n.getTime()-t,i=Math.abs(r),a=i<36e5?[`minute`,6e4]:i<864e5?[`hour`,36e5]:i<6048e5?[`day`,864e5]:i<2592e6?[`week`,6048e5]:i<31536e6?[`month`,2592e6]:[`year`,31536e6];return new Intl.RelativeTimeFormat(void 0,{numeric:`auto`}).format(Math.round(r/a[1]),a[0])}function a(e,t=Date.now()){let n=new Date(e).getTime(),r=t-n;return Number.isFinite(n)&&r>=0&&r<2592e6}function o(e=``){let t=e.toLowerCase();if(t===`license`||t.startsWith(`license.`))return`License`;if(t===`third_party_notices`)return`Notices`;if(t.startsWith(`.git`))return`Git`;let n=t.includes(`.`)?t.split(`.`).pop():``;return{css:`CSS`,html:`HTML`,js:`JavaScript`,json:`JSON`,md:`Markdown`,png:`Image`,svg:`SVG`,yml:`YAML`,yaml:`YAML`,ts:`TypeScript`}[n]||(n?n.toUpperCase():`File`)}var s=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%237e57c2'%20d='M20%2018h-2v-2h-2v2c0%20.193%200%20.703%201.254%201.033A3.345%203.345%200%200%201%2020%2022h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356%2020.34%2020%2018.84%2020%2018m-3.254%202.966C14.356%2020.34%2014%2018.84%2014%2018h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034'/%3e%3cpath%20fill='%237e57c2'%20d='M24%204H4v20a4%204%200%200%200%204%204h16.16A3.84%203.84%200%200%200%2028%2024.16V8a4%204%200%200%200-4-4m2%2014h-2v-2h-2v2c0%20.193%200%20.703%201.254%201.033A3.345%203.345%200%200%201%2026%2022v2a2%202%200%200%201-2%202h-2a2%202%200%200%201-2-2%202%202%200%200%201-2%202h-2a2%202%200%200%201-2-2%202%202%200%200%201-2%202h-2a2%202%200%200%201-2-2v-8a2%202%200%200%201%202-2h2a2%202%200%200%201%202%202%202%202%200%200%201%202-2h2a2%202%200%200%201%202%202%202%202%200%200%201%202-2h2a2%202%200%200%201%202%202Z'/%3e%3c/svg%3e`,c=`data:image/svg+xml,%3csvg%20viewBox='0%200%2016%2016'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='m6.922%203.768-.644-.536A1%201%200%200%200%205.638%203H2a1%201%200%200%200-1%201v8a1%201%200%200%200%201%201h12a1%201%200%200%200%201-1V5a1%201%200%200%200-1-1H7.562a1%201%200%200%201-.64-.232'%20fill='%2390a4ae'%20/%3e%3c/svg%3e`,l=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e64a19'%20d='M13.172%202.828%2011.78%204.22l1.91%201.91%202%202A2.986%202.986%200%200%201%2020%2010.81a3.25%203.25%200%200%201-.31%201.31l2.06%202a2.68%202.68%200%200%201%203.37.57%202.86%202.86%200%200%201%20.88%202.117%203.02%203.02%200%200%201-.856%202.109A2.9%202.9%200%200%201%2023%2019.81a2.93%202.93%200%200%201-2.13-.87%202.694%202.694%200%200%201-.56-3.38l-2-2.06a3%203%200%200%201-.31.12V20a3%203%200%200%201%201.44%201.09%202.92%202.92%200%200%201%20.56%201.72%202.88%202.88%200%200%201-.878%202.128%202.98%202.98%200%200%201-2.048.871%202.981%202.981%200%200%201-2.514-4.719A3%203%200%200%201%2016%2020v-6.38a2.96%202.96%200%200%201-1.44-1.09%202.9%202.9%200%200%201-.56-1.72%202.9%202.9%200%200%201%20.31-1.31l-3.9-3.9-7.579%207.572a4%204%200%200%200-.001%205.658l10.342%2010.342a4%204%200%200%200%205.656%200l10.344-10.344a4%204%200%200%200%200-5.656L18.828%202.828a4%204%200%200%200-5.656%200'/%3e%3c/svg%3e`,u=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e65100'%20d='m4%204%202%2022%2010%202%2010-2%202-22Zm19.72%207H11.28l.29%203h11.86l-.802%209.335L15.99%2025l-6.635-1.646L8.93%2019h3.02l.19%202%203.86.77%203.84-.77.29-4H8.84L8%208h16Z'/%3e%3c/svg%3e`,d=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%2326a69a'%20d='M8.5%206h4l-4-4zM3.875%201H9.5l4%204v8.6c0%20.773-.616%201.4-1.375%201.4h-8.25c-.76%200-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4%201.375-1.4M4%2013.6h8V8l-2.625%202.8L8%209.4zm1.25-7.7c-.76%200-1.375.627-1.375%201.4s.616%201.4%201.375%201.4c.76%200%201.375-.627%201.375-1.4S6.009%205.9%205.25%205.9'/%3e%3c/svg%3e`,f=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%23ffca28'%20d='M2%202v12h12V2zm6%206h1v4a1.003%201.003%200%200%201-1%201H7a1.003%201.003%200%200%201-1-1v-1h1v1h1zm3%200h2v1h-2v1h1a1.003%201.003%200%200%201%201%201v1a1.003%201.003%200%200%201-1%201h-2v-1h2v-1h-1a1.003%201.003%200%200%201-1-1V9a1.003%201.003%200%200%201%201-1'/%3e%3c/svg%3e`,p=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%20-960%20960%20960'%3e%3cpath%20fill='%23f9a825'%20d='M560-160v-80h120q17%200%2028.5-11.5T720-280v-80q0-38%2022-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50%200%2085%2035t35%2085v80q0%2017%2011.5%2028.5T840-560h40v160h-40q-17%200-28.5%2011.5T800-360v80q0%2050-35%2085t-85%2035zm-280%200q-50%200-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17%200%2028.5-11.5T160-600v-80q0-50%2035-85t85-35h120v80H280q-17%200-28.5%2011.5T240-680v80q0%2038-22%2069t-58%2044v14q36%2013%2058%2044t22%2069v80q0%2017%2011.5%2028.5T280-240h120v80z'/%3e%3c/svg%3e`,m=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%23ff5722'%20d='M8%201a5.5%205.5%200%200%200-4%209.26V15l4-1.5%204%201.5v-4.74A5.49%205.49%200%200%200%208%201m0%201.5a4%204%200%201%201%200%208%204%204%200%200%201%200-8m0%202a2%202%200%201%200%200%204%202%202%200%200%200%200-4'/%3e%3c/svg%3e`,h=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%2342a5f5'%20d='m14%2010-4%203.5L6%2010H4v12h4v-6l2%202%202-2v6h4V10zm12%206v-6h-4v6h-4l6%208%206-8z'/%3e%3c/svg%3e`,g=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23e53935'%20d='M4%204v24h24V4Zm20%2020h-4V12h-4v12H8V8h16Z'/%3e%3c/svg%3e`,_=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23ffb300'%20d='M29.168%2014.03a2.7%202.7%200%200%200-1.968-.83%202.51%202.51%200%200%200-1.929.8h-4.443l3.078-3.078a2.835%202.835%200%200%200%202.857-2.842%202.6%202.6%200%200%200-.831-1.969%202.82%202.82%200%200%200-2.014-.788%202.67%202.67%200%200%200-1.968.788%202.36%202.36%200%200%200-.812%201.922L18%2011.17V6.726a2.51%202.51%200%200%200%20.8-1.929%202.7%202.7%200%200%200-.832-1.968%202.745%202.745%200%200%200-3.936%200%202.7%202.7%200%200%200-.832%201.968%202.51%202.51%200%200%200%20.8%201.93v4.443l-3.138-3.138a2.36%202.36%200%200%200-.812-1.922%202.66%202.66%200%200%200-1.968-.788%202.83%202.83%200%200%200-2.014.788%202.6%202.6%200%200%200-.831%201.969%202.74%202.74%200%200%200%20.831%202.013%202.8%202.8%200%200%200%202.026.829l3.078%203.078H6.729a2.51%202.51%200%200%200-1.929-.8%202.7%202.7%200%200%200-1.968.831%202.745%202.745%200%200%200%200%203.937%202.7%202.7%200%200%200%201.968.832%202.51%202.51%200%200%200%201.929-.8h4.443l-3.078%203.077a2.835%202.835%200%200%200-2.857%202.842%202.6%202.6%200%200%200%20.831%201.969%202.82%202.82%200%200%200%202.014.788%202.67%202.67%200%200%200%201.968-.788%202.36%202.36%200%200%200%20.812-1.922L14%2020.827v4.444a2.51%202.51%200%200%200-.8%201.929%202.784%202.784%200%200%200%204.768%201.968A2.7%202.7%200%200%200%2018.8%2027.2a2.51%202.51%200%200%200-.8-1.929v-4.444l3.138%203.138a2.36%202.36%200%200%200%20.812%201.922%202.66%202.66%200%200%200%201.968.788%202.83%202.83%200%200%200%202.014-.788%202.6%202.6%200%200%200%20.831-1.969%202.74%202.74%200%200%200-.831-2.013%202.8%202.8%200%200%200-2.026-.829L20.828%2018h4.443a2.51%202.51%200%200%200%201.93.8%202.784%202.784%200%200%200%201.967-4.769Z'/%3e%3c/svg%3e`,v=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xml:space='preserve'%20viewBox='0%200%2016%2016'%3e%3cpath%20fill='%230288d1'%20d='M2%202v12h12V2zm4%206h3v1H8v4H7V9H6zm5%200h2v1h-2v1h1a1.003%201.003%200%200%201%201%201v1a1.003%201.003%200%200%201-1%201h-2v-1h2v-1h-1a1.003%201.003%200%200%201-1-1V9a1.003%201.003%200%200%201%201-1'/%3e%3c/svg%3e`,y=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3e%3cpath%20fill='%23a0f'%20d='M29.313%2012h-6.664a1.427%201.427%200%200%201-1.1-2.24l4.398-6.676A.703.703%200%200%200%2025.397%202H8.428a.62.62%200%200%200-.55.289l-5.77%208.627A.703.703%200%200%200%202.658%2012h8.175a1.427%201.427%200%200%201%201.099%202.24l-4.48%206.676A.702.702%200%200%200%208%2022l6.695.002A1.34%201.34%200%200%201%2016%2023.375v5.934a.652.652%200%200%200%201.168.433l12.694-16.586a.725.725%200%200%200-.55-1.156'/%3e%3c/svg%3e`,b=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3e%3cpath%20fill='%23ff5252'%20d='M13%209h5.5L13%203.5zM6%202h8l6%206v12c0%201.1-.9%202-2%202H6c-1.1%200-2-.9-2-2V4c0-1.1.9-2%202-2m12%2016v-2H9v2zm-4-4v-2H6v2z'/%3e%3c/svg%3e`,x={css:s,htm:u,html:u,jpeg:d,jpg:d,js:f,json:p,md:h,mjs:f,png:d,svg:_,ts:v,yaml:b,yml:b};function S(e){if(e.icon)return e.icon;if(e.type===`directory`)return c;let t=e.name.toLowerCase();return t===`license`||t.startsWith(`license.`)?m:t===`package.json`||t===`package-lock.json`?g:t.startsWith(`vite.config.`)?y:t.startsWith(`.git`)?l:x[t.split(`.`).pop()]||`data:image/svg+xml,%3csvg%20viewBox='0%200%2016%2016'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='m8.668%206h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4%204v8c0%200.73828-0.59375%201.3359-1.332%201.3359h-8c-0.73828%200-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219%200.59375-1.3359%201.332-1.3359m3.332%201.3359h-3.332v10.664h8v-6h-4.668z'%20fill='%2390a4ae'%20/%3e%3c/svg%3e`}var C=globalThis.HTMLElement||class{};function w(e,t,n){let r=document.createElement(e);return t&&(r.className=t),n!==void 0&&(r.textContent=n),r}var T=class extends C{#e=[];#t=[];static get observedAttributes(){return[`empty-label`]}constructor(){super(),this.attachShadow({mode:`open`})}connectedCallback(){this.#a()}attributeChangedCallback(){this.isConnected&&this.#a()}set entries(e){this.#e=Array.isArray(e)?e:[],this.#a()}get entries(){return this.#e}set path(e){this.#t=Array.isArray(e)?[...e]:[],this.#a()}get path(){return[...this.#t]}#n(e,t){this.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:t}))}#r(e){if(e.parent)return this.#n(`octopus:navigate`,{path:this.#t.slice(0,-1)});if(e.type===`directory`)return this.#n(`octopus:navigate`,{path:[...this.#t,e.name]});this.#n(`octopus:open`,{entry:e,path:[...this.#t,e.name]})}#i(e){let t=w(`li`),n=w(`button`,`entry`);n.type=`button`,n.title=e.parent?`Parent folder`:e.name,n.setAttribute(`aria-label`,e.parent?`Parent folder`:`${e.name}, ${e.type===`directory`?`folder`:o(e.name)}`),n.addEventListener(`click`,()=>this.#r(e));let s=w(`span`,`cell name`),c=w(`img`,`icon`);c.alt=``,c.src=S(e),s.append(c,w(`span`,`label`,e.name)),e.type!==`directory`&&s.append(w(`span`,`size`,r(e.size)));let l=w(`span`,`cell modified`,e.modified?i(e.modified):``);return e.modified&&(l.title=new Date(e.modified).toLocaleString(),a(e.modified)&&l.classList.add(`recent`)),n.append(s,l),t.append(n),t}#a(){if(!this.shadowRoot||typeof document>`u`)return;let t=w(`style`);t.textContent=e;let r=w(`ul`,`list`);r.setAttribute(`aria-label`,`Files`);let i=n(this.#e);this.#t.length&&r.append(this.#i({name:`..`,type:`directory`,parent:!0})),i.length?i.forEach(e=>r.append(this.#i(e))):r.append(w(`li`,`empty`,this.getAttribute(`empty-label`)||`This folder is empty.`)),this.shadowRoot.replaceChildren(t,r)}};globalThis.customElements&&!customElements.get(`octopus-file-browser`)&&customElements.define(`octopus-file-browser`,T);var E=[{name:`.github`,type:`directory`,modified:`2026-09-23T19:58:44.810Z`,children:[{name:`FUNDING.yml`,type:`file`,modified:`2026-09-23T19:58:44.810Z`,size:20}]},{name:`.gitignore`,type:`file`,modified:`2026-09-23T20:00:50.728Z`,size:33},{name:`.suneignore`,type:`file`,modified:`2026-09-24T01:11:49.671Z`,size:19},{name:`AGENTS.md`,type:`file`,modified:`2026-09-23T18:32:40.331Z`,size:105},{name:`docs`,type:`directory`,modified:`2026-09-24T01:11:49.670Z`,children:[{name:`ofb.png`,type:`file`,modified:`2026-09-23T16:30:08.419Z`,size:907351},{name:`ui.png`,type:`file`,modified:`2026-09-23T19:43:55.773Z`,size:37253}]},{name:`index.html`,type:`file`,modified:`2026-09-23T17:30:19.971Z`,size:446},{name:`LICENSE`,type:`file`,modified:`2026-09-23T19:55:45.681Z`,size:4111},{name:`octopus-file-browser.d.ts`,type:`file`,modified:`2026-09-24T01:11:49.702Z`,size:1630},{name:`package-lock.json`,type:`file`,modified:`2026-09-23T19:59:32.000Z`,size:26439},{name:`package.json`,type:`file`,modified:`2026-09-23T19:58:44.809Z`,size:1472},{name:`README.md`,type:`file`,modified:`2026-09-24T01:11:49.701Z`,size:3165},{name:`src`,type:`directory`,modified:`2026-09-24T01:11:49.703Z`,children:[{name:`demo.css`,type:`file`,modified:`2026-09-23T17:30:19.989Z`,size:298},{name:`demo.js`,type:`file`,modified:`2026-09-24T01:11:49.703Z`,size:641},{name:`file-browser-utils.js`,type:`file`,modified:`2026-09-23T17:30:19.978Z`,size:2101},{name:`icon-resolver.js`,type:`file`,modified:`2026-09-23T17:01:59.141Z`,size:1499},{name:`octopus-file-browser.css`,type:`file`,modified:`2026-09-23T17:30:35.003Z`,size:2076},{name:`octopus-file-browser.js`,type:`file`,modified:`2026-09-24T01:11:49.703Z`,size:3949}]},{name:`test`,type:`directory`,modified:`2026-09-23T17:01:59.162Z`,children:[{name:`file-browser-utils.test.js`,type:`file`,modified:`2026-09-23T17:30:20.005Z`,size:1558}]},{name:`THIRD_PARTY_NOTICES`,type:`file`,modified:`2026-09-23T17:05:01.941Z`,size:3905},{name:`vite.config.js`,type:`file`,modified:`2026-09-24T01:11:49.704Z`,size:1118},{name:`vite.lib.config.js`,type:`file`,modified:`2026-09-23T17:30:20.000Z`,size:407}],D=document.querySelector(`octopus-file-browser`);function O(e){return e.reduce((e,t)=>e.find(e=>e.type===`directory`&&e.name===t)?.children||[],E).map(({children:e,...t})=>t)}D.addEventListener(`octopus:navigate`,({detail:e})=>{D.path=e.path,D.entries=O(e.path)}),D.entries=O([]); \ No newline at end of file diff --git a/demo/index.html b/demo/index.html index e063c7b..958605e 100644 --- a/demo/index.html +++ b/demo/index.html @@ -6,12 +6,13 @@ octopus-file-browser - - + +
+

Right-click or long-press a file or folder.

diff --git a/index.html b/index.html index ff77235..9897d41 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@
+

Right-click or long-press a file or folder.

diff --git a/octopus-file-browser.d.ts b/octopus-file-browser.d.ts index 047ca05..3bc5330 100644 --- a/octopus-file-browser.d.ts +++ b/octopus-file-browser.d.ts @@ -13,6 +13,13 @@ export interface OctopusOpenDetail { path: string[]; } +export interface OctopusMenuItem { + label: string; + action: (entry: OctopusEntry, path: string[]) => void; + when?: (entry: OctopusEntry, path: string[]) => boolean; + danger?: boolean; +} + export interface OctopusNavigateDetail { path: string[]; } @@ -25,6 +32,7 @@ export interface OctopusFileBrowserEventMap { export class OctopusFileBrowser extends HTMLElement { entries: OctopusEntry[]; path: string[]; + contextMenu: OctopusMenuItem[]; addEventListener( type: K, listener: (this: OctopusFileBrowser, event: OctopusFileBrowserEventMap[K]) => void, @@ -39,7 +47,7 @@ export class OctopusFileBrowser extends HTMLElement { export function createOctopusFileBrowser( target: Element, - options?: { entries?: OctopusEntry[]; path?: string[]; emptyLabel?: string }, + options?: { entries?: OctopusEntry[]; path?: string[]; contextMenu?: OctopusMenuItem[]; emptyLabel?: string }, ): OctopusFileBrowser; export function fileKindLabel(name?: string): string; diff --git a/src/demo.css b/src/demo.css index a1a360d..65e791b 100644 --- a/src/demo.css +++ b/src/demo.css @@ -15,3 +15,10 @@ main { @media (max-width: 640px) { main { padding: 8px; } } + +.demo-status { + color: #57606a; + font-size: 13px; + margin: 8px 2px 0; + min-height: 20px; +} diff --git a/src/demo.js b/src/demo.js index 0d9d1da..e0ee190 100644 --- a/src/demo.js +++ b/src/demo.js @@ -1,19 +1,90 @@ import './demo.css'; -import './octopus-file-browser.js'; +import { formatFileSize } from './octopus-file-browser.js'; import tree from 'virtual:octopus-repo-tree'; const browser = document.querySelector('octopus-file-browser'); +const status = document.querySelector('.demo-status'); +const say = (text) => { status.textContent = text; }; +const pathText = (path) => `/${path.join('/')}`; +const isDirectory = (entry) => entry.type === 'directory'; -// Stands in for an app backend: returns one directory listing per request. -function list(path) { +// Stands in for an app backend: `tree` is its filesystem and `list` returns one directory per request. +function directory(path) { return path.reduce((entries, name) => - entries.find((entry) => entry.type === 'directory' && entry.name === name)?.children || [], tree) - .map(({ children, ...entry }) => entry); + entries.find((entry) => isDirectory(entry) && entry.name === name)?.children || [], tree); } -browser.addEventListener('octopus:navigate', ({ detail }) => { - browser.path = detail.path; - browser.entries = list(detail.path); -}); +function list(path) { + return directory(path).map(({ children, ...entry }) => entry); +} -browser.entries = list([]); +function show(path) { + browser.path = path; + browser.entries = list(path); +} + +function stored(path) { + return directory(path.slice(0, -1)).find((entry) => entry.name === path.at(-1)); +} + +browser.contextMenu = [ + { label: 'Open', when: isDirectory, action: (entry, path) => show(path) }, + { + label: 'New folder…', + when: isDirectory, + action: (entry, path) => { + const name = prompt(`New folder in ${pathText(path)}`)?.trim(); + if (!name) return; + directory(path).push({ name, type: 'directory', modified: new Date().toISOString(), children: [] }); + say(`Created ${pathText([...path, name])}`); + }, + }, + { + label: 'Download', + when: (entry) => entry.type === 'file', + action: (entry, path) => say(`"Download" on ${pathText(path)} (${formatFileSize(entry.size)})`), + }, + { + label: 'Validate JSON', + when: (entry) => entry.type === 'file' && entry.name.toLowerCase().endsWith('.json'), + action: (entry, path) => say(`"Validate JSON" on ${pathText(path)}`), + }, + { + label: 'Copy path', + action: async (entry, path) => { + try { + await navigator.clipboard.writeText(pathText(path)); + say(`Copied ${pathText(path)}`); + } catch { + say(`Could not copy ${pathText(path)}`); + } + }, + }, + { + label: 'Rename…', + action: (entry, path) => { + const name = prompt(`Rename ${entry.name}`, entry.name)?.trim(); + if (!name || name === entry.name) return; + if (list(browser.path).some((sibling) => sibling.name === name)) return say(`${name} already exists`); + stored(path).name = name; + browser.entries = list(browser.path); + say(`Renamed ${pathText(path)} to ${name}`); + }, + }, + { + label: 'Delete', + danger: true, + action: (entry, path) => { + if (!confirm(`Delete ${pathText(path)}?`)) return; + const siblings = directory(path.slice(0, -1)); + siblings.splice(siblings.indexOf(stored(path)), 1); + browser.entries = list(browser.path); + say(`Deleted ${pathText(path)}`); + }, + }, +]; + +browser.addEventListener('octopus:navigate', ({ detail }) => show(detail.path)); +browser.addEventListener('octopus:open', ({ detail }) => say(`Opened ${pathText(detail.path)}`)); + +show([]); diff --git a/src/file-browser-utils.js b/src/file-browser-utils.js index eb19eef..45de8be 100644 --- a/src/file-browser-utils.js +++ b/src/file-browser-utils.js @@ -52,3 +52,7 @@ export function fileKindLabel(name = '') { png: 'Image', svg: 'SVG', yml: 'YAML', yaml: 'YAML', ts: 'TypeScript', })[extension] || (extension ? extension.toUpperCase() : 'File'); } + +export function menuItemsFor(items = [], entry, path) { + return items.filter((item) => !item.when || item.when(entry, path)); +} diff --git a/src/octopus-file-browser.css b/src/octopus-file-browser.css index 154fd63..6d3be1a 100644 --- a/src/octopus-file-browser.css +++ b/src/octopus-file-browser.css @@ -7,6 +7,7 @@ --ofb-hover: #f6f8fa; --ofb-focus: #0969da; --ofb-recent: #d15700; + --ofb-danger: #cf222e; color: var(--ofb-text); display: block; font: 14px/20px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, @@ -39,6 +40,9 @@ min-height: 37px; padding: 0; text-align: left; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; width: 100%; } @@ -76,6 +80,48 @@ li:first-child .entry { border-top: 0; } text-align: center; } +.menu { + background: #fff; + border: 1px solid var(--ofb-border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(140, 149, 159, 0.25); + color: var(--ofb-text); + font: inherit; + inset: auto; + margin: 0; + max-height: calc(100vh - 8px); + max-width: min(280px, calc(100vw - 8px)); + min-width: 180px; + overflow-y: auto; + padding: 4px; + position: fixed; + z-index: 2147483647; +} + +.menu-item { + appearance: none; + background: none; + border: 0; + border-radius: 4px; + color: inherit; + cursor: pointer; + display: block; + font: inherit; + overflow: hidden; + padding: 6px 10px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.menu-item:hover, .menu-item:focus-visible { background: var(--ofb-hover); outline: 0; } +.menu-item.danger { color: var(--ofb-danger); } + +@media (pointer: coarse) { + .menu-item { padding: 11px 12px; } +} + @media (max-width: 640px) { .entry { grid-template-columns: minmax(0, 1fr) 104px; diff --git a/src/octopus-file-browser.js b/src/octopus-file-browser.js index 7aa97af..4ca700c 100644 --- a/src/octopus-file-browser.js +++ b/src/octopus-file-browser.js @@ -1,8 +1,10 @@ import styles from './octopus-file-browser.css?inline'; -import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries } from './file-browser-utils.js'; +import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, menuItemsFor, sortEntries } from './file-browser-utils.js'; import { resolveIcon } from './icon-resolver.js'; const HTMLElementBase = globalThis.HTMLElement || class {}; +const LONG_PRESS_MS = 500; +const LONG_PRESS_SLOP = 10; function element(tag, className, text) { const node = document.createElement(tag); @@ -14,6 +16,10 @@ function element(tag, className, text) { export class OctopusFileBrowser extends HTMLElementBase { #entries = []; #path = []; + #contextMenu = []; + #menu = null; + #menuRow = null; + #press = null; static get observedAttributes() { return ['empty-label']; } @@ -23,6 +29,7 @@ export class OctopusFileBrowser extends HTMLElementBase { } connectedCallback() { this.#render(); } + disconnectedCallback() { this.#cancelPress(); this.#closeMenu(); } attributeChangedCallback() { if (this.isConnected) this.#render(); } set entries(value) { @@ -39,6 +46,13 @@ export class OctopusFileBrowser extends HTMLElementBase { get path() { return [...this.#path]; } + set contextMenu(value) { + this.#contextMenu = Array.isArray(value) ? value : []; + this.#closeMenu(); + } + + get contextMenu() { return this.#contextMenu; } + #emit(name, detail) { this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail })); } @@ -49,6 +63,111 @@ export class OctopusFileBrowser extends HTMLElementBase { this.#emit('octopus:open', { entry, path: [...this.#path, entry.name] }); } + #bindMenu(row, entry) { + row.addEventListener('contextmenu', (event) => { + const items = menuItemsFor(this.#contextMenu, entry, [...this.#path, entry.name]); + if (!items.length) return; + event.preventDefault(); + this.#cancelPress(); + if (this.#menuRow === row) return; // Already opened by a long press. + const fromKeyboard = !event.clientX && !event.clientY; + const rect = row.getBoundingClientRect(); + this.#openMenu(row, entry, items, fromKeyboard ? rect.left + 16 : event.clientX, fromKeyboard ? rect.bottom : event.clientY); + }); + + // iOS never fires contextmenu for touch, so long presses are detected here. + row.addEventListener('pointerdown', (event) => { + if (event.pointerType !== 'touch' || !this.#contextMenu.length) return; + this.#cancelPress(); + const { clientX: x, clientY: y } = event; + const timer = setTimeout(() => { + this.#press = null; + const items = menuItemsFor(this.#contextMenu, entry, [...this.#path, entry.name]); + if (items.length && this.#menuRow !== row) this.#openMenu(row, entry, items, x, y); + }, LONG_PRESS_MS); + this.#press = { timer, x, y }; + }); + row.addEventListener('pointermove', (event) => { + if (this.#press && Math.hypot(event.clientX - this.#press.x, event.clientY - this.#press.y) > LONG_PRESS_SLOP) + this.#cancelPress(); + }); + for (const type of ['pointerup', 'pointercancel', 'pointerleave']) + row.addEventListener(type, () => this.#cancelPress()); + } + + #cancelPress() { + if (!this.#press) return; + clearTimeout(this.#press.timer); + this.#press = null; + } + + #openMenu(row, entry, items, x, y) { + this.#closeMenu(); + const path = [...this.#path, entry.name]; + const menu = element('div', 'menu'); + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', entry.name); + menu.setAttribute('popover', 'manual'); + for (const item of items) { + const button = element('button', item.danger ? 'menu-item danger' : 'menu-item', item.label); + button.type = 'button'; + button.tabIndex = -1; + button.setAttribute('role', 'menuitem'); + button.addEventListener('click', () => { + this.#closeMenu(true); + item.action?.(entry, path); + }); + menu.append(button); + } + menu.addEventListener('keydown', (event) => this.#onMenuKey(event)); + this.shadowRoot.append(menu); + menu.showPopover?.(); + + const { width, height } = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(4, Math.min(x, innerWidth - width - 4))}px`; + menu.style.top = `${y + height > innerHeight - 4 ? Math.max(4, y - height) : y}px`; + + this.#menu = menu; + this.#menuRow = row; + document.addEventListener('pointerdown', this.#onOutsidePointer, true); + addEventListener('scroll', this.#onDismiss, true); + addEventListener('resize', this.#onDismiss); + addEventListener('blur', this.#onDismiss); + menu.firstChild.focus({ preventScroll: true }); + } + + #closeMenu(restoreFocus = false) { + if (!this.#menu) return; + const row = this.#menuRow; + this.#menu.remove(); + this.#menu = null; + this.#menuRow = null; + document.removeEventListener('pointerdown', this.#onOutsidePointer, true); + removeEventListener('scroll', this.#onDismiss, true); + removeEventListener('resize', this.#onDismiss); + removeEventListener('blur', this.#onDismiss); + if (restoreFocus && row.isConnected) row.focus(); + } + + #onOutsidePointer = (event) => { + if (!event.composedPath().includes(this.#menu)) this.#closeMenu(); + }; + + #onDismiss = () => this.#closeMenu(); + + #onMenuKey(event) { + const items = [...this.#menu.children]; + const index = items.indexOf(this.shadowRoot.activeElement); + const next = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 }[event.key]; + if (next !== undefined) { + event.preventDefault(); + items[(next + items.length) % items.length].focus(); + } else if (event.key === 'Escape' || event.key === 'Tab') { + event.preventDefault(); + this.#closeMenu(true); + } + } + #renderEntry(entry) { const item = element('li'); const row = element('button', 'entry'); @@ -57,7 +176,9 @@ export class OctopusFileBrowser extends HTMLElementBase { row.setAttribute('aria-label', entry.parent ? 'Parent folder' : `${entry.name}, ${entry.type === 'directory' ? 'folder' : fileKindLabel(entry.name)}`); - row.addEventListener('click', () => this.#open(entry)); + // While a menu is open, a click is the release of the long press that opened it. + row.addEventListener('click', () => { if (!this.#menu) this.#open(entry); }); + if (!entry.parent) this.#bindMenu(row, entry); const name = element('span', 'cell name'); const icon = element('img', 'icon'); @@ -77,6 +198,8 @@ export class OctopusFileBrowser extends HTMLElementBase { #render() { if (!this.shadowRoot || typeof document === 'undefined') return; + this.#cancelPress(); + this.#closeMenu(); const style = element('style'); style.textContent = styles; const list = element('ul', 'list'); @@ -95,6 +218,7 @@ if (globalThis.customElements && !customElements.get('octopus-file-browser')) export function createOctopusFileBrowser(target, options = {}) { const browser = document.createElement('octopus-file-browser'); if (options.emptyLabel) browser.setAttribute('empty-label', options.emptyLabel); + if (options.contextMenu) browser.contextMenu = options.contextMenu; browser.path = options.path || []; browser.entries = options.entries || []; target.append(browser); diff --git a/test/file-browser-utils.test.js b/test/file-browser-utils.test.js index f8361f6..02875f0 100644 --- a/test/file-browser-utils.test.js +++ b/test/file-browser-utils.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries } from '../src/file-browser-utils.js'; +import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, menuItemsFor, sortEntries } from '../src/file-browser-utils.js'; test('sortEntries keeps folders first and names natural', () => { const sorted = sortEntries([ @@ -36,3 +36,15 @@ test('fileKindLabel identifies special and common files', () => { assert.equal(fileKindLabel('app.js'), 'JavaScript'); assert.equal(fileKindLabel('Dockerfile'), 'File'); }); + +test('menuItemsFor keeps items whose when() matches the entry', () => { + const items = [ + { label: 'All' }, + { label: 'Folders', when: (entry) => entry.type === 'directory' }, + { label: 'JSON', when: (entry, path) => entry.name.endsWith('.json') && path.length === 2 }, + ]; + const labels = (entry, path) => menuItemsFor(items, entry, path).map(({ label }) => label); + assert.deepEqual(labels({ name: 'src', type: 'directory' }, ['src']), ['All', 'Folders']); + assert.deepEqual(labels({ name: 'a.json', type: 'file' }, ['src', 'a.json']), ['All', 'JSON']); + assert.deepEqual(menuItemsFor(undefined, { name: 'a', type: 'file' }, ['a']), []); +});