refine(browse): leaner menu — fold Navigate-into into Open, hide unpermitted actions, URL link in info box

Menu refinements per review:
- "Open" now navigates into a folder (rescope); the separate "Navigate into"
  item is removed. Zip → expand inline (can't navigate in); file → preview.
  Inline expand stays on single-click / chevron / arrow keys.
- "New markdown file" → "New file".
- New folder / New file / Rename / Delete are now HIDDEN when the user lacks
  the create/write/delete capability (folded into appliesTo) instead of shown
  greyed — a guest gets a lean menu; users who can still see them. New
  folder/file also remain on the toolbar.
- "Edit access rules…" is shown only when the user can actually edit them
  (admin verb 'a' or subtree/site admin) — hidden otherwise, not greyed.
- Removed "Copy path" / "Copy name" — the info box (hovercard) carries the
  name and a clickable URL now.

Info box (hovercard): dropped the on-disk "Path" row; the "URL" is rendered as
a clickable hyperlink (via the existing kvLink helper) — the shareable
reference, openable or right-click-to-copy.

Tests updated: file row omits New folder/file + Copy + Navigate; permission-
gated Rename/Delete are HIDDEN for a read-only server node and PRESENT for a
read/write/delete node (pure menuModel unit). All browse+conflict+diff green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 07:59:21 -05:00
parent e2179d167b
commit 198d691518
3 changed files with 52 additions and 90 deletions

View file

@ -165,10 +165,10 @@
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
}
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
if (node.url && node.url !== path) html += kv('URL', node.url, true);
// URL last (longest, most likely to wrap) — rendered as a clickable
// link the user can open or right-click to copy. The on-disk path is
// intentionally omitted; the URL is the shareable reference.
if (node.url) html += kvLink('URL', node.url, node.url);
return html;
}

View file

@ -58,16 +58,9 @@
function isSiteAdmin(access) { return !!(access && access.is_super_admin); }
function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); }
function tierLabelForMissing(missing) {
switch (missing) {
case 'w': return "You don't have write access to this item.";
case 'd': return "You don't have delete access to this item.";
case 'c': return 'You dont have create access here.';
case 'subtree-admin': return 'Project administrators only.';
case 'site-admin': return 'Site administrators only.';
default: return '';
}
}
// 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,
@ -141,7 +134,12 @@
},
appliesTo: function (ctx) { return !ctx.node.virtual; },
action: function (ctx) {
if (ctx.node.isDir || ctx.node.isZip) {
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 {
@ -180,26 +178,27 @@
},
// ── 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) {
if (ctx.surface === 'pane') return true;
return appliesToFolderLike(ctx.node) && !insideZip(ctx.node);
var typeOk = ctx.surface === 'pane'
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
return typeOk && createGate(ctx).enabled;
},
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',
id: 'new-file', group: 'create', surfaces: ['row', 'pane'],
label: 'New file',
appliesTo: function (ctx) {
if (ctx.surface === 'pane') return true;
return appliesToFolderLike(ctx.node) && !insideZip(ctx.node);
var typeOk = ctx.surface === 'pane'
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
return typeOk && createGate(ctx).enabled;
},
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'); }
},
{
@ -212,56 +211,20 @@
}
},
// ── mutate (permission-gated; shown disabled + tooltip) ──
// ── mutate (HIDDEN unless permitted — capability folded into appliesTo) ──
{
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); },
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; },
enabled: function (ctx) { return verbGate(ctx.node, 'd').enabled; },
tooltip: function (ctx) { return tierLabelForMissing(verbGate(ctx.node, 'd').missing); },
appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'd').enabled; },
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'],
@ -281,12 +244,6 @@
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) ──
{
@ -357,14 +314,15 @@
// ── admin / sub-admin tier ──
{
// HIDDEN unless the user can actually edit access rules here
// (admin verb 'a', or subtree/site admin) — not shown greyed.
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
label: 'Edit access rules…',
appliesTo: function (ctx) {
if (!isServer()) return false; // server-only tier
return ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled;
},
enabled: function (ctx) { return manageAccessGate(ctx).enabled; },
tooltip: function (ctx) { return tierLabelForMissing(manageAccessGate(ctx).missing); },
action: function (ctx) { openZddcEditor(ctx.dir); }
},

View file

@ -233,10 +233,13 @@ test.describe('Browse menu — context & tiers', () => {
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);
await expect(page.locator('.zddc-menu__item', { hasText: 'New file' })).toHaveCount(0);
// Copy path/name removed; Navigate into folded into Open.
await expect(page.locator('.zddc-menu__item', { hasText: 'Copy path' })).toHaveCount(0);
await expect(page.locator('.zddc-menu__item', { hasText: 'Navigate into' })).toHaveCount(0);
});
test('folder row SHOWS New folder, enabled', async ({ page }) => {
test('folder row SHOWS New folder (FS mode → create permitted), 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' });
@ -246,25 +249,26 @@ test.describe('Browse menu — context & tiers', () => {
await expect(item).not.toHaveClass(/is-disabled/);
});
test('permission-gated item is shown disabled with a reason (server verbs)', async ({ page }) => {
test('permission-gated items are HIDDEN when not permitted, shown when permitted', async ({ page }) => {
await openWithTree(page);
// Pure-DOM unit over the declarative model: a read-only server node.
// Pure-DOM unit over the declarative model in server mode.
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,
};
function labels(verbs) {
const node = { name: 'doc.md', ext: 'md', isDir: false, isZip: false,
virtual: false, url: '/doc.md', verbs: verbs };
return window.app.modules.menuModel
.buildRowItems(node, null, { path_verbs: verbs })
.filter((i) => i.label).map((i) => i.label);
}
return { ro: labels('r'), rwd: labels('rwd') };
});
expect(res.renameDisabled).toBe(true);
expect(res.renameTooltip.toLowerCase()).toContain('write access');
expect(res.hasNewFolder).toBe(false); // file → create omitted, not greyed
// read-only → no Rename/Delete (hidden, not greyed)
expect(res.ro).not.toContain('Rename…');
expect(res.ro).not.toContain('Delete…');
// read+write+delete → both present
expect(res.rwd).toContain('Rename…');
expect(res.rwd).toContain('Delete…');
});
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {