// 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); } // Formats the Export submenu offers for a file (server-side conversion): // a file of one of these extensions can be exported as the other two. var EXPORT_FORMATS = ['md', 'docx', 'html']; 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); } // Create / mutate / admin actions are HIDDEN when the user can't perform // them (capability folded into appliesTo), so these gates only need the // boolean — the `missing` field is retained for potential future tooltips. // 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) { // Open = navigate into the folder (rescope). Inline // expand stays on single-click / chevron / arrow keys. if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node); } else if (ctx.node.isZip) { // A zip can't be navigated into — expand it inline. 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); } }, { // Export submenu: a folder offers ".zip" (both modes); a md/docx/html // file offers the OTHER two formats (server-side conversion, so // server mode only). A zip is already an archive — no Export. id: 'export', group: 'io', surfaces: ['row'], label: 'Export', appliesTo: function (ctx) { var n = ctx.node; if (!n || n.virtual) return false; if (n.isDir) return true; if (n.isZip) return false; return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1; }, items: function (ctx) { var n = ctx.node; var d = window.app.modules.download; if (!d) return []; if (n.isDir) { return [{ label: '.zip', action: function () { d.downloadFolder(n); } }]; } var cur = (n.ext || '').toLowerCase(); return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) { return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; }); } }, // ── create (folder rows + pane; NOT file rows) ── // Create actions are HIDDEN unless the user can create here (the // capability is folded into appliesTo, not greyed). On a row they // apply to folders only (create inside); on the pane, to the scope. { id: 'new-folder', group: 'create', surfaces: ['row', 'pane'], label: 'New folder', appliesTo: function (ctx) { var typeOk = ctx.surface === 'pane' || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); return typeOk && createGate(ctx).enabled; }, action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); } }, { id: 'new-file', group: 'create', surfaces: ['row', 'pane'], label: 'New file', appliesTo: function (ctx) { var typeOk = ctx.surface === 'pane' || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); return typeOk && createGate(ctx).enabled; }, 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 (HIDDEN unless permitted — capability folded into appliesTo) ── { id: 'rename', group: 'mutate', surfaces: ['row'], label: 'Rename…', appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'w').enabled; }, 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 && verbGate(ctx.node, 'd').enabled; }, action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); } }, // ── 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); } }, // ── 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 ── { // HIDDEN unless the user can actually edit access rules here // (admin verb 'a', or subtree/site admin) — not shown greyed. id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], label: 'Edit access rules…', appliesTo: function (ctx) { if (!isServer()) return false; // server-only tier var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); return typeOk && manageAccessGate(ctx).enabled; }, 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) { var item = { 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) || ''); } }; // A descriptor with `items` becomes a submenu (resolved against the // captured browse ctx); otherwise it's a normal action row. if (d.items) { item.items = function () { return resolve(d.items, ctx); }; } else { item.action = function () { if (d.action) d.action(ctx); }; } return item; } 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 }; })();