diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 157fa480..df705c7e 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -206,6 +206,21 @@ * `contextActions` are provided, `oncontextmenu` wins. */ oncontextmenu?: (e: MouseEvent, item: FileItem | FolderItem) => void; + /** + * Optional async pre-open hook. When provided, ResourceList + * awaits it before the built-in context menu appears — so a + * page can lazily prime any per-item cache the menu's + * `visible?` predicates depend on WITHOUT the page having to + * pre-warm every row at load time (which would fire N HTTP + * calls for a feature the user may never invoke). + * + * Reference use: `/recent` / `/favorites` probe folder-access + * for the row's parent inside `menuPrepare` so the "Open parent + * folder" entry shows up on the first right-click of a + * previously-unseen row. Short-typically-cached call; typical + * menu-open latency stays well under a UI frame. + */ + menuPrepare?: (item: FileItem | FolderItem, ctx?: ItemContext) => Promise; /** * Per-item action cell (renders at the end of a row). Kept as a * distinct slot from the action-bar snippets below so callers @@ -350,6 +365,7 @@ onfavorite, onselectionchange, oncontextmenu: onContextMenuOverride, + menuPrepare, itemActions, actions, batchActions, @@ -787,13 +803,30 @@ let ctxY = $state(0); let ctxItem = $state(null); - function openContext(e: MouseEvent, item: FileItem | FolderItem) { + async function openContext(e: MouseEvent, item: FileItem | FolderItem) { if (!contextActions?.length) return; e.preventDefault(); e.stopPropagation(); + // Snapshot the pointer coords now — after an `await menuPrepare` + // tick the event object may be reused / stale, and reading + // `e.clientX` post-await could pin the menu to the wrong spot. + const x = Math.min(e.clientX, window.innerWidth - 220); + const y = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24)); + // Give the page a chance to prime any per-item cache the + // `visible?` predicates read (e.g. folder-access on /recent + + // /favorites for the "Open parent folder" entry). Awaited so the + // menu opens with the final visibility state — avoids a + // flash-of-hidden-then-shown when the probe resolves. + if (menuPrepare) { + try { + await menuPrepare(item, ctxOf(item.id)); + } catch { + /* prepare failures degrade to the sync-only visibility */ + } + } ctxItem = item; - ctxX = Math.min(e.clientX, window.innerWidth - 220); - ctxY = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24)); + ctxX = x; + ctxY = y; ctxOpen = true; } function closeContext() { @@ -930,7 +963,7 @@ oncontextmenu={onContextMenuOverride ? (e) => onContextMenuOverride(e, item) : contextActions?.length - ? (e) => openContext(e, item) + ? (e) => void openContext(e, item) : undefined} > {#if selectable} @@ -1057,7 +1090,7 @@ onclick={(e) => { e.stopPropagation(); if (onContextMenuOverride) onContextMenuOverride(e, item); - else openContext(e, item); + else void openContext(e, item); }} > @@ -1096,11 +1129,16 @@ ondragleave={onSystemDragLeave} ondrop={onSystemDrop} > + +

{title}

-

{title}

- {#if breadcrumb} -
{@render breadcrumb()}
- {/if} {#snippet start()} +
{@render breadcrumb()}
+ {/if}
{#if error} diff --git a/frontend/src/lib/utils/folderAccess.ts b/frontend/src/lib/utils/folderAccess.ts index a20ca2e9..b75d3f66 100644 --- a/frontend/src/lib/utils/folderAccess.ts +++ b/frontend/src/lib/utils/folderAccess.ts @@ -75,22 +75,3 @@ export async function probeFolderAccess(id: string): Promise { 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 5a36f8cf..dab700d6 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -28,7 +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 { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; import { t } from '$lib/i18n/index.svelte'; let raw = $state([]); @@ -124,16 +124,6 @@ ]); 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'); @@ -345,6 +335,14 @@ showPath selectable {contextActions} + menuPrepare={async (item) => { + // Lazy folder-access probe — fires only when the user actually + // opens the context menu on a row, not proactively for every + // row on load. Cached in the LRU (see `folderAccess.ts`) so + // subsequent right-clicks on the same folder are instant. + const pid = parentFolderId(item); + if (pid) await probeFolderAccess(pid); + }} {groupBys} bind:groupBy bind:reversed diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index be4f3762..84c7e0b5 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -35,7 +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 { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; import { t } from '$lib/i18n/index.svelte'; import Icon from '$lib/icons/Icon.svelte'; @@ -120,16 +120,6 @@ ]); 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'); @@ -387,6 +377,14 @@ showDotfileToggle selectable {contextActions} + menuPrepare={async (item) => { + // Lazy folder-access probe — fires only when the user actually + // opens the context menu on a row, not proactively for every + // row on load. Cached in the LRU forever after (per-session); + // subsequent right-clicks on the same folder are instant. + const pid = parentFolderId(item); + if (pid) await probeFolderAccess(pid); + }} {groupBys} bind:groupBy bind:reversed