feat(ui): add 'open parent directory' in recent and favorite section

This commit is contained in:
Edouard Vanbelle
2026-07-20 01:21:48 +02:00
parent 63589e595e
commit 7ffb7bf0ae
4 changed files with 182 additions and 1 deletions
@@ -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
)}
<div
class="rl-ctx-scrim"
role="presentation"
@@ -1274,7 +1287,7 @@
role="menu"
data-testid="resource-list-context-menu"
>
{#each contextActions as action (action.key)}
{#each visibleActions as action (action.key)}
<button
class="rl-ctx-item"
class:rl-ctx-item--danger={action.danger}
+96
View File
@@ -0,0 +1,96 @@
/**
* Folder-access cache — memoises "can the caller read this folder?" so
* UI decisions (e.g. showing / hiding the "Open parent folder" entry in
* a context menu) don't fire an HTTP call at click-time.
*
* The backend answers the question via `GET /api/folders/{id}`:
* * 2xx → caller has Read on the folder (or it's their own).
* * 404 → anti-enumeration; treated as "no access" from the UI's
* perspective (the recipient can't navigate there whether the
* folder exists or not).
*
* The cache is a simple insertion-order-bumping LRU capped at
* `MAX_ENTRIES`. `probeFolderAccess` is the async entry point; pages
* kick a bulk `warmFolderAccess` when a list loads so the cache is
* populated before the user right-clicks anything.
*/
import { getFolder } from '$lib/api/endpoints/folders';
const MAX_ENTRIES = 200;
// Cache: id → resolved answer. Presence means we know; `true`/`false`
// distinguishes the two outcomes. Insertion order preserved by Map;
// `bump` re-inserts on write so oldest sits at the front for eviction.
const cache = new Map<string, boolean>();
// 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<string, Promise<boolean>>();
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<boolean> {
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<string | null | undefined>): void {
const seen = new Set<string>();
for (const id of ids) {
if (!id || seen.has(id) || cache.has(id) || inflight.has(id)) continue;
seen.add(id);
void probeFolderAccess(id);
}
}