ZDDC/browse/js/menu-model.js
ZDDC e2179d167b 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>
2026-06-04 07:21:02 -05:00

449 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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