From fbfb8d15a1fa37318fa14d0e1bf7eaf7b65b7ce5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 21 May 2026 08:45:11 -0500 Subject: [PATCH] feat(browse): verb-aware gating on rename/delete + editor save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse's row context menu and in-place editors now consult the server-computed verbs string (via window.zddc.cap.has) before enabling write/delete affordances: - Rename… disables when the entry's verbs lacks 'w'. - Delete… disables when verbs lacks 'd'. - Markdown editor mounts read-only when verbs lacks 'w'. - YAML editor mounts read-only when verbs lacks 'w' for regular files, 'a' for the .zddc placeholder (matches the file API's ActionAdmin gate at that URL). Disabled menu items carry a tooltip naming the missing access ("You don't have write access to this item.") so the user discovers which permission is missing rather than just seeing a greyed row. shared/context-menu.js gains a `tooltip` field (string or fn(ctx)) that sets the row's title attribute. canMutate() stays as the source-side gate (server vs FS-API reachability, zip-member / virtual filtering); verbs gate composes on top. Server-side ACL still has the final say if a stale client ever tries the action. cap.has() falls back to node.writable for 'w' when verbs is absent, so offline FS-API mode keeps working without a server. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/js/events.js | 31 +++++++++++++++++++++++++++++-- browse/js/preview-markdown.js | 13 ++++++++----- browse/js/preview-yaml.js | 13 +++++++++---- shared/context-menu.js | 17 ++++++++++++----- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/browse/js/events.js b/browse/js/events.js index c868967..495f704 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -895,16 +895,43 @@ { separator: true }, // ── Rename + Delete (the permission-gated pair) ── + // + // Two gates compose: canMutate() rules out un-writable + // sources (offline FS-API without a handle, zip members, + // virtual placeholders) and zddc.cap.has(node, verb) + // applies the server-computed cascade verbs. If either + // bars the action, the item disables with a tooltip + // explaining the reason — server-side ACL still has the + // final say on the actual PUT/DELETE if a stale client + // somehow tries. { label: 'Rename…', - disabled: function (c) { return !canMutate(c); }, + disabled: function (c) { + if (!canMutate(c)) return true; + if (!window.zddc.cap) return false; + return !window.zddc.cap.has(c.node, 'w'); + }, + tooltip: function (c) { + if (!canMutate(c)) return ''; + if (!window.zddc.cap || window.zddc.cap.has(c.node, 'w')) return ''; + return "You don't have write access to this item."; + }, action: function (c) { renameNode(c.node); } }, { label: 'Delete…', icon: '🗑', danger: true, - disabled: function (c) { return !canMutate(c); }, + disabled: function (c) { + if (!canMutate(c)) return true; + if (!window.zddc.cap) return false; + return !window.zddc.cap.has(c.node, 'd'); + }, + tooltip: function (c) { + if (!canMutate(c)) return ''; + if (!window.zddc.cap || window.zddc.cap.has(c.node, 'd')) return ''; + return "You don't have delete access to this item."; + }, action: function (c) { deleteNode(c.node); } }, { separator: true }, diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 99efceb..faad30a 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -304,11 +304,14 @@ function canSave(node) { if (isZipMemberNode(node)) return false; - // Server-computed authority gate. The listing's `writable` - // bit reflects what a PUT would do — false here means the - // file API would 403 the save, so we mount in read-only - // mode rather than letting the user type and lose changes. - if (node.url && window.app.state.source === 'server' && !node.writable) return false; + // Server-computed authority gate. The listing's verbs string + // tells us whether a PUT to this entry would be allowed — + // false here means the file API would 403, so we mount in + // read-only mode rather than letting the user type and lose + // changes. cap.has() falls back to node.writable for 'w' + // when verbs is absent (offline FS-API listings). + if (node.url && window.app.state.source === 'server' + && window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index f21e1b6..c6b4883 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -82,10 +82,15 @@ // user home, canonical-folder virtuals) is just a tree // affordance, not a writable file. if (node.virtual && node.name !== '.zddc') return false; - // Server-computed authority gate. Mirrors the markdown editor's - // check — listing's `writable` bit is the same decision the - // file API would reach on PUT. - if (node.url && window.app.state.source === 'server' && !node.writable) return false; + // Server-computed authority gate. The virtual .zddc entry + // requires the admin verb 'a' (matches fileapi.go's + // ActionAdmin gate at the .zddc URL); regular YAML files + // require write 'w'. cap.has falls back to node.writable for + // 'w' when verbs is absent (offline FS-API listings). + if (node.url && window.app.state.source === 'server' && window.zddc.cap) { + var needed = node.name === '.zddc' ? 'a' : 'w'; + if (!window.zddc.cap.has(node, needed)) return false; + } if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; diff --git a/shared/context-menu.js b/shared/context-menu.js index 4c46718..4932e8d 100644 --- a/shared/context-menu.js +++ b/shared/context-menu.js @@ -9,8 +9,11 @@ // // `items` is an array (or a function returning an array, evaluated // against `context` at open-time). Each entry is one of: -// { label, action, icon?, accel?, disabled?, visible?, danger? } +// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? } // — a normal menu item; `action(ctx)` fires on click/Enter. +// `tooltip` (string or fn(ctx)) sets the row's title attribute — +// useful for explaining WHY a disabled item is unavailable +// ("You don't have write access here", etc.). // { label, checked, action, ... } // — toggle item; `checked` may be a bool or a fn(ctx). Renders // a ✓ in the gutter when truthy. @@ -21,10 +24,10 @@ // are collapsed automatically so callers can build items // conditionally without managing dividers. // -// Any of `label`, `checked`, `visible`, `disabled`, and `items` may -// be a function — each is invoked with the context object so callers -// can render fully context-aware menus from a single declarative -// config. +// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and +// `items` may be a function — each is invoked with the context object +// so callers can render fully context-aware menus from a single +// declarative config. // // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // submenu, ArrowLeft / Escape backs up one level (or closes if @@ -146,6 +149,10 @@ row.classList.add('is-disabled'); row.setAttribute('aria-disabled', 'true'); } + if ('tooltip' in item) { + var tip = resolve(item.tooltip, ctx); + if (tip) row.title = String(tip); + } row.setAttribute('role', hasSub ? 'menuitem' : (isToggle ? 'menuitemcheckbox' : 'menuitem'));