diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte
index cea00a9a..1b38da25 100644
--- a/frontend/src/lib/components/ResourceList.svelte
+++ b/frontend/src/lib/components/ResourceList.svelte
@@ -50,6 +50,16 @@
label: string;
icon: string;
danger?: boolean;
+ /**
+ * 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.
+ */
+ visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean;
run: (item: FileItem | FolderItem, ctx?: ItemContext) => void;
}
@@ -1261,6 +1271,9 @@
{/snippet}
{#if ctxOpen && ctxItem && contextActions}
+ {@const visibleActions = contextActions.filter(
+ (a) => a.visible?.(ctxItem!, ctxOf(ctxItem!.id)) !== false
+ )}
- {#each contextActions as action (action.key)}
+ {#each visibleActions as action (action.key)}
();
+
+// In-flight dedup — if two callers ask about the same id before the
+// first request settles, they share the same Promise. Cleared once the
+// promise resolves.
+const inflight = new Map>();
+
+function bump(id: string, value: boolean): void {
+ cache.delete(id);
+ cache.set(id, value);
+ // Trim from the front (oldest insertion) until we're back under cap.
+ while (cache.size > MAX_ENTRIES) {
+ const oldest = cache.keys().next().value;
+ if (oldest === undefined) break;
+ cache.delete(oldest);
+ }
+}
+
+/**
+ * Sync lookup — `undefined` means "not yet probed"; callers gating UI
+ * on this should call `warmFolderAccess` when items load so the
+ * `true` / `false` answer is present by the time the user reaches for
+ * the context menu.
+ */
+export function folderAccessCached(id: string): boolean | undefined {
+ return cache.get(id);
+}
+
+/**
+ * Async probe. Fires a `GET /api/folders/{id}` (deduplicated against
+ * concurrent callers) and caches the boolean outcome. Never throws —
+ * 404 and network failures both resolve to `false`.
+ */
+export async function probeFolderAccess(id: string): Promise {
+ const cached = cache.get(id);
+ if (cached !== undefined) return cached;
+ const running = inflight.get(id);
+ if (running) return running;
+ const p = (async () => {
+ try {
+ await getFolder(id);
+ bump(id, true);
+ return true;
+ } catch {
+ bump(id, false);
+ return false;
+ } finally {
+ inflight.delete(id);
+ }
+ })();
+ inflight.set(id, p);
+ return p;
+}
+
+/**
+ * Bulk pre-warm. Deduplicates the input and skips ids already in the
+ * cache or in flight, then fires background probes for the rest. Does
+ * not await — the promises populate the cache asynchronously.
+ *
+ * Used by list surfaces (/recent, /favorites, /shared-with-me) that
+ * want to gate a per-row "Open parent folder" affordance on whether
+ * the caller can actually navigate there. Calling this on every
+ * `load()` (initial + infinite-scroll page) is cheap: probes for
+ * already-known ids no-op.
+ */
+export function warmFolderAccess(ids: Iterable): void {
+ const seen = new Set();
+ for (const id of ids) {
+ if (!id || seen.has(id) || cache.has(id) || inflight.has(id)) continue;
+ seen.add(id);
+ void probeFolderAccess(id);
+ }
+}
diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte
index 9daa62f0..5a36f8cf 100644
--- a/frontend/src/routes/favorites/+page.svelte
+++ b/frontend/src/routes/favorites/+page.svelte
@@ -28,6 +28,7 @@
type ItemContext
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
+ import { folderAccessCached, warmFolderAccess } from '$lib/utils/folderAccess';
import { t } from '$lib/i18n/index.svelte';
let raw = $state([]);
@@ -123,6 +124,16 @@
]);
cursor = page.next_cursor;
void owners.resolve(page.items.map((i) => i.resource.created_by));
+ // Pre-warm the folder-access cache for each row's parent
+ // folder — the "Open parent folder" context-menu entry
+ // gates on the cached boolean. Fire-and-forget: probes for
+ // already-cached ids no-op.
+ warmFolderAccess(
+ page.items.map((i) => {
+ const r = i.resource as FileItem | FolderItem;
+ return isFile(r) ? r.folder_id : r.parent_id;
+ })
+ );
} catch (e) {
console.error('favorites: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
@@ -223,7 +234,31 @@
a.remove();
}
+ // See /recent's mirror for the rationale: files carry `folder_id`,
+ // folders carry `parent_id`; nullable when the folder is a drive
+ // root. Null → no meaningful parent to open.
+ function parentFolderId(item: FileItem | FolderItem): string | null {
+ return isFile(item) ? item.folder_id : item.parent_id;
+ }
+
const contextActions: ContextAction[] = [
+ {
+ 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) => {
+ const pid = parentFolderId(item);
+ return pid !== null && folderAccessCached(pid) === true;
+ },
+ run: (item) => {
+ const pid = parentFolderId(item);
+ if (pid) goto(resolve(`/files/${pid}`));
+ }
+ },
{
key: 'download',
label: t('common.download', 'Download'),
diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte
index 961e8f8b..be4f3762 100644
--- a/frontend/src/routes/recent/+page.svelte
+++ b/frontend/src/routes/recent/+page.svelte
@@ -35,6 +35,7 @@
// filter is inside ResourceList (gated on `showDotfileToggle`).
import { preferences } from '$lib/stores/preferences.svelte';
import { isDotfile } from '$lib/utils/dotfileFilter';
+ import { folderAccessCached, warmFolderAccess } from '$lib/utils/folderAccess';
import { t } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
@@ -119,6 +120,16 @@
]);
cursor = page.next_cursor;
void owners.resolve(page.items.map((i) => i.resource.updated_by));
+ // Pre-warm the folder-access cache for each row's parent
+ // folder so the "Open parent folder" context-menu entry has
+ // a resolved boolean by the time the user right-clicks. Fire-
+ // and-forget: probes for already-cached ids no-op.
+ warmFolderAccess(
+ page.items.map((i) => {
+ const r = i.resource as FileItem | FolderItem;
+ return isFile(r) ? r.folder_id : r.parent_id;
+ })
+ );
} catch (e) {
console.error('recent: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
@@ -254,7 +265,33 @@
a.remove();
}
+ // Extract the parent-folder id from any item — files carry `folder_id`
+ // (required by the DTO), folders carry `parent_id` (nullable when the
+ // folder is a drive root). `null` means "no meaningful parent to open";
+ // the "Open parent folder" entry stays hidden in that case.
+ function parentFolderId(item: FileItem | FolderItem): string | null {
+ return isFile(item) ? item.folder_id : item.parent_id;
+ }
+
const contextActions: ContextAction[] = [
+ {
+ key: 'open_parent',
+ label: t('files.open_parent', 'Open parent folder'),
+ icon: 'folder-open',
+ // Sync gate: relies on the `warmFolderAccess` call in `load()`
+ // having populated the cache with a boolean answer for each
+ // visible row's parent id by the time the user right-clicks.
+ // `undefined` (not yet probed) is treated as "hide" — the
+ // entry appears once the probe resolves to `true`.
+ visible: (item) => {
+ const pid = parentFolderId(item);
+ return pid !== null && folderAccessCached(pid) === true;
+ },
+ run: (item) => {
+ const pid = parentFolderId(item);
+ if (pid) goto(resolve(`/files/${pid}`));
+ }
+ },
{
key: 'download',
label: t('common.download', 'Download'),