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:
parent
e2179d167b
commit
198d691518
3 changed files with 52 additions and 90 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue