feat(ui): add 'open parent directory' in recent and favorite section
This commit is contained in:
@@ -50,6 +50,16 @@
|
|||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
danger?: boolean;
|
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;
|
run: (item: FileItem | FolderItem, ctx?: ItemContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,6 +1271,9 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if ctxOpen && ctxItem && contextActions}
|
{#if ctxOpen && ctxItem && contextActions}
|
||||||
|
{@const visibleActions = contextActions.filter(
|
||||||
|
(a) => a.visible?.(ctxItem!, ctxOf(ctxItem!.id)) !== false
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
class="rl-ctx-scrim"
|
class="rl-ctx-scrim"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
@@ -1274,7 +1287,7 @@
|
|||||||
role="menu"
|
role="menu"
|
||||||
data-testid="resource-list-context-menu"
|
data-testid="resource-list-context-menu"
|
||||||
>
|
>
|
||||||
{#each contextActions as action (action.key)}
|
{#each visibleActions as action (action.key)}
|
||||||
<button
|
<button
|
||||||
class="rl-ctx-item"
|
class="rl-ctx-item"
|
||||||
class:rl-ctx-item--danger={action.danger}
|
class:rl-ctx-item--danger={action.danger}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
type ItemContext
|
type ItemContext
|
||||||
} from '$lib/components/ResourceList.svelte';
|
} from '$lib/components/ResourceList.svelte';
|
||||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||||
|
import { folderAccessCached, warmFolderAccess } from '$lib/utils/folderAccess';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
let raw = $state<FavoritesResourceItem[]>([]);
|
let raw = $state<FavoritesResourceItem[]>([]);
|
||||||
@@ -123,6 +124,16 @@
|
|||||||
]);
|
]);
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
void owners.resolve(page.items.map((i) => i.resource.created_by));
|
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) {
|
} catch (e) {
|
||||||
console.error('favorites: load error', e);
|
console.error('favorites: load error', e);
|
||||||
error = t('errors_loadFailed', 'Failed to load items');
|
error = t('errors_loadFailed', 'Failed to load items');
|
||||||
@@ -223,7 +234,31 @@
|
|||||||
a.remove();
|
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[] = [
|
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',
|
key: 'download',
|
||||||
label: t('common.download', 'Download'),
|
label: t('common.download', 'Download'),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
// filter is inside ResourceList (gated on `showDotfileToggle`).
|
// filter is inside ResourceList (gated on `showDotfileToggle`).
|
||||||
import { preferences } from '$lib/stores/preferences.svelte';
|
import { preferences } from '$lib/stores/preferences.svelte';
|
||||||
import { isDotfile } from '$lib/utils/dotfileFilter';
|
import { isDotfile } from '$lib/utils/dotfileFilter';
|
||||||
|
import { folderAccessCached, warmFolderAccess } from '$lib/utils/folderAccess';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
|
||||||
@@ -119,6 +120,16 @@
|
|||||||
]);
|
]);
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
void owners.resolve(page.items.map((i) => i.resource.updated_by));
|
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) {
|
} catch (e) {
|
||||||
console.error('recent: load error', e);
|
console.error('recent: load error', e);
|
||||||
error = t('errors_loadFailed', 'Failed to load items');
|
error = t('errors_loadFailed', 'Failed to load items');
|
||||||
@@ -254,7 +265,33 @@
|
|||||||
a.remove();
|
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[] = [
|
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',
|
key: 'download',
|
||||||
label: t('common.download', 'Download'),
|
label: t('common.download', 'Download'),
|
||||||
|
|||||||
Reference in New Issue
Block a user