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>
449 lines
20 KiB
JavaScript
449 lines
20 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); }
|
||
function cap() { return window.zddc && window.zddc.cap; }
|
||
|
||
function canVerb(node, verb) {
|
||
return !!(node && cap() && cap().has(node, verb));
|
||
}
|
||
function pathHasVerb(access, verb) {
|
||
return !!(access && typeof access.path_verbs === 'string'
|
||
&& access.path_verbs.indexOf(verb) !== -1);
|
||
}
|
||
function isSiteAdmin(access) { return !!(access && access.is_super_admin); }
|
||
function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); }
|
||
|
||
function tierLabelForMissing(missing) {
|
||
switch (missing) {
|
||
case 'w': return "You don't have write access to this item.";
|
||
case 'd': return "You don't have delete access to this item.";
|
||
case 'c': return 'You don’t have create access here.';
|
||
case 'subtree-admin': return 'Project administrators only.';
|
||
case 'site-admin': return 'Site administrators only.';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
// Rename/Delete gate — preserves today's compose exactly: canMutate rules
|
||
// out un-writable sources (offline FS without a handle, zip members,
|
||
// virtual placeholders) with no tooltip; when the server cascade reports
|
||
// verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs
|
||
// field) fall back to canMutate alone. Returns { enabled, missing }.
|
||
function verbGate(node, verb) {
|
||
var up = window.app.modules.upload;
|
||
if (!up || !up.canMutate(node)) return { enabled: false, missing: '' };
|
||
if (!isServer() || !cap()) return { enabled: true, missing: '' };
|
||
if (typeof node.verbs !== 'string') return { enabled: true, missing: '' };
|
||
if (cap().has(node, verb)) return { enabled: true, missing: '' };
|
||
return { enabled: false, missing: verb };
|
||
}
|
||
|
||
// Create gate (New folder / New file). canCreateHere() rules out the
|
||
// no-target case (offline FS without a picked handle) — no tooltip there.
|
||
// In server mode, gate on the 'c' verb: per-node for a folder row, per
|
||
// scope for the pane. Unknown verbs → optimistic (server is the final
|
||
// arbiter, surfacing 403 via cap.handleForbidden, exactly as today).
|
||
function createGate(ctx) {
|
||
if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' };
|
||
if (!isServer()) return { enabled: true, missing: '' };
|
||
if (ctx.node) { // folder-row create → inside this folder
|
||
if (typeof ctx.node.verbs === 'string') {
|
||
return canVerb(ctx.node, 'c')
|
||
? { enabled: true, missing: '' }
|
||
: { enabled: false, missing: 'c' };
|
||
}
|
||
return { enabled: true, missing: '' };
|
||
}
|
||
// pane create → current scope
|
||
if (ctx.access && typeof ctx.access.path_verbs === 'string') {
|
||
return pathHasVerb(ctx.access, 'c')
|
||
? { enabled: true, missing: '' }
|
||
: { enabled: false, missing: 'c' };
|
||
}
|
||
return { enabled: true, missing: '' };
|
||
}
|
||
|
||
// "Edit access rules" (.zddc) — the sub-admin / site-admin tier item.
|
||
// Enabled per-node when the entry grants the admin verb 'a', else by the
|
||
// scope's subtree-admin / site-admin status (admin authority cascades
|
||
// down a subtree). Returns { enabled, missing }.
|
||
function manageAccessGate(ctx) {
|
||
if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' };
|
||
if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' };
|
||
return { enabled: false, missing: 'subtree-admin' };
|
||
}
|
||
|
||
function insideZip(node) {
|
||
// Creating inside a zip member is impossible — the server can't PUT
|
||
// into an archive. Mirror tree.zipNestedInsideZip's URL heuristic.
|
||
if (!node) return false;
|
||
if (node.url && /\.zip\//i.test(node.url)) return true;
|
||
if (node.handle && node.handle.isZipEntry) return true;
|
||
return false;
|
||
}
|
||
|
||
// ── Descriptors ─────────────────────────────────────────────────────────
|
||
// group order = visual order; a separator is inserted on each group change
|
||
// among the items that actually render (context-menu.js collapses extras).
|
||
var DESCRIPTORS = [
|
||
// ── open ──
|
||
{
|
||
id: 'open', group: 'open', surfaces: ['row'],
|
||
label: function (ctx) {
|
||
if (ctx.node.isDir) return 'Open';
|
||
if (ctx.node.isZip) return 'Open archive';
|
||
return 'Preview';
|
||
},
|
||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||
action: function (ctx) {
|
||
if (ctx.node.isDir || ctx.node.isZip) {
|
||
var t = window.app.modules.tree;
|
||
if (t) t.toggleFolder(ctx.node.id);
|
||
} else {
|
||
var p = window.app.modules.preview;
|
||
if (p) p.showFilePreview(ctx.node);
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'open-new-tab', group: 'open', surfaces: ['row'],
|
||
label: 'Open in new tab', accel: 'Ctrl+Click',
|
||
appliesTo: function (ctx) { return !!ctx.node.url; },
|
||
action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); }
|
||
},
|
||
{
|
||
id: 'popout', group: 'open', surfaces: ['row'],
|
||
label: 'Pop out preview',
|
||
appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; },
|
||
action: function (ctx) {
|
||
var p = window.app.modules.preview;
|
||
if (p) p.showFilePreview(ctx.node, { popup: true });
|
||
}
|
||
},
|
||
|
||
// ── io ──
|
||
{
|
||
id: 'download', group: 'io', surfaces: ['row'],
|
||
label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; },
|
||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||
action: function (ctx) {
|
||
var d = window.app.modules.download;
|
||
if (!d) return;
|
||
if (ctx.node.isDir) d.downloadFolder(ctx.node);
|
||
else d.downloadFile(ctx.node);
|
||
}
|
||
},
|
||
|
||
// ── create (folder rows + pane; NOT file rows) ──
|
||
{
|
||
id: 'new-folder', group: 'create', surfaces: ['row', 'pane'],
|
||
label: 'New folder',
|
||
appliesTo: function (ctx) {
|
||
if (ctx.surface === 'pane') return true;
|
||
return appliesToFolderLike(ctx.node) && !insideZip(ctx.node);
|
||
},
|
||
enabled: function (ctx) { return createGate(ctx).enabled; },
|
||
tooltip: function (ctx) { return tierLabelForMissing(createGate(ctx).missing); },
|
||
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); }
|
||
},
|
||
{
|
||
id: 'new-md', group: 'create', surfaces: ['row', 'pane'],
|
||
label: 'New markdown file',
|
||
appliesTo: function (ctx) {
|
||
if (ctx.surface === 'pane') return true;
|
||
return appliesToFolderLike(ctx.node) && !insideZip(ctx.node);
|
||
},
|
||
enabled: function (ctx) { return createGate(ctx).enabled; },
|
||
tooltip: function (ctx) { return tierLabelForMissing(createGate(ctx).missing); },
|
||
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); }
|
||
},
|
||
{
|
||
id: 'create-transmittal', group: 'create', surfaces: ['pane'],
|
||
label: 'Create Transmittal folder…',
|
||
appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; },
|
||
action: function () {
|
||
var ct = window.app.modules.createTransmittal;
|
||
if (ct) ct.invoke();
|
||
}
|
||
},
|
||
|
||
// ── mutate (permission-gated; shown disabled + tooltip) ──
|
||
{
|
||
id: 'rename', group: 'mutate', surfaces: ['row'],
|
||
label: 'Rename…',
|
||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||
enabled: function (ctx) { return verbGate(ctx.node, 'w').enabled; },
|
||
tooltip: function (ctx) { return tierLabelForMissing(verbGate(ctx.node, 'w').missing); },
|
||
action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); }
|
||
},
|
||
{
|
||
id: 'delete', group: 'mutate', surfaces: ['row'], danger: true,
|
||
label: 'Delete…',
|
||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||
enabled: function (ctx) { return verbGate(ctx.node, 'd').enabled; },
|
||
tooltip: function (ctx) { return tierLabelForMissing(verbGate(ctx.node, 'd').missing); },
|
||
action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); }
|
||
},
|
||
|
||
// ── clipboard ──
|
||
{
|
||
id: 'copy-path', group: 'clipboard', surfaces: ['row'],
|
||
label: 'Copy path',
|
||
action: function (ctx) {
|
||
var t = window.app.modules.tree;
|
||
var path = t ? t.pathFor(ctx.node) : ctx.node.name;
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(path).then(
|
||
function () { if (act.statusInfo) act.statusInfo('Copied: ' + path); },
|
||
function () { if (act.statusError) act.statusError('Clipboard copy denied'); }
|
||
);
|
||
} else if (act.statusInfo) { act.statusInfo(path); }
|
||
}
|
||
},
|
||
{
|
||
id: 'copy-name', group: 'clipboard', surfaces: ['row'],
|
||
label: 'Copy name',
|
||
action: function (ctx) {
|
||
var n = ctx.node.name;
|
||
var ext = ctx.node.ext;
|
||
if (!ctx.node.isDir && ext
|
||
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
|
||
n = window.zddc.joinExtension(n, ext);
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(n);
|
||
}
|
||
if (act.statusInfo) act.statusInfo('Copied: ' + n);
|
||
}
|
||
},
|
||
|
||
// ── treeops (folder/zip rows only) ──
|
||
{
|
||
id: 'expand-subtree', group: 'treeops', surfaces: ['row'],
|
||
label: 'Expand subtree', accel: 'Shift+Click',
|
||
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
|
||
action: function (ctx) {
|
||
var t = window.app.modules.tree;
|
||
if (t) t.expandSubtree(ctx.node.id);
|
||
}
|
||
},
|
||
{
|
||
id: 'collapse-subtree', group: 'treeops', surfaces: ['row'],
|
||
label: 'Collapse subtree',
|
||
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
|
||
action: function (ctx) {
|
||
var t = window.app.modules.tree;
|
||
if (t) t.collapseSubtree(ctx.node.id);
|
||
}
|
||
},
|
||
{
|
||
id: 'navigate-into', group: 'treeops', surfaces: ['row'],
|
||
label: 'Navigate into', accel: 'Dbl-click',
|
||
appliesTo: function (ctx) { return !!ctx.node.isDir; },
|
||
action: function (ctx) { if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node); }
|
||
},
|
||
|
||
// ── workflow (already type+scope gated → omitted when N/A) ──
|
||
{
|
||
id: 'plan-review', group: 'workflow', surfaces: ['row'],
|
||
label: 'Plan Review…',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer() || !state.scopeOnPlanReview) return false;
|
||
var pr = window.app.modules.planReview;
|
||
return !!(pr && pr.isReceivedTrackingFolder(ctx.node));
|
||
},
|
||
action: function (ctx) {
|
||
var pr = window.app.modules.planReview;
|
||
if (pr) pr.invoke(ctx.node);
|
||
}
|
||
},
|
||
{
|
||
id: 'accept-transmittal', group: 'workflow', surfaces: ['row'],
|
||
label: 'Accept Transmittal…',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer()) return false;
|
||
var at = window.app.modules.acceptTransmittal;
|
||
return !!(at && at.isAcceptableTransmittalFolder(ctx.node));
|
||
},
|
||
action: function (ctx) {
|
||
var at = window.app.modules.acceptTransmittal;
|
||
if (at) at.invoke(ctx.node);
|
||
}
|
||
},
|
||
{
|
||
id: 'stage', group: 'workflow', surfaces: ['row'],
|
||
label: 'Stage to…',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer()) return false;
|
||
var s = window.app.modules.stage;
|
||
return !!(s && s.isStageableFile(ctx.node));
|
||
},
|
||
action: function (ctx) {
|
||
var s = window.app.modules.stage;
|
||
if (s) s.invokeStage(ctx.node);
|
||
}
|
||
},
|
||
{
|
||
id: 'unstage', group: 'workflow', surfaces: ['row'],
|
||
label: 'Unstage to working/',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer()) return false;
|
||
var s = window.app.modules.stage;
|
||
return !!(s && s.isUnstageableFile(ctx.node));
|
||
},
|
||
action: function (ctx) {
|
||
var s = window.app.modules.stage;
|
||
if (s) s.invokeUnstage(ctx.node);
|
||
}
|
||
},
|
||
{
|
||
id: 'history', group: 'workflow', surfaces: ['row'],
|
||
label: 'History…',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer()) return false;
|
||
var n = ctx.node;
|
||
return appliesToFile(n) && !n.virtual && !!n.history;
|
||
},
|
||
action: function (ctx) {
|
||
var h = window.app.modules.history;
|
||
if (h) h.open(ctx.node);
|
||
}
|
||
},
|
||
|
||
// ── admin / sub-admin tier ──
|
||
{
|
||
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
||
label: 'Edit access rules…',
|
||
appliesTo: function (ctx) {
|
||
if (!isServer()) return false; // server-only tier
|
||
return ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||
},
|
||
enabled: function (ctx) { return manageAccessGate(ctx).enabled; },
|
||
tooltip: function (ctx) { return tierLabelForMissing(manageAccessGate(ctx).missing); },
|
||
action: function (ctx) { openZddcEditor(ctx.dir); }
|
||
},
|
||
|
||
// ── view (pane) ──
|
||
{
|
||
id: 'refresh', group: 'view', surfaces: ['pane'],
|
||
label: 'Refresh', accel: 'F5',
|
||
action: function () { if (act.refreshListing) act.refreshListing(); }
|
||
}
|
||
];
|
||
|
||
// Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree
|
||
// node (carries verbs/virtual flags) else synthesize one; the yaml plugin
|
||
// recognises name === '.zddc' and gates the save on the admin verb 'a'.
|
||
function openZddcEditor(dir) {
|
||
var url = (dir || '/');
|
||
if (!url.endsWith('/')) url += '/';
|
||
url += '.zddc';
|
||
var found = null;
|
||
var t = window.app.modules.tree;
|
||
state.nodes.forEach(function (n) {
|
||
if (found || n.name !== '.zddc' || !t) return;
|
||
if (t.pathFor(n) === url) found = n;
|
||
});
|
||
var node = found || { url: url, name: '.zddc', ext: '' };
|
||
var p = window.app.modules.preview;
|
||
if (p) p.showFilePreview(node);
|
||
}
|
||
|
||
// ── Projection ────────────────────────────────────────────────────────
|
||
|
||
function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; }
|
||
function resolveBool(v, ctx, dflt) {
|
||
if (v === undefined) return dflt;
|
||
return !!(typeof v === 'function' ? v(ctx) : v);
|
||
}
|
||
|
||
function toMenuItem(d, ctx) {
|
||
return {
|
||
label: resolve(d.label, ctx),
|
||
accel: d.accel,
|
||
danger: d.danger,
|
||
// disabled / tooltip ignore the menu's own context arg — ctx is
|
||
// already captured here with the richer browse context.
|
||
disabled: function () { return !resolveBool(d.enabled, ctx, true); },
|
||
tooltip: function () {
|
||
return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || '');
|
||
},
|
||
action: function () { if (d.action) d.action(ctx); }
|
||
};
|
||
}
|
||
|
||
function project(surface, ctx) {
|
||
var out = [];
|
||
var lastGroup = null;
|
||
for (var i = 0; i < DESCRIPTORS.length; i++) {
|
||
var d = DESCRIPTORS[i];
|
||
if (d.surfaces.indexOf(surface) === -1) continue;
|
||
if (!resolveBool(d.appliesTo, ctx, true)) continue;
|
||
if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true });
|
||
lastGroup = d.group;
|
||
out.push(toMenuItem(d, ctx));
|
||
}
|
||
return out; // context-menu.js collapses leading/trailing/dup separators
|
||
}
|
||
|
||
function buildRowItems(node, row, access) {
|
||
var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/');
|
||
return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access });
|
||
}
|
||
function buildPaneItems(access) {
|
||
var dir = state.currentPath || '/';
|
||
return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access });
|
||
}
|
||
|
||
window.app.modules.menuModel = {
|
||
configure: configure,
|
||
buildRowItems: buildRowItems,
|
||
buildPaneItems: buildPaneItems,
|
||
DESCRIPTORS: DESCRIPTORS // exposed for tests
|
||
};
|
||
})();
|