From 3b31b8911b0e4baee4bc721f0bf28a4572597e02 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 26 Jul 2026 21:31:04 +0200 Subject: [PATCH] feat(breadcrumb): build breadcrumb in 1 API call add /api/folders/{id}/ancestors this API to iterate parent up to the drive root or the shared folder this will help UI to build the breadcrumb in 1 API call and to identify the root element (is it a drive users has access to or a shared folder ?) ui: now only 1 API call is now required to build the breadcrumb --- frontend/src/lib/api/endpoints/folders.ts | 31 +- frontend/src/lib/api/types.ts | 65 ++++ .../lib/components/FolderBreadcrumb.svelte | 343 ++++++++++++++++++ .../src/lib/components/ResourceList.svelte | 37 +- frontend/src/lib/styles/ported/breadcrumb.css | 16 +- .../src/lib/styles/ported/resourceList.css | 31 +- .../src/routes/files/[...path]/+page.svelte | 227 +++++------- frontend/src/routes/files/page.test.ts | 7 + frontend/src/routes/search/+page.svelte | 81 ++--- frontend/static/locales/ar.json | 9 +- frontend/static/locales/de.json | 9 +- frontend/static/locales/en.json | 9 +- frontend/static/locales/es.json | 9 +- frontend/static/locales/fa.json | 9 +- frontend/static/locales/fr.json | 9 +- frontend/static/locales/hi.json | 9 +- frontend/static/locales/it.json | 9 +- frontend/static/locales/ja.json | 9 +- frontend/static/locales/ko.json | 9 +- frontend/static/locales/nl.json | 9 +- frontend/static/locales/pl.json | 9 +- frontend/static/locales/pt.json | 9 +- frontend/static/locales/ru.json | 9 +- frontend/static/locales/zh-TW.json | 9 +- frontend/static/locales/zh.json | 9 +- src/application/dtos/folder_dto.rs | 105 ++++++ src/application/services/folder_service.rs | 119 +++++- .../repositories/pg/folder_db_repository.rs | 106 ++++++ src/interfaces/api/handlers/folder_handler.rs | 38 +- src/interfaces/api/mod.rs | 13 +- src/interfaces/api/routes.rs | 7 +- tests/api/folder_ancestors.hurl | 192 ++++++++++ tests/api/run.sh | 1 + 33 files changed, 1342 insertions(+), 221 deletions(-) create mode 100644 frontend/src/lib/components/FolderBreadcrumb.svelte create mode 100644 tests/api/folder_ancestors.hurl diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index f955633c..7e197883 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -1,7 +1,7 @@ /** Folder endpoints — ported from filesModel.js + fileOperations.js. */ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; -import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; +import type { FileItem, FolderAncestorsResponse, FolderItem, ItemType } from '$lib/api/types'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; const NO_CACHE: RequestInit = { @@ -109,6 +109,35 @@ export function getFolder(id: string): Promise { return request; } +// ── Ancestor chain (breadcrumb) ────────────────────────────────────────── +// Backing store + inflight dedup for `GET /api/folders/{id}/ancestors` — +// mirrors the folderInflight pattern for `getFolder`. Rapid navigation +// (files → sub → sub-sub in <1s) folds concurrent requests for the same +// leaf into one round-trip. Response also seeds `folderNames` for every +// ancestor, so subsequent `getFolderName(id)` lookups are cache-free. +const ancestorsInflight = new Map>(); + +export function getFolderAncestors(id: string): Promise { + const inflight = ancestorsInflight.get(id); + if (inflight) return inflight; + const request = (async () => { + try { + const chain = await apiJson( + `/api/folders/${id}/ancestors`, + NO_CACHE + ); + // Prime the shared folder-name cache — the breadcrumb walk + // happens to be the exact input that populates it. + for (const a of chain.ancestors) rememberFolderName(a.id, a.name); + return chain; + } finally { + ancestorsInflight.delete(id); + } + })(); + ancestorsInflight.set(id, request); + return request; +} + /** One page of `/api/folders/{id}/resources`. */ export interface FolderPage { /** diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 38efe888..6841391f 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -413,3 +413,68 @@ export interface DriveMember { granted_at: string; expires_at?: string | null; } + +// ─── Folder ancestors (breadcrumb endpoint) ────────────────────────────── +// Wire shape of `GET /api/folders/{id}/ancestors`. Mirrors the backend +// `FolderAncestorsDto` — see `src/application/dtos/folder_dto.rs`. One +// round-trip returns the whole caller-visible parent chain plus an +// `access_source` telling the breadcrumb component which root icon / +// tooltip to render. + +export interface FolderAncestor { + id: string; + name: string; + /** `null` on the drive-root ancestor. */ + parent_id: string | null; + /** + * Drive the folder belongs to (always populated — every folder has a + * drive_id post-D0). Lets `/files` derive `currentFolderDriveId` from + * the ancestors response instead of firing an extra + * `GET /api/folders/{id}` on load. Same value across every entry in + * `ancestors` (all folders in a chain live in one drive). + */ + drive_id: string; +} + +/** + * How the caller reached the topmost accessible ancestor. + * - `drive` — via drive membership (own personal, secondary personal, or + * shared drive). `drive` field carries the drive's id/name/kind for + * the root icon. + * - `direct_share` — via a folder-level `role_grants` row (share). + * `subject` may name the grantee (self or a group) once subject + * enrichment lands; MVP leaves it null. + * - `token` — reserved for public-link callers. Not emitted today. + */ +export type AccessSourceKind = 'drive' | 'direct_share' | 'token'; + +export interface AccessSourceDrive { + id: string; + name: string; + kind: DriveKind; +} + +export interface AccessSourceSubject { + kind: 'user' | 'group'; + id: string; + /** Nullable in MVP (subject enrichment deferred). */ + name?: string | null; +} + +export interface AccessSource { + kind: AccessSourceKind; + /** Populated when `kind === 'drive'`. */ + drive?: AccessSourceDrive; + /** Optional grantee info for shares / group grants. */ + subject?: AccessSourceSubject; +} + +/** + * Response envelope of `GET /api/folders/{id}/ancestors`. `ancestors` + * is root-first, leaf-last (length ≥ 1). `access_source` describes + * the boundary at element 0 (drive root or share boundary). + */ +export interface FolderAncestorsResponse { + ancestors: FolderAncestor[]; + access_source: AccessSource; +} diff --git a/frontend/src/lib/components/FolderBreadcrumb.svelte b/frontend/src/lib/components/FolderBreadcrumb.svelte new file mode 100644 index 00000000..975d805a --- /dev/null +++ b/frontend/src/lib/components/FolderBreadcrumb.svelte @@ -0,0 +1,343 @@ + + +{#if chain && (visibleCrumbs.length > 0 || chain.access_source.kind === 'drive')} + +{/if} + + diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 956b7a43..47d37647 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -504,6 +504,33 @@ const SKELETON = [0, 1, 2, 3, 4, 5]; + // ── Delayed-skeleton reveal ────────────────────────────────────────── + // Fast fetches (< 150 ms) don't render the skeleton bars — the flash + // is worse UX than briefly-empty content. The skeleton appears only + // when a load is genuinely slow. Ed's 2026-07-26 report: navigating + // from an empty folder to its parent showed "6 blank elements" (the + // skeleton) for the ~25 ms fetch window because stale-while-revalidate + // at the /files layer has no previous content to keep on screen here. + // + // Pairs with the empty-state gate below (`!loading && isEmpty`) so + // the pre-fix "Folder is empty" flash during the delay window + // doesn't come back — during load, neither skeleton nor empty state + // renders; the container just holds empty until content or the + // 150 ms timer elapses. + let renderSkeleton = $state(false); + $effect(() => { + if (loading && items.length === 0) { + const timer = setTimeout(() => { + renderSkeleton = true; + }, 150); + return () => { + clearTimeout(timer); + renderSkeleton = false; + }; + } + renderSkeleton = false; + }); + // ── Group-by / direction ────────────────────────────────────────────────── const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy)); @@ -1319,9 +1346,15 @@ {#if error} - {:else if loading && isEmpty} + {:else if renderSkeleton} + - {:else if isEmpty} + {:else if isEmpty && !loading} + s.length > 0)); - // First-crumb icon mirrors the drive at pathSegments[0]: `home` for the - // default-personal, `folder` for a secondary personal, `users` for a - // shared drive. Falls back to `home` while the drives list is loading - // or when the URL's leading segment isn't a known drive root (deep-link - // into a sub-folder bypasses drive identification — same limitation as - // the breadcrumb name resolution). - const rootIcon = $derived.by(() => { - const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null); - return drive ? driveIcon(drive) : 'home'; - }); - // The drive whose content the user is currently browsing. // // Priorities (first match wins): - // 1. `currentFolderDriveId` — set by `load()` after a `getFolder` - // fetch on the current folder. Authoritative for deep-links - // too (the URL's leading segment might not be a drive root). + // 1. `currentFolderDriveId` — set by `load()` from the ancestors + // response (`chain.ancestors.at(-1).drive_id`). Authoritative + // for deep-links too (the URL's leading segment might not be a + // drive root). // 2. `listing.folders[0]?.drive_id` — fast-path when the folder - // has at least one subfolder; avoids the extra round-trip on - // the initial `applyListing` before `getFolder` returns. + // has at least one subfolder; avoids waiting on the ancestors + // response before the initial `applyListing`. // (`FileDto` doesn't carry `drive_id` today, so we can't use // files as a fallback source; folders alone.) // 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy @@ -156,11 +146,23 @@ const hiddenCount = $derived( preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0 ); - let crumbs = $state>([]); let currentId = $state(null); - let loading = $state(false); - // Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it. - let showSkeleton = $state(false); + // Default `true` (not `false`) so the first render — before the + // `$effect` fires `load()` — shows the "loading" arm of ResourceList + // (skeleton, gated on 100 ms delay) instead of the "empty" arm + // ("No elements here"). Ed's 2026-07-26 report: a brief empty-state + // flash appeared between page mount and the first fetch landing. + // `load()` still writes `loading = true` before its first await, so + // mid-navigation clears work as before. + let loading = $state(true); + // `showSkeleton` used to sit 100 ms behind `loading` to avoid flashing + // skeleton bars on fast loads. Retired 2026-07-26 because ResourceList + // received `loading={showSkeleton}` (not the real `loading` state), so + // during those 100 ms it saw `loading=false && items=[]` and rendered + // the empty-state ("Folder is empty") — the flash Ed reported. Pass + // the real `loading` instead; the skeleton renders instantly for + // slow loads and instantly-disappears for fast loads (users don't + // perceive a sub-100 ms frame flip). let error = $state(null); let fileInput = $state(null); let uploading = $state(false); @@ -219,24 +221,6 @@ } } - async function buildCrumbs(segments: string[]): Promise> { - // Names come from the cache first (every listing names its children, so - // step-by-step navigation needs zero requests); only ids we've never seen - // — a cold deep-link's ancestors — are fetched, in parallel. - return Promise.all( - segments.map(async (id) => { - const known = getFolderName(id); - if (known !== undefined) return { id, name: known }; - try { - const f = await getFolder(id); - return { id, name: f.name }; - } catch { - return { id, name: '…' }; - } - }) - ); - } - // Bumped on every load; a stale in-flight response checks this before it // writes state, so a fast navigation can't be clobbered by an older fetch. let loadSeq = 0; @@ -262,7 +246,6 @@ const seq = ++loadSeq; let folderId: string; - let skeletonTimer: ReturnType | undefined; if (reset) { // External users have no home folder; send them to shared-with-me. if (session.isExternalUser && pathSegments.length === 0) { @@ -294,32 +277,55 @@ currentId = folderId; filesStore.currentFolder = folderId; - // Reset paging state: previous folder's cursor is meaningless here, - // and mixing its rows with the new folder's would flash a wrong list. - pageCursor = undefined; - listing = { folders: [], files: [] }; - orderedItems = []; + // Reset paging state: previous folder's cursor is meaningless + // on the new folder — must clear or the first append would + // paginate the OLD folder's next-page slice. + // + // `listing` / `orderedItems` are deliberately NOT cleared — + // the previous folder's rows stay on screen during the (~25 ms) + // fetch, then the response handler swaps in the new folder's + // content atomically. Stale-while-revalidate for the inter- + // folder case (Ed 2026-07-26: the pre-refactor clear-then- + // fetch-then-render sequence flashed either the SkeletonList + // or the "Folder is empty" empty-state for the fetch window, + // depending on which arm ResourceList happened to render for + // the empty-loading state; neither is useful for a 25 ms + // transition). First-mount (no previous content) still hits + // the skeleton correctly because `orderedItems` defaults `[]` + // and `loading` defaults `true` — the empty-loading arm + // gates on that. loading = true; + pageCursor = undefined; - // Delayed skeleton so fast loads don't flash it. - skeletonTimer = setTimeout(() => { - if (loading) showSkeleton = true; - }, 100); + // Legacy path-chain URLs canonicalize to the single-id form on + // load. `/files/A/B/C` still resolves (router matches `[...path]`) + // but the URL bar and any subsequent bookmark reflects the + // canonical `/files/C` — see 2026-07-26 URL-format discussion. + // `replaceState` (not `pushState`) so the back button doesn't + // gain a spurious entry. + if (pathSegments.length > 1 && typeof window !== 'undefined') { + window.history.replaceState({}, '', resolve(`/files/${folderId}`)); + } - // Breadcrumbs resolve independently so they never block the grid paint. - void buildCrumbs(pathSegments).then((trail) => { - if (seq === loadSeq) crumbs = trail; - }); - - // Resolve the current folder's drive_id so the read-only banner - // works even on deep-links into a sub-folder. Guarded by `seq`. - void getFolder(folderId) - .then((folder) => { - if (seq === loadSeq) currentFolderDriveId = folder.drive_id; + // Resolve the current folder's drive_id via the ancestors + // response — every `FolderAncestor` carries `drive_id`, so + // the shared ``'s in-flight call is the + // same round-trip we'd otherwise duplicate here. The + // `ancestorsInflight` dedup map inside `getFolderAncestors` + // means this second caller gets the same promise, not a + // second HTTP request — the extra `getFolder(folderId)` + // that used to fire here is gone (2026-07-26 UX pass on + // /files load traffic). + void getFolderAncestors(folderId) + .then((chain) => { + if (seq !== loadSeq) return; + const leaf = chain.ancestors.at(-1); + if (leaf) currentFolderDriveId = leaf.drive_id; }) .catch(() => { // Fallback chain in `currentDrive` still gives us a - // best-effort drive resolution. + // best-effort drive resolution (listing.folders[0].drive_id, + // then drivesStore lookup by root-folder id). }); } else { // Append path: reuse `currentId`. `pageCursor === undefined` means @@ -360,10 +366,8 @@ ? e.message : String(e); } finally { - if (skeletonTimer !== undefined) clearTimeout(skeletonTimer); if (seq === loadSeq && reset) { loading = false; - showSkeleton = false; } } } @@ -421,7 +425,10 @@ } function openFolder(folder: FolderItem) { - goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`)); + // Canonical single-id URL. Legacy `/files/A/B/C` still resolves + // (canonicalize-on-load rewrites it inside `load()`), but new + // navigation lands directly on `/files/{id}`. + goto(resolve(`/files/${folder.id}`)); } async function onNewFolder() { @@ -1185,12 +1192,12 @@ // ── Drag-to-move ───────────────────────────────────────────────────────── const DRAG_TYPE = 'application/x-oxi-item'; let dropFolderId = $state(null); - // Highlighted breadcrumb crumb during an OxiCloud drag. Holds the - // crumb's folder id, or the sentinel `'__home__'` for the home link - // (which doesn't have a stable folder id — depends on the caller's - // home folder resolution). - const CRUMB_HOME_ID = '__home__'; - let dropCrumbId = $state(null); + // Per-crumb drop highlight state lived here until the breadcrumb + // migrated to the shared `` component (2026-07-26), + // which owns its own hover state. The `CRUMB_HOME_ID` sentinel is + // gone too — the shared component's root icon isn't a drop target + // (the drive root's ancestor is always the drive itself, and + // dropping "at the drive" is ambiguous). // Copy-vs-move on drop. // @@ -1871,7 +1878,7 @@ ) : t('files.empty_hint', 'Drop files here or use the Upload button to add files.')} emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined} - loading={showSkeleton} + {loading} error={error ?? undefined} selectable shiftRangeSelect @@ -1923,61 +1930,24 @@ {/snippet} {#snippet breadcrumb()} - + + onCrumbDrop(e, target)} + dragMime={DRAG_TYPE} + /> {/snippet} {#snippet actions()} @@ -2142,7 +2112,8 @@ onclick={() => { const id = ctxTarget!.id; closeContext(); - goto(resolve(`/files/${[...pathSegments, id].join('/')}`)); + // Canonical single-id URL — see `openFolder` above. + goto(resolve(`/files/${id}`)); }}> {t('files.open', 'Open')}