469 lines
21 KiB
JavaScript
469 lines
21 KiB
JavaScript
// 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); }
|
|
|
|
// The Export submenu's convertible-format set comes from the download
|
|
// module's canonical matrix (download.exportTargets), which mirrors the
|
|
// server's conversion matrix — the single source of truth shared with the
|
|
// markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the
|
|
// target formats for a source extension (e.g. md → docx, html, pdf), or []
|
|
// when the extension isn't a convertible source.
|
|
function exportTargets(ext) {
|
|
var d = window.app.modules.download;
|
|
return (d && d.exportTargets) ? d.exportTargets(ext) : [];
|
|
}
|
|
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 convertible
|
|
// file (md/docx/html) offers its server-side conversion targets —
|
|
// md → docx/html/pdf, docx → md/html, html → md/docx (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() && exportTargets(n.ext).length > 0;
|
|
},
|
|
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); } }];
|
|
}
|
|
// exportTargets already excludes the source format.
|
|
return exportTargets(n.ext).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 ──
|
|
{
|
|
// Guided "who can do what here" dialog — the front door for access.
|
|
// HIDDEN unless the user can administer here (admin verb 'a', or
|
|
// subtree/site admin).
|
|
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
|
label: 'Manage access…',
|
|
appliesTo: function (ctx) {
|
|
if (!isServer()) return false; // server-only tier
|
|
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
|
return typeOk && manageAccessGate(ctx).enabled
|
|
&& !!(window.app.modules.manageAccess);
|
|
},
|
|
action: function (ctx) {
|
|
var m = window.app.modules.manageAccess;
|
|
if (m) m.open(ctx.dir);
|
|
}
|
|
},
|
|
{
|
|
// The raw-YAML escape hatch — same authority gate, demoted to
|
|
// "advanced" since the guided dialog covers the common case.
|
|
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
|
|
label: 'Edit raw policy (.zddc)…',
|
|
appliesTo: function (ctx) {
|
|
if (!isServer()) return false;
|
|
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
|
|
};
|
|
})();
|