feat(browse): capability/role/tier-driven, context-correct menu system

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 07:21:02 -05:00
parent 8edbb81958
commit e2179d167b
10 changed files with 805 additions and 366 deletions

View file

@ -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" \

View file

@ -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. */

View file

@ -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;

View file

@ -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 dont 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,315 +1064,64 @@
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/<tracking>/ 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;
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();
refreshListing();
} }
];
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();
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()); }
});
}
},
{ 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();
} }
];
// 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.
//
@ -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
};
})();

View file

@ -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,

449
browse/js/menu-model.js Normal file
View file

@ -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 dont 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
};
})();

View file

@ -392,6 +392,14 @@
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ 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.
+ '<button type="button" class="tree-row__kebab" tabindex="-1"'
+ ' aria-label="Row actions">'
+ window.zddc.icons.html('icon-ellipsis')
+ '</button>'
+ '</div>';
}

View file

@ -73,6 +73,25 @@
aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off"
spellcheck="false">
<div class="tree-pane__controls">
<button type="button" id="newFolderBtn" class="btn btn-sm btn--subtle"
title="New folder in the current directory">New folder</button>
<button type="button" id="newFileBtn" class="btn btn-sm btn--subtle"
title="New markdown file in the current directory">New file</button>
<label class="tp-control" title="Sort order">
<span class="tp-control__label">Sort</span>
<select id="sortSelect" aria-label="Sort order">
<option value="name:1">Name</option>
<option value="date:-1">Modified</option>
<option value="size:-1">Size</option>
<option value="ext:1">Type</option>
</select>
</label>
<label class="tp-control tp-control--check" title="Show hidden files (dot/underscore names)">
<input type="checkbox" id="showHiddenChk">
<span class="tp-control__label">Hidden</span>
</label>
</div>
</div>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
@ -126,10 +145,16 @@
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Preview it in the right pane.</dd>
<dt>Right-click any row</dt>
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
folder-specific actions. Toggle items show a ✓ when active; submenus
open on hover.</dd>
<dt>Row actions — right-click, ⋯, or the menu key</dt>
<dd>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.</dd>
<dt>Toolbar (above the tree)</dt>
<dd>Filter, New folder / New file (created in the current directory),
Sort order, and Show hidden files all live here.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>

View file

@ -127,6 +127,12 @@
// one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>'
+ '</symbol>'
// Horizontal three-dot "kebab" — the per-row actions affordance.
+ '<symbol id="icon-ellipsis" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="1"/>'
+ '<circle cx="19" cy="12" r="1"/>'
+ '<circle cx="5" cy="12" r="1"/>'
+ '</symbol>';
var injected = false;

View file

@ -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();
});
});