From e2179d167b166c128fc4fd40d6014eafb457b9d5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 4 Jun 2026 07:21:02 -0500 Subject: [PATCH] feat(browse): capability/role/tier-driven, context-correct menu system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the browse menu/tree interaction into a declarative, contextually honest model and moves view settings onto a toolbar — the menu is the UI to the system, so it should be familiar, inviting, and only ever offer what applies. New declarative menu model (browse/js/menu-model.js): - Every action is one descriptor with a TYPE predicate (appliesTo) and a CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over it; separators are derived from group changes. Designed data-shaped so a future server-sourced manifest (zddc.zip) can supply/extend it. - Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a file, Expand on a file); permission/role/tier-gated actions are SHOWN DISABLED with a reason — so a lower tier sees what a higher role unlocks. - Roles are NOT hardcoded: ordinary actions gate on the verbs the server returns (node.verbs / path_verbs), so any operator-defined role works. Only the two intrinsically-special tiers are recognised by name — site admin (is_super_admin) and project/subtree admin (path_is_admin), surfaced as the "Edit access rules…" item; both come from the existing /.profile/access. - The headline fix: New folder / New markdown file no longer appear on file rows (they target a folder or the current dir). events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/ SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor /openPaneMenu path shared by right-click, the hover kebab, and the keyboard menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on load/rescope/refresh/popstate so menus never fetch at open time. Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite, revealed on hover/selection/focus) opens the same menu; keyboard menu key supported. Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the tree-pane toolbar, plus New folder / New file buttons (act on the current dir, greyed with a reason when create access is lacking). Help copy updated. Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder style); only new sprite is the kebab's icon-ellipsis. Tests: +5 browse specs (file row omits New-folder; folder row shows it; a read-only server node greys Rename with a "write access" tooltip via a pure menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present; kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/build.sh | 1 + browse/css/tree.css | 62 +++++ browse/js/app.js | 2 + browse/js/events.js | 513 ++++++++++++---------------------------- browse/js/init.js | 8 + browse/js/menu-model.js | 449 +++++++++++++++++++++++++++++++++++ browse/js/tree.js | 8 + browse/template.html | 33 ++- shared/icons.js | 6 + tests/browse.spec.js | 89 +++++++ 10 files changed, 805 insertions(+), 366 deletions(-) create mode 100644 browse/js/menu-model.js diff --git a/browse/build.sh b/browse/build.sh index 4c56f72..8ffa917 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -65,6 +65,7 @@ concat_files \ "js/init.js" \ "js/util.js" \ "js/conflict.js" \ + "js/menu-model.js" \ "js/loader.js" \ "js/tree.js" \ "js/preview.js" \ diff --git a/browse/css/tree.css b/browse/css/tree.css index 9feafea..0904844 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -324,6 +324,68 @@ body { color: var(--text); } +/* Per-row "⋯" actions button — the visible affordance that a row has a + context menu. Hidden until the row is hovered/selected or the button + itself is keyboard-focused, so it stays out of the way during reading + but is discoverable without knowing to right-click. Pushed to the right + edge; never part of the tab order (rows use roving tabindex). */ +.tree-row__kebab { + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4rem; + height: 1.4rem; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted, #888); + border-radius: var(--radius); + cursor: pointer; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; +} +.tree-row__kebab svg { width: 1em; height: 1em; } +.tree-row:hover .tree-row__kebab, +.tree-row.is-selected .tree-row__kebab, +.tree-row__kebab:focus-visible { + opacity: 1; +} +.tree-row__kebab:hover, +.tree-row__kebab:focus-visible { + background: var(--bg-hover); + color: var(--text); +} + +/* Tree-pane toolbar controls row (New folder/file, Sort, Show hidden), + sitting under the filter input. */ +.tree-pane__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-top: 0.4rem; +} +.tree-pane__controls .tp-control { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-muted, #888); +} +.tree-pane__controls .tp-control--check { cursor: pointer; } +.tree-pane__controls select { + font-family: var(--font); + font-size: 0.8rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.15rem 0.3rem; +} + /* Per-row drop target highlight: applied while a file/folder drag is hovering this row. The dashed outline reads as "drop here" without shifting layout. */ diff --git a/browse/js/app.js b/browse/js/app.js index a2d32f5..9bae039 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -91,6 +91,7 @@ tree.setRoot(detected.entries); events.showBrowseRoot(); tree.render(); + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); events.statusInfo('Loaded ' + detected.entries.length + ' item' + (detected.entries.length === 1 ? '' : 's') + ' from ' + detected.path); @@ -133,6 +134,7 @@ window.app.state.lastPreviewedNodeId = null; tree.setRoot(es); tree.render(); + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); // Route through clearPreview so a live editor is disposed // (not leaked) when back/forward swaps scope. var pmod = window.app.modules.preview; diff --git a/browse/js/events.js b/browse/js/events.js index 423dc0e..94f1aef 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -88,6 +88,21 @@ refresh.classList.add('hidden'); } } + // Toolbar New buttons: enabled when there's a writable target, and in + // server mode greyed (with a why-tooltip) when the scope lacks the + // create verb. Mirrors the menu's create-gate. + var canCreate = canCreateHere(); + var lacksCreateVerb = state.source === 'server' + && state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string' + && state.scopeAccess.path_verbs.indexOf('c') === -1; + ['newFolderBtn', 'newFileBtn'].forEach(function (id) { + var b = document.getElementById(id); + if (!b) return; + var off = !canCreate || lacksCreateVerb; + b.disabled = off; + b.title = lacksCreateVerb ? 'You don’t have create access here.' + : (!canCreate ? 'Open a folder to create files here.' : ''); + }); } // syncURLToSelection reflects the current scope + selected node + @@ -165,6 +180,7 @@ await tree.restoreState(snap); if (!isCurrentNav(seq)) return; tree.render(); + prefetchScopeAccess(); statusInfo('Refreshed (' + raw.length + ' item' + (raw.length === 1 ? '' : 's') + ')'); } else if (state.source === 'fs' && state.rootHandle) { @@ -185,6 +201,23 @@ } function init() { + // Inject the action implementations the declarative menu-model + // delegates to (avoids an events ↔ menu-model circular dependency). + var mm = window.app.modules.menuModel; + if (mm && mm.configure) { + mm.configure({ + createInDir: createInDir, + renameNode: renameNode, + deleteNode: deleteNode, + navigateIntoFolder: navigateIntoFolder, + refreshListing: refreshListing, + parentDirFor: parentDirFor, + canCreateHere: canCreateHere, + statusInfo: statusInfo, + statusError: statusError + }); + } + // Header buttons var btn = document.getElementById('addDirectoryBtn'); if (btn) btn.addEventListener('click', pickLocalDir); @@ -192,6 +225,37 @@ var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); + // ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ── + // View settings live on the toolbar (not in per-row right-click + // menus); create has a discoverable affordance here now that file + // rows no longer offer it. + var newFolderBtn = document.getElementById('newFolderBtn'); + if (newFolderBtn) newFolderBtn.addEventListener('click', function () { + createInDir(state.currentPath || '/', 'folder'); + }); + var newFileBtn = document.getElementById('newFileBtn'); + if (newFileBtn) newFileBtn.addEventListener('click', function () { + createInDir(state.currentPath || '/', 'markdown'); + }); + var sortSelect = document.getElementById('sortSelect'); + if (sortSelect) { + // Reflect current state, then drive setSortExplicit on change. + sortSelect.value = state.sort.key + ':' + state.sort.dir; + sortSelect.addEventListener('change', function () { + var parts = sortSelect.value.split(':'); + tree.setSortExplicit(parts[0], parseInt(parts[1], 10) === -1 ? -1 : 1); + }); + } + var showHiddenChk = document.getElementById('showHiddenChk'); + if (showHiddenChk) { + showHiddenChk.checked = !!state.showHidden; + showHiddenChk.addEventListener('change', function () { + state.showHidden = showHiddenChk.checked; + syncURLToSelection(); + refreshListing(); + }); + } + // Tree autofilter — parses input through zddc.filter.parse so // the same query grammar that the archive app uses (terms, // quotes, !negation, multi-word AND) works here. The AST is @@ -286,6 +350,16 @@ treeBody.addEventListener('click', function (e) { var row = e.target.closest('.tree-row'); if (!row) return; + // Kebab (⋯) button → open the row menu at the button; must run + // BEFORE the toggle/preview logic so it doesn't also fire those. + var kebab = e.target.closest('.tree-row__kebab'); + if (kebab) { + e.preventDefault(); + e.stopPropagation(); + var r = kebab.getBoundingClientRect(); + openRowMenuFor(row, r.right, r.bottom); + return; + } var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; @@ -382,6 +456,22 @@ // if collapsed/leaf // Enter / Space — preview file / toggle folder // Home / End — first / last visible row + // Keyboard menu key — ContextMenu key or Shift+F10 opens the row + // menu at the selected row (standard file-manager / a11y gesture). + document.addEventListener('keydown', function (e) { + var tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.target && e.target.isContentEditable) return; + if (document.querySelector('.modal-overlay, .zddc-menu')) return; + var isMenuKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'); + if (!isMenuKey || state.selectedId == null) return; + var selRow = treeBody.querySelector('.tree-row[data-id="' + state.selectedId + '"]'); + if (!selRow) return; + e.preventDefault(); + var rr = selRow.getBoundingClientRect(); + openRowMenuFor(selRow, rr.left + 16, rr.bottom - 4); + }); + document.addEventListener('keydown', function (e) { // Skip editable contexts. var tag = (e.target && e.target.tagName) || ''; @@ -483,27 +573,8 @@ treeBody.addEventListener('contextmenu', function (e) { e.preventDefault(); var row = e.target.closest('.tree-row'); - if (row) { - var id = parseInt(row.dataset.id, 10); - var node = state.nodes.get(id); - if (!node) return; - state.selectedId = id; - tree.render(); - syncURLToSelection(); - window.zddc.menu.open({ - x: e.clientX, - y: e.clientY, - context: { node: node, row: row }, - items: buildTreeRowMenu - }); - } else { - window.zddc.menu.open({ - x: e.clientX, - y: e.clientY, - context: { dir: state.currentPath || '/' }, - items: buildPaneMenu - }); - } + if (row) openRowMenuFor(row, e.clientX, e.clientY); + else openPaneMenu(e.clientX, e.clientY); }); // Per-row drag-drop. Any row is a drop target — folders @@ -874,7 +945,6 @@ } } - function createInside(node, kind) { return createInDir(parentDirFor(node), kind); } // Reload a directory's children in the tree so a create/delete/ // rename is reflected. Works for both the current scope (root) @@ -987,42 +1057,6 @@ } } - // Shared submenu (used by both the row menu and the pane menu). - // Toggle items so the active sort is checked in both surfaces. - var SORT_BY_ITEMS = [ - { label: 'Name', - checked: function () { return state.sort.key === 'name'; }, - action: function () { tree.setSortExplicit('name', 1); } }, - { label: 'Modified', - checked: function () { return state.sort.key === 'date'; }, - action: function () { tree.setSortExplicit('date', -1); } }, - { label: 'Size', - checked: function () { return state.sort.key === 'size'; }, - action: function () { tree.setSortExplicit('size', -1); } }, - { label: 'Type', - checked: function () { return state.sort.key === 'ext'; }, - action: function () { tree.setSortExplicit('ext', 1); } } - ]; - - // Row context menu — traditional file-manager layout: - // Open / Open in new tab / Pop out preview - // ─ - // Download (label flips on type) - // ─ - // New folder / New markdown file - // ─ - // Rename / Delete (permission-gated, disabled - // when the row can't be mutated) - // ─ - // Copy path / Copy name - // ─ - // Expand / Collapse / Navigate into - // ─ - // Sort by … / Show hidden files - // - // Items are kept VISIBLE but DISABLED when they don't apply, so - // every menu has the same shape regardless of what the user - // right-clicked. Predictable position = muscle memory. // canCreateHere — whether New folder/file has a writable target: the // server (ACL decides the rest) or a picked local folder (the // filesystem permission decides, escalated on first write). @@ -1030,316 +1064,65 @@ return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); } - function buildTreeRowMenu(ctx) { - var serverMode = state.source === 'server'; - var canMutate = function (c) { - var up = window.app.modules.upload; - return !!(up && up.canMutate(c.node)); - }; - return [ - // ── Open / preview cluster ── - { - label: function (c) { - if (c.node.isDir) return 'Open'; - if (c.node.isZip) return 'Open archive'; - return 'Preview'; - }, - disabled: function (c) { return !!c.node.virtual; }, - action: function (c) { - if (c.node.isDir || c.node.isZip) { - tree.toggleFolder(c.node.id); - } else { - var p = previewMod(); - if (p) p.showFilePreview(c.node); - } - } - }, - { - label: 'Open in new tab', - accel: 'Ctrl+Click', - disabled: function (c) { return !c.node.url; }, - action: function (c) { - if (c.node.url) window.open(c.node.url, '_blank', 'noopener'); - } - }, - { - label: 'Pop out preview', - disabled: function (c) { return c.node.isDir || c.node.isZip; }, - action: function (c) { - var p = previewMod(); - if (p) p.showFilePreview(c.node, { popup: true }); - } - }, - { separator: true }, + // ── Menu opening (row / pane / kebab / keyboard) ────────────────────── + // The menu CONTENTS come from the declarative menu-model; this layer just + // resolves the target, syncs selection, and positions the menu. All four + // entry points (right-click row, right-click pane, kebab button, keyboard + // menu key) funnel through here so they stay identical. - // ── Download (single item; label flips on type) ── - { - label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; }, - icon: '⤓', - disabled: function (c) { return !!c.node.virtual; }, - action: function (c) { - var d = window.app.modules.download; - if (!d) return; - if (c.node.isDir) d.downloadFolder(c.node); - else d.downloadFile(c.node); - } - }, - { separator: true }, + // The prefetched /.profile/access view for the current scope (set on every + // listing load — see prefetchScopeAccess). Returned synchronously; the + // menu never triggers a fetch at open time. null until prefetched / FS mode. + function prefetchedAccess() { return state.scopeAccess; } - // ── Create new (in the row's parent folder) ── - { - label: 'New folder', - disabled: !canCreateHere(), - action: function (c) { createInside(c.node, 'folder'); } - }, - { - label: 'New markdown file', - disabled: !canCreateHere(), - action: function (c) { createInside(c.node, 'markdown'); } - }, - { separator: true }, + function menuModel() { return window.app.modules.menuModel; } - // ── 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 — when the listing carries - // server-cascade verbs — zddc.cap.has(node, verb) applies - // the per-entry ACL. The verbs gate is server-mode only; - // file:// FS-API and plain Caddy listings have no verbs - // field, so we fall back to canMutate alone (FS-API - // enforces locally; Caddy has no PUT/DELETE either way). - // Server-side ACL still has the final say on the actual - // PUT/DELETE if a stale client tries the action. - { - label: 'Rename…', - disabled: function (c) { - if (!canMutate(c)) return true; - if (!serverMode || !window.zddc.cap) return false; - // verbs===undefined → Caddy or other non-zddc - // server, no cascade signal to gate on. verbs==="" - // is zddc-server's explicit zero grant; still - // gate (disable). verbs==="rw…" → check the bit. - if (typeof c.node.verbs !== 'string') return false; - return !window.zddc.cap.has(c.node, 'w'); - }, - tooltip: function (c) { - if (!serverMode || !canMutate(c)) return ''; - if (!window.zddc.cap) return ''; - if (typeof c.node.verbs !== 'string') return ''; - if (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) { - if (!canMutate(c)) return true; - if (!serverMode || !window.zddc.cap) return false; - if (typeof c.node.verbs !== 'string') return false; - return !window.zddc.cap.has(c.node, 'd'); - }, - tooltip: function (c) { - if (!serverMode || !canMutate(c)) return ''; - if (!window.zddc.cap) return ''; - if (typeof c.node.verbs !== 'string') return ''; - if (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 }, - - // ── Clipboard / identifiers ── - { - label: 'Copy path', - action: function (c) { - var path = tree.pathFor(c.node); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(path).then( - function () { statusInfo('Copied: ' + path); }, - function () { statusError('Clipboard copy denied'); } - ); - } else { - statusInfo(path); - } - } - }, - { - label: 'Copy name', - action: function (c) { - // Always include the file extension. node.name - // already does for normal listings, but re-joining - // via zddc.joinExtension is defensive against any - // upstream that ever returns the basename split. - var n = c.node.name; - var ext = c.node.ext; - if (!c.node.isDir && ext - && !n.toLowerCase().endsWith('.' + ext.toLowerCase())) { - n = window.zddc.joinExtension(n, ext); - } - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(n); - } - statusInfo('Copied: ' + n); - } - }, - { separator: true }, - - // ── Tree-view ops (folder/zip rows only) ── - { - label: 'Expand subtree', - accel: 'Shift+Click', - disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, - action: function (c) { tree.expandSubtree(c.node.id); } - }, - { - label: 'Collapse subtree', - disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, - action: function (c) { tree.collapseSubtree(c.node.id); } - }, - { - label: 'Navigate into', - accel: 'Dbl-click', - disabled: function (c) { return !c.node.isDir; }, - action: function (c) { navigateIntoFolder(c.node); } - }, - { separator: true }, - - // ── Plan Review (received// only, cascade-gated) ── - { - label: 'Plan Review…', - visible: function (c) { - if (!serverMode) return false; - if (!state.scopeOnPlanReview) return false; - var pr = window.app.modules.planReview; - if (!pr) return false; - return pr.isReceivedTrackingFolder(c.node); - }, - action: function (c) { - var pr = window.app.modules.planReview; - if (pr) pr.invoke(c.node); - } - }, - // ── Accept Transmittal (transmittal folder under incoming/) ── - { - label: 'Accept Transmittal…', - visible: function (c) { - if (!serverMode) return false; - var at = window.app.modules.acceptTransmittal; - if (!at) return false; - return at.isAcceptableTransmittalFolder(c.node); - }, - action: function (c) { - var at = window.app.modules.acceptTransmittal; - if (at) at.invoke(c.node); - } - }, - // ── Stage / Unstage (files under working/ or staging/) ── - { - label: 'Stage to…', - visible: function (c) { - if (!serverMode) return false; - var s = window.app.modules.stage; - return !!(s && s.isStageableFile(c.node)); - }, - action: function (c) { - var s = window.app.modules.stage; - if (s) s.invokeStage(c.node); - } - }, - { - label: 'Unstage to working/', - visible: function (c) { - if (!serverMode) return false; - var s = window.app.modules.stage; - return !!(s && s.isUnstageableFile(c.node)); - }, - action: function (c) { - var s = window.app.modules.stage; - if (s) s.invokeUnstage(c.node); - } - }, - // ── Version history (history:true subtree, real files only) ── - // Server-mode only: the audit trail (who saved when) is - // server-stamped, so there's no offline equivalent. node.history - // is set by the listing when this file sits in a history-enabled - // cascade subtree (working/). - { - label: 'History…', - icon: '🕘', - visible: function (c) { - if (!serverMode) return false; - if (c.node.isDir || c.node.isZip || c.node.virtual) return false; - return !!c.node.history; - }, - action: function (c) { - var h = window.app.modules.history; - if (h) h.open(c.node); - } - }, - { separator: true }, - - // ── View ── - { label: 'Sort by', items: SORT_BY_ITEMS }, - { label: 'Show hidden files', - checked: function () { return !!state.showHidden; }, - action: function () { - state.showHidden = !state.showHidden; - syncURLToSelection(); - refreshListing(); - } } - ]; + function openRowMenuFor(row, x, y) { + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + // Select the row first so the highlight + menu target agree. + state.selectedId = id; + tree.render(); + syncURLToSelection(); + var mm = menuModel(); + if (!mm) return; + window.zddc.menu.open({ + x: x, y: y, + context: { node: node, row: row, surface: 'row' }, + items: function () { return mm.buildRowItems(node, row, prefetchedAccess()); } + }); } - // Right-click on empty space in the tree pane → directory-scope - // menu. Operations apply to the current scope (state.currentPath), - // not any specific row. - function buildPaneMenu() { - var serverMode = state.source === 'server'; - return [ - { - label: 'New folder', - disabled: !canCreateHere(), - action: function () { createInDir(state.currentPath || '/', 'folder'); } - }, - { - label: 'New markdown file', - disabled: !canCreateHere(), - action: function () { createInDir(state.currentPath || '/', 'markdown'); } - }, - // ── Create Transmittal folder (staging/ scope only) ── - { - label: 'Create Transmittal folder…', - visible: function () { - return serverMode && state.scopeCanonicalFolder === 'staging'; - }, - action: function () { - var ct = window.app.modules.createTransmittal; - if (ct) ct.invoke(); - } - }, - { separator: true }, - { - label: 'Refresh', - accel: 'F5', - action: function () { refreshListing(); } - }, - { separator: true }, - { label: 'Sort by', items: SORT_BY_ITEMS }, - { label: 'Show hidden files', - checked: function () { return !!state.showHidden; }, - action: function () { - state.showHidden = !state.showHidden; - syncURLToSelection(); - refreshListing(); - } } - ]; + function openPaneMenu(x, y) { + var mm = menuModel(); + if (!mm) return; + window.zddc.menu.open({ + x: x, y: y, + context: { dir: state.currentPath || '/', surface: 'pane' }, + items: function () { return mm.buildPaneItems(prefetchedAccess()); } + }); } + // Prefetch (memoised) the scope access view so the menu's create-gate and + // admin/sub-admin tier items resolve without a fetch. Server-mode only; + // cap.at returns null on file:// so FS mode leaves scopeAccess null. + function prefetchScopeAccess() { + if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) { + state.scopeAccess = null; + return; + } + var path = state.currentPath || '/'; + window.zddc.cap.at(path).then(function (view) { + // Ignore a stale resolution if the scope moved on. + if ((state.currentPath || '/') === path) { + state.scopeAccess = view || null; + applySourceUI(); + } + }, function () { /* best-effort; leave prior value */ }); + } + + // View mode is URL-driven, not UI-driven. // // ?view=grid → grid mode (only honored where classifier is @@ -1433,6 +1216,7 @@ // don't pushState/setRoot on top of it. if (!isCurrentNav(seq)) return; state.currentPath = url; + prefetchScopeAccess(); // Selection / preview belong to the old scope; clear them so // the new root doesn't carry stale highlight state. state.selectedId = null; @@ -1489,6 +1273,11 @@ // can't race the in-tool navigations. beginNav() claims the latest // token; isCurrentNav(seq) reports whether it's still latest. beginNav: beginNav, - isCurrentNav: isCurrentNav + isCurrentNav: isCurrentNav, + // Prefetch the current scope's /.profile/access view into + // state.scopeAccess (memoised) so the menu's create-gate + admin-tier + // items resolve without a fetch. Called by app.js on initial load + + // back/forward. + prefetchScopeAccess: prefetchScopeAccess }; })(); diff --git a/browse/js/init.js b/browse/js/init.js index be66c31..55f39d4 100644 --- a/browse/js/init.js +++ b/browse/js/init.js @@ -79,6 +79,14 @@ scopeCanonicalFolder: '', scopeOnPlanReview: false, + // Prefetched /.profile/access view for the CURRENT scope + // (state.currentPath), via cap.at() — memoised. Supplies + // path_verbs / path_is_admin / path_roles to the menu model for + // pane-scope create gating and the admin/sub-admin tier items, so + // the menu never fetches at open time. null until prefetched / in + // FS-Access (offline) mode. + scopeAccess: null, + // Whether the listing includes dotfiles. Toggled by the // "Show hidden files" menu item; URL-persisted via ?hidden=1. showHidden: false, diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js new file mode 100644 index 0000000..9a2570c --- /dev/null +++ b/browse/js/menu-model.js @@ -0,0 +1,449 @@ +// menu-model.js — the declarative source of truth for the browse tool's +// action menus (right-click row menu, right-click pane menu, the keyboard +// menu key, and the hover kebab). +// +// Every action is declared ONCE as a descriptor. The row/pane menus are +// projections over that list, filtered by surface + an `appliesTo` TYPE +// predicate and annotated with an `enabled` CAPABILITY predicate: +// +// appliesTo(ctx) === false → the item is OMITTED (it doesn't make sense +// for this target — e.g. "New folder" on a +// file row, "Expand" on a file). +// appliesTo true, enabled +// (ctx) === false → the item is SHOWN DISABLED with a tooltip +// naming what's required (write access / +// create access / project-admin / site-admin). +// +// That hybrid realizes the cumulative guest ⊂ project-team ⊂ sub-admin ⊂ +// admin menus: a lower tier SEES higher-tier actions greyed and learns they +// exist, while type-irrelevant noise is hidden. +// +// Roles are NOT hardcoded: ordinary actions gate on the verbs the server +// returns per entry (node.verbs) or per scope (cap.at → path_verbs), so any +// operator-defined role works. Only two intrinsically-special tiers are +// recognised by name — site admin (is_super_admin / IsAdmin) and project / +// subtree admin (path_is_admin / IsSubtreeAdmin) — because they govern +// administration itself and can't be expressed as a plain verb bundle. +// +// Deliberately data-shaped so a future server-sourced manifest (zddc.zip) +// can supply or extend the descriptors without touching the tool code. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + var state = window.app.state; + + // Action implementations are injected by events.init() via configure() + // to avoid an events ↔ menu-model circular dependency. Everything else + // (tree, preview, download, workflow modules) is reached through + // window.app.modules at call time. + var act = {}; + function configure(a) { act = a || {}; } + + // ── Predicates ──────────────────────────────────────────────────────── + + function isServer() { return state.source === 'server'; } + function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); } + function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); } + function cap() { return window.zddc && window.zddc.cap; } + + function canVerb(node, verb) { + return !!(node && cap() && cap().has(node, verb)); + } + function pathHasVerb(access, verb) { + return !!(access && typeof access.path_verbs === 'string' + && access.path_verbs.indexOf(verb) !== -1); + } + function isSiteAdmin(access) { return !!(access && access.is_super_admin); } + function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); } + + function tierLabelForMissing(missing) { + switch (missing) { + case 'w': return "You don't have write access to this item."; + case 'd': return "You don't have delete access to this item."; + case 'c': return 'You don’t have create access here.'; + case 'subtree-admin': return 'Project administrators only.'; + case 'site-admin': return 'Site administrators only.'; + default: return ''; + } + } + + // Rename/Delete gate — preserves today's compose exactly: canMutate rules + // out un-writable sources (offline FS without a handle, zip members, + // virtual placeholders) with no tooltip; when the server cascade reports + // verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs + // field) fall back to canMutate alone. Returns { enabled, missing }. + function verbGate(node, verb) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return { enabled: false, missing: '' }; + if (!isServer() || !cap()) return { enabled: true, missing: '' }; + if (typeof node.verbs !== 'string') return { enabled: true, missing: '' }; + if (cap().has(node, verb)) return { enabled: true, missing: '' }; + return { enabled: false, missing: verb }; + } + + // Create gate (New folder / New file). canCreateHere() rules out the + // no-target case (offline FS without a picked handle) — no tooltip there. + // In server mode, gate on the 'c' verb: per-node for a folder row, per + // scope for the pane. Unknown verbs → optimistic (server is the final + // arbiter, surfacing 403 via cap.handleForbidden, exactly as today). + function createGate(ctx) { + if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' }; + if (!isServer()) return { enabled: true, missing: '' }; + if (ctx.node) { // folder-row create → inside this folder + if (typeof ctx.node.verbs === 'string') { + return canVerb(ctx.node, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + // pane create → current scope + if (ctx.access && typeof ctx.access.path_verbs === 'string') { + return pathHasVerb(ctx.access, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + + // "Edit access rules" (.zddc) — the sub-admin / site-admin tier item. + // Enabled per-node when the entry grants the admin verb 'a', else by the + // scope's subtree-admin / site-admin status (admin authority cascades + // down a subtree). Returns { enabled, missing }. + function manageAccessGate(ctx) { + if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' }; + if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' }; + return { enabled: false, missing: 'subtree-admin' }; + } + + function insideZip(node) { + // Creating inside a zip member is impossible — the server can't PUT + // into an archive. Mirror tree.zipNestedInsideZip's URL heuristic. + if (!node) return false; + if (node.url && /\.zip\//i.test(node.url)) return true; + if (node.handle && node.handle.isZipEntry) return true; + return false; + } + + // ── Descriptors ───────────────────────────────────────────────────────── + // group order = visual order; a separator is inserted on each group change + // among the items that actually render (context-menu.js collapses extras). + var DESCRIPTORS = [ + // ── open ── + { + id: 'open', group: 'open', surfaces: ['row'], + label: function (ctx) { + if (ctx.node.isDir) return 'Open'; + if (ctx.node.isZip) return 'Open archive'; + return 'Preview'; + }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + if (ctx.node.isDir || ctx.node.isZip) { + var t = window.app.modules.tree; + if (t) t.toggleFolder(ctx.node.id); + } else { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node); + } + } + }, + { + id: 'open-new-tab', group: 'open', surfaces: ['row'], + label: 'Open in new tab', accel: 'Ctrl+Click', + appliesTo: function (ctx) { return !!ctx.node.url; }, + action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); } + }, + { + id: 'popout', group: 'open', surfaces: ['row'], + label: 'Pop out preview', + appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; }, + action: function (ctx) { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node, { popup: true }); + } + }, + + // ── io ── + { + id: 'download', group: 'io', surfaces: ['row'], + label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + var d = window.app.modules.download; + if (!d) return; + if (ctx.node.isDir) d.downloadFolder(ctx.node); + else d.downloadFile(ctx.node); + } + }, + + // ── create (folder rows + pane; NOT file rows) ── + { + id: 'new-folder', group: 'create', surfaces: ['row', 'pane'], + label: 'New folder', + appliesTo: function (ctx) { + if (ctx.surface === 'pane') return true; + return appliesToFolderLike(ctx.node) && !insideZip(ctx.node); + }, + enabled: function (ctx) { return createGate(ctx).enabled; }, + tooltip: function (ctx) { return tierLabelForMissing(createGate(ctx).missing); }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); } + }, + { + id: 'new-md', group: 'create', surfaces: ['row', 'pane'], + label: 'New markdown file', + appliesTo: function (ctx) { + if (ctx.surface === 'pane') return true; + return appliesToFolderLike(ctx.node) && !insideZip(ctx.node); + }, + enabled: function (ctx) { return createGate(ctx).enabled; }, + tooltip: function (ctx) { return tierLabelForMissing(createGate(ctx).missing); }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); } + }, + { + id: 'create-transmittal', group: 'create', surfaces: ['pane'], + label: 'Create Transmittal folder…', + appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; }, + action: function () { + var ct = window.app.modules.createTransmittal; + if (ct) ct.invoke(); + } + }, + + // ── mutate (permission-gated; shown disabled + tooltip) ── + { + id: 'rename', group: 'mutate', surfaces: ['row'], + label: 'Rename…', + appliesTo: function (ctx) { return !ctx.node.virtual; }, + enabled: function (ctx) { return verbGate(ctx.node, 'w').enabled; }, + tooltip: function (ctx) { return tierLabelForMissing(verbGate(ctx.node, 'w').missing); }, + action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); } + }, + { + id: 'delete', group: 'mutate', surfaces: ['row'], danger: true, + label: 'Delete…', + appliesTo: function (ctx) { return !ctx.node.virtual; }, + enabled: function (ctx) { return verbGate(ctx.node, 'd').enabled; }, + tooltip: function (ctx) { return tierLabelForMissing(verbGate(ctx.node, 'd').missing); }, + action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); } + }, + + // ── clipboard ── + { + id: 'copy-path', group: 'clipboard', surfaces: ['row'], + label: 'Copy path', + action: function (ctx) { + var t = window.app.modules.tree; + var path = t ? t.pathFor(ctx.node) : ctx.node.name; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(path).then( + function () { if (act.statusInfo) act.statusInfo('Copied: ' + path); }, + function () { if (act.statusError) act.statusError('Clipboard copy denied'); } + ); + } else if (act.statusInfo) { act.statusInfo(path); } + } + }, + { + id: 'copy-name', group: 'clipboard', surfaces: ['row'], + label: 'Copy name', + action: function (ctx) { + var n = ctx.node.name; + var ext = ctx.node.ext; + if (!ctx.node.isDir && ext + && !n.toLowerCase().endsWith('.' + ext.toLowerCase())) { + n = window.zddc.joinExtension(n, ext); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(n); + } + if (act.statusInfo) act.statusInfo('Copied: ' + n); + } + }, + + // ── treeops (folder/zip rows only) ── + { + id: 'expand-subtree', group: 'treeops', surfaces: ['row'], + label: 'Expand subtree', accel: 'Shift+Click', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.expandSubtree(ctx.node.id); + } + }, + { + id: 'collapse-subtree', group: 'treeops', surfaces: ['row'], + label: 'Collapse subtree', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.collapseSubtree(ctx.node.id); + } + }, + { + id: 'navigate-into', group: 'treeops', surfaces: ['row'], + label: 'Navigate into', accel: 'Dbl-click', + appliesTo: function (ctx) { return !!ctx.node.isDir; }, + action: function (ctx) { if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node); } + }, + + // ── workflow (already type+scope gated → omitted when N/A) ── + { + id: 'plan-review', group: 'workflow', surfaces: ['row'], + label: 'Plan Review…', + appliesTo: function (ctx) { + if (!isServer() || !state.scopeOnPlanReview) return false; + var pr = window.app.modules.planReview; + return !!(pr && pr.isReceivedTrackingFolder(ctx.node)); + }, + action: function (ctx) { + var pr = window.app.modules.planReview; + if (pr) pr.invoke(ctx.node); + } + }, + { + id: 'accept-transmittal', group: 'workflow', surfaces: ['row'], + label: 'Accept Transmittal…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var at = window.app.modules.acceptTransmittal; + return !!(at && at.isAcceptableTransmittalFolder(ctx.node)); + }, + action: function (ctx) { + var at = window.app.modules.acceptTransmittal; + if (at) at.invoke(ctx.node); + } + }, + { + id: 'stage', group: 'workflow', surfaces: ['row'], + label: 'Stage to…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isStageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeStage(ctx.node); + } + }, + { + id: 'unstage', group: 'workflow', surfaces: ['row'], + label: 'Unstage to working/', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isUnstageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeUnstage(ctx.node); + } + }, + { + id: 'history', group: 'workflow', surfaces: ['row'], + label: 'History…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var n = ctx.node; + return appliesToFile(n) && !n.virtual && !!n.history; + }, + action: function (ctx) { + var h = window.app.modules.history; + if (h) h.open(ctx.node); + } + }, + + // ── admin / sub-admin tier ── + { + id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], + label: 'Edit access rules…', + appliesTo: function (ctx) { + if (!isServer()) return false; // server-only tier + return ctx.surface === 'pane' || appliesToFolderLike(ctx.node); + }, + enabled: function (ctx) { return manageAccessGate(ctx).enabled; }, + tooltip: function (ctx) { return tierLabelForMissing(manageAccessGate(ctx).missing); }, + action: function (ctx) { openZddcEditor(ctx.dir); } + }, + + // ── view (pane) ── + { + id: 'refresh', group: 'view', surfaces: ['pane'], + label: 'Refresh', accel: 'F5', + action: function () { if (act.refreshListing) act.refreshListing(); } + } + ]; + + // Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree + // node (carries verbs/virtual flags) else synthesize one; the yaml plugin + // recognises name === '.zddc' and gates the save on the admin verb 'a'. + function openZddcEditor(dir) { + var url = (dir || '/'); + if (!url.endsWith('/')) url += '/'; + url += '.zddc'; + var found = null; + var t = window.app.modules.tree; + state.nodes.forEach(function (n) { + if (found || n.name !== '.zddc' || !t) return; + if (t.pathFor(n) === url) found = n; + }); + var node = found || { url: url, name: '.zddc', ext: '' }; + var p = window.app.modules.preview; + if (p) p.showFilePreview(node); + } + + // ── Projection ──────────────────────────────────────────────────────── + + function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; } + function resolveBool(v, ctx, dflt) { + if (v === undefined) return dflt; + return !!(typeof v === 'function' ? v(ctx) : v); + } + + function toMenuItem(d, ctx) { + return { + label: resolve(d.label, ctx), + accel: d.accel, + danger: d.danger, + // disabled / tooltip ignore the menu's own context arg — ctx is + // already captured here with the richer browse context. + disabled: function () { return !resolveBool(d.enabled, ctx, true); }, + tooltip: function () { + return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || ''); + }, + action: function () { if (d.action) d.action(ctx); } + }; + } + + function project(surface, ctx) { + var out = []; + var lastGroup = null; + for (var i = 0; i < DESCRIPTORS.length; i++) { + var d = DESCRIPTORS[i]; + if (d.surfaces.indexOf(surface) === -1) continue; + if (!resolveBool(d.appliesTo, ctx, true)) continue; + if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true }); + lastGroup = d.group; + out.push(toMenuItem(d, ctx)); + } + return out; // context-menu.js collapses leading/trailing/dup separators + } + + function buildRowItems(node, row, access) { + var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/'); + return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access }); + } + function buildPaneItems(access) { + var dir = state.currentPath || '/'; + return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access }); + } + + window.app.modules.menuModel = { + configure: configure, + buildRowItems: buildRowItems, + buildPaneItems: buildPaneItems, + DESCRIPTORS: DESCRIPTORS // exposed for tests + }; +})(); diff --git a/browse/js/tree.js b/browse/js/tree.js index 9f6fc85..b8e9fdc 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -392,6 +392,14 @@ + '' + iconChar + extChip + '' + labelHtml(node) + virtualHint + // Kebab (⋯) — visible affordance that the row has actions; opens + // the same context menu. Revealed on hover/selection/focus (CSS). + // tabindex -1 keeps it out of the tab order (roving tabindex on + // the rows); reachable via right-click / the keyboard menu key. + + '' + ''; } diff --git a/browse/template.html b/browse/template.html index bfb2ace..7114e52 100644 --- a/browse/template.html +++ b/browse/template.html @@ -73,6 +73,25 @@ aria-label="Filter the tree by name, tracking number, status, revision, or title" autocomplete="off" spellcheck="false"> +
+ + + + +
@@ -126,10 +145,16 @@
Recursive expand or collapse — the whole subtree.
Click a file
Preview it in the right pane.
-
Right-click any row
-
Opens a context menu with Open, Download, Copy path, Sort, and - folder-specific actions. Toggle items show a ✓ when active; submenus - open on hover.
+
Row actions — right-click, ⋯, or the menu key
+
Right-click a row, click the ⋯ button that appears on hover, or + press the menu key (or Shift+F10) on the selected row. The menu only + lists actions that apply to that item; actions you can see but can't + use yet (you lack write/create access, or they're for project or site + administrators) appear greyed with a reason — so you can see what a + higher role unlocks.
+
Toolbar (above the tree)
+
Filter, New folder / New file (created in the current directory), + Sort order, and Show hidden files all live here.
⤴ Pop out
Open the current preview in a separate window — useful for a second monitor.
diff --git a/shared/icons.js b/shared/icons.js index 82cef28..1007c55 100644 --- a/shared/icons.js +++ b/shared/icons.js @@ -127,6 +127,12 @@ // one path instead of two. + '' + '' + + '' + // Horizontal three-dot "kebab" — the per-row actions affordance. + + '' + + '' + + '' + + '' + ''; var injected = false; diff --git a/tests/browse.spec.js b/tests/browse.spec.js index 854bea1..0f79e53 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -207,3 +207,92 @@ test.describe('Browse', () => { ]); }); }); + +// ── Menu harmonization: context-correct, capability/tier-driven ────────── +test.describe('Browse menu — context & tiers', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(MOCK_FS_INIT_SCRIPT); + }); + + async function openWithTree(page) { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); + await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); + await page.evaluate(() => { + window.__setMockDirectoryTree('mock-folder', { + 'a.txt': 'AAA', + 'sub': { 'b.txt': 'BBB' }, + }); + }); + await page.locator('#addDirectoryBtn').click(); + await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 }); + } + + test('file row OMITS New folder / New file (context-correct)', async ({ page }) => { + await openWithTree(page); + const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) }); + await fileRow.click({ button: 'right' }); + await page.waitForSelector('.zddc-menu', { timeout: 5000 }); + await expect(page.locator('.zddc-menu__item', { hasText: 'New folder' })).toHaveCount(0); + await expect(page.locator('.zddc-menu__item', { hasText: 'New markdown file' })).toHaveCount(0); + }); + + test('folder row SHOWS New folder, enabled', async ({ page }) => { + await openWithTree(page); + const folderRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) }); + await folderRow.click({ button: 'right' }); + await page.waitForSelector('.zddc-menu', { timeout: 5000 }); + const item = page.locator('.zddc-menu__item', { hasText: 'New folder' }).first(); + await expect(item).toBeVisible(); + await expect(item).not.toHaveClass(/is-disabled/); + }); + + test('permission-gated item is shown disabled with a reason (server verbs)', async ({ page }) => { + await openWithTree(page); + // Pure-DOM unit over the declarative model: a read-only server node. + const res = await page.evaluate(() => { + window.app.state.source = 'server'; + const node = { name: 'doc.md', ext: 'md', isDir: false, isZip: false, + virtual: false, url: '/doc.md', verbs: 'r' }; + const items = window.app.modules.menuModel.buildRowItems(node, null, { path_verbs: 'r' }); + const labels = items.filter((i) => i.label).map((i) => i.label); + const rename = items.find((i) => i.label === 'Rename…'); + return { + renameDisabled: rename.disabled(), + renameTooltip: rename.tooltip(), + hasNewFolder: labels.indexOf('New folder') !== -1, + }; + }); + expect(res.renameDisabled).toBe(true); + expect(res.renameTooltip.toLowerCase()).toContain('write access'); + expect(res.hasNewFolder).toBe(false); // file → create omitted, not greyed + }); + + test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => { + await openWithTree(page); + await expect(page.locator('#newFolderBtn')).toBeVisible(); + await expect(page.locator('#newFileBtn')).toBeVisible(); + + await page.locator('#sortSelect').selectOption('date:-1'); + expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 }); + + await page.locator('#showHiddenChk').check(); + expect(await page.evaluate(() => window.app.state.showHidden)).toBe(true); + }); + + test('keyboard menu key and kebab both open the row menu', async ({ page }) => { + await openWithTree(page); + const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) }); + + // Kebab click opens the menu (no preview/toggle side-effect needed here). + await fileRow.click(); // select first + await fileRow.locator('.tree-row__kebab').click(); + await expect(page.locator('.zddc-menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('.zddc-menu')).toHaveCount(0); + + // Keyboard menu key (Shift+F10) opens it on the selected row. + await fileRow.click(); + await page.keyboard.press('Shift+F10'); + await expect(page.locator('.zddc-menu')).toBeVisible(); + }); +});