diff --git a/.github/workflows/load-nightly.yml b/.github/workflows/load-nightly.yml index 31e16c73..82daf863 100644 --- a/.github/workflows/load-nightly.yml +++ b/.github/workflows/load-nightly.yml @@ -57,7 +57,15 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - shared-key: load + # Distinct from load-smoke's `load-v2`. Nightly runs on the + # self-hosted `nuc-loadtest` runner (homogeneous), where + # `-C target-cpu=native` from `.cargo/config.toml` gives the + # accurate perf baseline we want. Smoke runs on the GHA + # heterogeneous pool with `RUSTFLAGS=-C target-cpu=x86-64-v3`. + # Sharing the same cache key would let native-baked proc-macro + # dylibs from nightly leak into smoke's restore set → SIGILL + # on a leaner GHA runner. Separate namespaces prevent that. + shared-key: load-nightly-v2 - name: Install Node 20 # The self-hosted runner image ships Node 12, which can't parse the diff --git a/.github/workflows/load-smoke.yml b/.github/workflows/load-smoke.yml index 330f5536..60cb8157 100644 --- a/.github/workflows/load-smoke.yml +++ b/.github/workflows/load-smoke.yml @@ -18,6 +18,14 @@ on: env: CARGO_TERM_COLOR: always + # `.cargo/config.toml` sets `-C target-cpu=native` for dev speed, but + # GitHub-hosted runners are a heterogeneous pool — cached rlibs / proc- + # macro dylibs baked with AVX-512 on one runner crash with SIGILL when + # rustc dlopens them on a leaner one. Env RUSTFLAGS fully replaces the + # config.toml value (they don't merge), so pinning to `x86-64-v3` here + # (AVX2 baseline — every GHA runner has it) makes cached artifacts + # portable across the pool without touching local dev. + RUSTFLAGS: "-C target-cpu=x86-64-v3" jobs: smoke: @@ -31,7 +39,9 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - shared-key: load + # Bumped suffix busts any existing cache poisoned with native-CPU + # ISA from a prior build (see RUSTFLAGS note above). + shared-key: load-v2 - name: Install k6 uses: grafana/setup-k6-action@v1 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..38da7e5b 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -298,8 +298,34 @@ export interface SearchResourcesResponse { export type DriveKind = 'personal' | 'shared'; -/** Role-keyed share strength. Matches `Role` in the backend authz model. */ -export type DriveRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer'; +/** + * Full role set from `storage.grant_role` — every value that can appear + * on a `role_grants` row regardless of `resource_type` (drive, folder, + * file, playlist, calendar, address_book, …). Use this for folder-level + * and file-level `caller_role` fields where all five values are valid. + * Matches `RoleDto` in the backend. + */ +export type GrantRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer'; + +/** + * Role assignable at DRIVE scope — a strict subset of `GrantRole`. + * Drives only meaningfully take the three management-ladder tiers: + * - `owner` — full control (rename, delete, quota, membership). + * - `editor` — can create/modify content anywhere in the drive. + * - `viewer` — read-only access to the whole drive. + * + * `contributor` (create-in-folder-without-touching-siblings) and + * `commenter` (react without modifying) are folder/file-scope + * semantics: they describe fine-grained access to a specific item, + * not to a whole drive. Grants of those roles happen at folder or + * file scope via a separate `role_grants` row, not at the drive + * boundary. Do NOT widen this type without a matching backend + * check — the DB ENUM permits all 5 today, so the constraint is + * conventional. + * + * Use `GrantRole` for folder/file-level `caller_role` fields. + */ +export type DriveRole = 'owner' | 'editor' | 'viewer'; /** Subject of a grant. Mirrors `SubjectDto`. */ export type SubjectKind = 'user' | 'group' | 'token'; @@ -413,3 +439,86 @@ 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; + /** + * SHARER — the user who created the grant that gave the caller + * access at the boundary (`role_grants.granted_by`). Kind is always + * `'user'` today (a group can't perform an action), but the type + * stays open in case a future model permits it. Null when the + * boundary can't be resolved to a single grant (e.g. `token`). + */ + subject?: AccessSourceSubject; + /** + * Caller's role via the boundary grant (`role_grants.role` on the + * same row that carries `granted_by`). Lets the FE render permission- + * aware affordances at the ancestor scope. Reflects the boundary grant + * only — aggregate effective role via other channels may be stronger. + * Null on `token` access. + * + * Typed as `GrantRole` (not `DriveRole`): the boundary can be a + * folder-level share where all five role_grant values are valid, + * not just the drive-scoped subset. + */ + caller_role?: GrantRole | null; +} + +/** + * 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..e0df3061 --- /dev/null +++ b/frontend/src/lib/components/FolderBreadcrumb.svelte @@ -0,0 +1,423 @@ + + +{#if chain && (visibleCrumbs.length > 0 || chain.access_source.kind === 'drive')} + +{/if} + + diff --git a/frontend/src/lib/components/MoveDialog.svelte b/frontend/src/lib/components/MoveDialog.svelte index d9bfe240..53894e8d 100644 --- a/frontend/src/lib/components/MoveDialog.svelte +++ b/frontend/src/lib/components/MoveDialog.svelte @@ -10,9 +10,12 @@ import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; - // A drive accepts new items only if the caller can Create on its root. - // Owner / Editor / Contributor cover that; Commenter + Viewer cannot. - const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const; + // A drive accepts new items only if the caller can Create on its + // root. Drive-scope roles are the management-ladder subset — + // Owner / Editor / Viewer — so writability collapses to the top two; + // Viewer cannot. `contributor`/`commenter` don't appear at drive + // scope (folder/file-scope semantics), so they're not in `DriveRole`. + const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor'] as const; function isWritable(d: Drive): boolean { return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role); } diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 956b7a43..8be57b8a 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -163,6 +163,17 @@ * the action is meaningful for and returns nothing otherwise. */ bucketAction?: Snippet<[string]>; + /** + * Optional custom renderer for the swimlane header label. Receives + * the bucket key + the default label string (from `labelOf`, or + * the key itself if no `labelOf`). Pages that want rich header + * content (e.g. `/shared-with-me` prefixing the "Shared by X" + * header with the sharer's avatar) use this; pages happy with + * a plain text label leave it undefined and ResourceList renders + * `{section.label}` as before. The default label is passed too + * so pages don't have to re-invoke `labelOf` themselves. + */ + bucketLabel?: Snippet<[string, string]>; /** Show the owner column + vignette (list view) and hover tooltip. */ showOwner?: boolean; /** @@ -248,6 +259,20 @@ * don't have to piggyback on the bar. */ itemActions?: Snippet<[FileItem | FolderItem]>; + /** + * Free-form overlay rendered inside `.file-item` (as a sibling of + * `.action-cell`), so the page can absolute-position content + * anywhere on the card. Used by `/shared-with-me` to anchor the + * sharer avatar at the bottom-right of the card — the pre-fix + * `rowBadge` slot rendered inside `.file-icon` (a positioned + * ancestor), which couldn't align with `.action-cell`'s + * `.file-item`-scoped coordinates. The page provides its own + * absolute-positioning CSS via a scoped style block. + * + * Fires in grid view only — list view has explicit columns + * (owner cell, etc.) for the same information. + */ + cardOverlay?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>; /** * Action-bar left cluster — always-visible page action buttons * (Upload / New folder / Empty trash / Clear recent / …). Swaps @@ -385,6 +410,7 @@ dateLabel, dateCell, bucketAction, + bucketLabel, showOwner = false, ownerLabel, showViewToggle = true, @@ -402,6 +428,7 @@ oncontextmenu: onContextMenuOverride, menuPrepare, itemActions, + cardOverlay, actions, batchActions, rowBadge, @@ -504,6 +531,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)); @@ -1213,6 +1267,17 @@ {/if} {/if} + + {#if cardOverlay && filesStore.viewMode === 'grid'} + {@render cardOverlay(item, ctx)} + {/if} {/snippet} @@ -1319,9 +1384,15 @@ {#if error} - {:else if loading && isEmpty} + {:else if renderSkeleton} + - {:else if isEmpty} + {:else if isEmpty && !loading} + - {section.label} + + {#if bucketLabel} + {@render bucketLabel(section.key, section.label)} + {:else} + {section.label} + {/if} + {#if bucketAction} {@render bucketAction(section.key)} @@ -1362,7 +1439,13 @@ {#each sections as section (section.key)} {#if section.label}
- {section.label} + + {#if bucketLabel} + {@render bucketLabel(section.key, section.label)} + {:else} + {section.label} + {/if} + {#if bucketAction} {@render bucketAction(section.key)} @@ -1647,6 +1730,17 @@ align-items: center; } + /* Label slot — inline-flex + gap so pages injecting rich content + via the `bucketLabel` snippet (e.g. `/shared-with-me` prefixing + with a sharer avatar) render avatar-then-text on one baseline + without hand-tuned spacing. Plain-text labels (no snippet) still + look identical — flex on a single text node is a no-op. */ + .rl-swimlane-header__label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + } + /* Grouped-grid container: a vertical stack of (header + its own windowed card grid) per section. Not `.files-grid-view` — the grid is on each VirtualList's inner window, so this outer element just stacks. */ diff --git a/frontend/src/lib/components/UserAvatar.svelte b/frontend/src/lib/components/UserAvatar.svelte new file mode 100644 index 00000000..c346a4bb --- /dev/null +++ b/frontend/src/lib/components/UserAvatar.svelte @@ -0,0 +1,131 @@ + + + + {#if image} + + {:else} + + {initials} + + {/if} + + + diff --git a/frontend/src/lib/components/round18.bench.test.ts b/frontend/src/lib/components/round18.bench.test.ts index e2542d3a..540d900f 100644 --- a/frontend/src/lib/components/round18.bench.test.ts +++ b/frontend/src/lib/components/round18.bench.test.ts @@ -109,7 +109,7 @@ describe('round18 §F1 — ResourceList itemIndexById incremental Map', () => { expect(reloaded).not.toBe(afterDelete); }); - it('a P-page drain builds the index ≥5x faster incrementally (perf gate)', () => { + it('a P-page drain builds the index ≥4x faster incrementally (perf gate)', () => { const PAGES = 40; const PER = 50; // 2 000 items total const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER)); @@ -129,6 +129,11 @@ describe('round18 §F1 — ResourceList itemIndexById incremental Map', () => { console.info( `§F1 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)` ); - expect(afterMs).toBeLessThan(beforeMs / 5); + // Ratio threshold: single-shot microbenches on shared CI runners + // (throttled CPU, cold caches, sibling load) routinely wobble + // ±20 %. 4× still catches any real regression — a correctness + // break would collapse the ratio to <2× — while surviving + // runner noise. Local dev machines see 6–10× consistently. + expect(afterMs).toBeLessThan(beforeMs / 4); }); }); diff --git a/frontend/src/lib/styles/ported/breadcrumb.css b/frontend/src/lib/styles/ported/breadcrumb.css index e7e9b60b..481a7c73 100644 --- a/frontend/src/lib/styles/ported/breadcrumb.css +++ b/frontend/src/lib/styles/ported/breadcrumb.css @@ -21,6 +21,14 @@ .breadcrumb-link { cursor: pointer; color: var(--color-text-muted); + /* No underline at rest OR on hover — Ed's 2026-07-26 UX call: the + hover background alone is enough affordance, and the pre-fix + browser-default underline mixed awkwardly with the bold-leaf + styling (leaf was bold+plain, root link was underlined+plain, + and the styling difference read as "these do different things" + when in fact both are simple navigations). Uniform link chrome + via background-on-hover; the bold-current class flags the leaf. */ + text-decoration: none; } .breadcrumb-link.drop-target { @@ -29,15 +37,19 @@ } .breadcrumb-link:hover { - text-decoration: underline; color: var(--color-accent); background: var(--color-accent-bg); } +/* Applied to the LEAF crumb (last visible item in the chain) so it + reads as "you are here". Every crumb — leaf included — is now a + link (Ed's 2026-07-26 UX ask: from `/search` the fastest way to + jump into the searched folder is to click its name in the crumb + trail; making the leaf clickable serves that path with zero extra + clicks). Only the bold weight distinguishes it from an intermediate. */ .breadcrumb-current { font-weight: var(--weight-semibold); color: var(--color-text-black); - cursor: default; } .breadcrumb-separator { diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 32129ce9..00e3d80d 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -360,6 +360,19 @@ white-space: nowrap; } +/* List-view rows are fixed 56px (VirtualList `rowHeight={56}`), and a + full UserVignette stacks avatar (32px) + name (20px) + email (15px) + → ~40px block that doesn't visually fit the flex-centered cell — + the email tail was cropped. Hide the email in dense-row contexts + (Ed 2026-07-26); the identity signal remains (avatar + name), and + the email survives in the ShareDialog / recipient pickers where + UserVignette was originally scaled for. Grid view's owner cell is + unaffected (it never renders a UserVignette — the sharer surfaces + through the `.file-icon__badge` slot instead). */ +.files-list-view .file-item .owner-cell .uv__email { + display: none; +} + /* Expand the grid track as soon as at least one owner cell is visible. */ .files-list-view:has(.owner-cell:not(.hidden)) { --files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 200px; @@ -409,7 +422,10 @@ overflow: hidden; } -.files-list-view .file-item .name-cell span { +/* Same narrowing rationale as the grid view rule below — target only + the name text span, not every descendant span, so `.file-icon__badge` + overlay contents (avatars, chips) aren't sized as text. */ +.files-list-view .file-item .name-cell__text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -571,9 +587,21 @@ } /* Reveal the kebab on hover for cleaner rows — but only on hover-capable - devices, so touch users (no hover) keep it always tappable. Stays visible - on keyboard focus within the row. Applies to both list and grid views - because both keep the kebab inside `.action-cell`. */ + devices, so touch users (no hover) keep it always tappable. Applies + to both list and grid views because both keep the kebab inside + `.action-cell`. + + Keyboard accessibility comes from `:focus-visible` on the kebab + button itself (below), NOT `:focus-within` on the row. Using + `:focus-within` on the row was a lingering-visibility trap: + • dragstart landed focus on the dragged descendant → row + `:focus-within` stayed true after the pointer left → kebab + stayed visible on an otherwise-idle row. + • Opening a context-menu / ShareDialog portal moved focus outside + the row (good) but if focus briefly bounced through the kebab + first, the reveal could persist through the transition. + Ed's 2026-07-26 report: "when starting dragging or when using the + share dialog, I have the [...] button that remains visible." */ @media (hover: hover) { .files-list-view .file-item .action-cell button.file-actions, .files-grid-view .file-item .action-cell button.file-actions { @@ -582,9 +610,9 @@ } .files-list-view .file-item:hover .action-cell button.file-actions, - .files-list-view .file-item:focus-within .action-cell button.file-actions, .files-grid-view .file-item:hover .action-cell button.file-actions, - .files-grid-view .file-item:focus-within .action-cell button.file-actions { + .files-list-view .file-item .action-cell button.file-actions:focus-visible, + .files-grid-view .file-item .action-cell button.file-actions:focus-visible { opacity: 1; } } @@ -1024,11 +1052,16 @@ margin-top: var(--space-1); } -.files-grid-view .file-item .name-cell span { +/* Narrowed from the pre-fix `.name-cell span` (descendant selector) to + the specific name-text span. The broad rule caught EVERY span inside + `.name-cell` — including the `.file-icon__badge` overlay and, inside + it, ``'s `.ua` wrapper — and slapped 8 px of top/bottom + padding on them, which crushed the badge's `` from 22 × 22 down + to 22 × 6 (Ed 2026-07-26). Text ellipsis / padding stays on the text + span alone; overlay children in `.file-icon` are unaffected. */ +.files-grid-view .file-item .name-cell__text { display: block; max-width: 100%; - /* Ellipsis must live on the span (the text node), not the flex parent, - or long names clip with no "…". */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1391,10 +1424,15 @@ transition: opacity var(--motion-fast) var(--ease-standard); } +/* Reveal on hover OR when the button itself has keyboard focus. The + pre-fix `:focus-within` on the row was a lingering-visibility trap + during drag / dialog transitions — see the `.file-actions` block + above for the full rationale. `:focus-visible` on the button gives + keyboard users the same reveal without the row-scope side effect. */ .files-list-view .file-item:hover .action-cell .btn-action--hover, -.files-list-view .file-item:focus-within .action-cell .btn-action--hover, .files-grid-view .file-item:hover .action-cell .btn-action--hover, -.files-grid-view .file-item:focus-within .action-cell .btn-action--hover { +.files-list-view .file-item .action-cell .btn-action--hover:focus-visible, +.files-grid-view .file-item .action-cell .btn-action--hover:focus-visible { opacity: 1; pointer-events: auto; } diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index b72e8623..4bcbeed5 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -125,10 +125,6 @@ return t('drive.role.owner', 'Owner'); case 'editor': return t('drive.role.editor', 'Editor'); - case 'contributor': - return t('drive.role.contributor', 'Contributor'); - case 'commenter': - return t('drive.role.commenter', 'Commenter'); case 'viewer': return t('drive.role.viewer', 'Viewer'); } diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 87f36152..9c5f69cc 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -10,8 +10,7 @@ createFolder, deleteFolder, fetchFolderPage, - getFolder, - getFolderName, + getFolderAncestors, invalidateFolderCache, moveFolder, rememberFolderName, @@ -41,6 +40,7 @@ import { preferences } from '$lib/stores/preferences.svelte'; import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; + import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte'; import ResourceList, { isFile, type GroupByDef as RLGroupByDef @@ -48,7 +48,7 @@ import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; - import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; + import { drives as drivesStore } from '$lib/stores/drives.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -67,26 +67,16 @@ // /files → home root; /files/a/b → folder b inside a inside home. const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => 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')}