diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte
index df705c7e..c190eb0b 100644
--- a/frontend/src/lib/components/ResourceList.svelte
+++ b/frontend/src/lib/components/ResourceList.svelte
@@ -53,13 +53,22 @@
/**
* Optional per-item visibility gate. Called at menu-open time
* with the target item + context; return `false` to hide the
- * entry for that row. Synchronous by contract — pages that need
- * an async check (e.g. "does the caller have Read on the parent
- * folder?") should pre-warm a cache when items load so the
- * answer is already resolved by the time this runs. See
- * `$lib/utils/folderAccess.ts` for the reference pattern.
+ * entry entirely for that row (e.g. `open_parent` on a drive-
+ * root folder that has no parent to open). Prefer `disabled?`
+ * over hiding when the action *could* apply but the caller
+ * lacks the required permission — a greyed entry answers
+ * "this option exists" for the user instead of leaving a hole
+ * that reads as a forgotten feature.
*/
visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
+ /**
+ * Optional per-item disabled gate. Called at menu-open time;
+ * `true` renders the entry non-interactive (dimmed, no click).
+ * Kept sync by the same contract as `visible?` — use the
+ * `menuPrepare` prop to prime any cache the predicate depends
+ * on before the menu renders.
+ */
+ disabled?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
run: (item: FileItem | FolderItem, ctx?: ItemContext) => void;
}
@@ -1352,12 +1361,17 @@
data-testid="resource-list-context-menu"
>
{#each visibleActions as action (action.key)}
+ {@const dis = action.disabled?.(ctxItem!, ctxOf(ctxItem!.id)) === true}
{
+ if (dis) return;
const target = ctxItem!;
closeContext();
action.run(target, ctxOf(target.id));
@@ -1542,10 +1556,24 @@
cursor: pointer;
}
- .rl-ctx-item:hover {
+ .rl-ctx-item:hover:not(:disabled) {
background: var(--color-bg-hover);
}
+ /* Disabled entry — dimmed but STILL RENDERED so the user sees that
+ the option exists and infers "I can't do this here" rather than
+ assuming a forgotten feature. No `cursor: not-allowed` badge on
+ hover (deliberate — a forbidden-sign cursor reads as alarming for
+ an entry the user didn't try to activate). The `disabled`
+ attribute alone still blocks click + keyboard activation and
+ flags the element to assistive tech via `aria-disabled`. */
+ .rl-ctx-item--disabled {
+ opacity: 0.5;
+ }
+ .rl-ctx-item--disabled:hover {
+ background: transparent;
+ }
+
.rl-ctx-item--danger {
/* Danger *foreground* on the light menu surface — the red accent, not
--color-danger-text (white, for text ON a red fill, invisible here). */
diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte
index 225c6b20..d51d78f6 100644
--- a/frontend/src/routes/favorites/+page.svelte
+++ b/frontend/src/routes/favorites/+page.svelte
@@ -236,13 +236,20 @@
key: 'open_parent',
label: t('files.open_parent', 'Open parent folder'),
icon: 'folder-open',
- // Sync gate on the pre-warmed folder-access cache (see
- // `warmFolderAccess` in `load()` below). `undefined` = not
- // yet probed → hide; the entry appears once the probe
- // resolves to `true`.
- visible: (item) => {
+ // Hidden only when there's literally no parent to open
+ // (drive-root folders where `parent_id === null`); otherwise
+ // the entry is always visible and shows up disabled when the
+ // caller lacks read on the parent — a greyed row reads as
+ // "you can't do this here" instead of "the option is missing."
+ // `folderAccessCached` returns `true`/`false`/`undefined`;
+ // disabled fires when the answer is explicitly `false`. On
+ // first right-click of a fresh row, `menuPrepare` below has
+ // primed the cache so the entry either enables or disables
+ // without a "flash of enabled" beforehand.
+ visible: (item) => parentFolderId(item) !== null,
+ disabled: (item) => {
const pid = parentFolderId(item);
- return pid !== null && folderAccessCached(pid) === true;
+ return pid === null || folderAccessCached(pid) === false;
},
run: (item) => {
const pid = parentFolderId(item);
@@ -274,6 +281,17 @@
moveOpen = true;
}
},
+ {
+ // Every row on /favorites IS a favorite, so the entry is always
+ // "Remove favorite" — no per-item state lookup needed. Mirrors
+ // the star-widget behaviour: click, row un-stars, disappears
+ // from the list on next reload. Placed between Move and Rename
+ // to match the canonical context-menu order on `/files`.
+ key: 'unfavorite',
+ label: t('files.unfavorite', 'Remove favorite'),
+ icon: 'star-outline',
+ run: (item) => void unfavorite(item)
+ },
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte
index e344d395..e9ebd219 100644
--- a/frontend/src/routes/files/[...path]/+page.svelte
+++ b/frontend/src/routes/files/[...path]/+page.svelte
@@ -1920,7 +1920,7 @@
const tg = ctxTarget!;
closeContext();
openShare(tg.kind, tg.id, tg.name);
- }}> {t('files.share', 'Share')} {t('files.share', 'Share')}
{
+ // Same disabled-not-hidden pattern as /favorites: hide only
+ // when there's no parent (drive-root folder), otherwise
+ // show and disable when the caller lacks Read on the
+ // parent. `menuPrepare` primes the cache before the menu
+ // renders so the final enabled/disabled state is correct
+ // on the very first right-click of a row.
+ visible: (item) => parentFolderId(item) !== null,
+ disabled: (item) => {
const pid = parentFolderId(item);
- return pid !== null && folderAccessCached(pid) === true;
+ return pid === null || folderAccessCached(pid) === false;
},
run: (item) => {
const pid = parentFolderId(item);
@@ -307,6 +310,21 @@
moveOpen = true;
}
},
+ {
+ // "Add to favorites" — /recent doesn't track per-row favorite
+ // state (the star widget was replaced by the broom), so the
+ // entry always reads "Add" and the backend swallows duplicate
+ // adds idempotently. If the user wants to un-favorite, they
+ // navigate to /favorites and use the row menu there. Placed
+ // between Move and Rename to match the canonical context-menu
+ // order on `/files`.
+ key: 'favorite',
+ label: t('files.favorite', 'Add favorite'),
+ icon: 'star',
+ run: (item) => {
+ void addFavorite(kindOf(item), item.id).catch(errorToast);
+ }
+ },
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
diff --git a/frontend/src/routes/shared-with-me/+page.svelte b/frontend/src/routes/shared-with-me/+page.svelte
index 55589ce8..7e57274b 100644
--- a/frontend/src/routes/shared-with-me/+page.svelte
+++ b/frontend/src/routes/shared-with-me/+page.svelte
@@ -1,17 +1,25 @@
@@ -161,6 +222,7 @@
{items}
{contextMap}
resolveOwnerName={(id) => sharers.name(id)}
+ {contextActions}
{loading}
{error}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}