diff --git a/browse/js/hovercard.js b/browse/js/hovercard.js index be0f87a..b1c6220 100644 --- a/browse/js/hovercard.js +++ b/browse/js/hovercard.js @@ -165,10 +165,10 @@ + ''; } - // 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; } diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js index 9a2570c..56bb50a 100644 --- a/browse/js/menu-model.js +++ b/browse/js/menu-model.js @@ -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 don’t 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); } }, diff --git a/tests/browse.spec.js b/tests/browse.spec.js index 0f79e53..fd80da3 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -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 }) => {