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:
parent
8edbb81958
commit
e2179d167b
10 changed files with 805 additions and 366 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 don’t 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,316 +1064,65 @@
|
|||
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;
|
||||
syncURLToSelection();
|
||||
refreshListing();
|
||||
} }
|
||||
];
|
||||
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();
|
||||
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();
|
||||
}
|
||||
},
|
||||
{ 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();
|
||||
} }
|
||||
];
|
||||
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()); }
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// ?view=grid → grid mode (only honored where classifier is
|
||||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
449
browse/js/menu-model.js
Normal 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 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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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>';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue