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
This commit is contained in:
Edouard Vanbelle
2026-07-26 21:31:04 +02:00
parent 0efbf0ff85
commit 3b31b8911b
33 changed files with 1342 additions and 221 deletions
+30 -1
View File
@@ -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<FolderItem> {
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<string, Promise<FolderAncestorsResponse>>();
export function getFolderAncestors(id: string): Promise<FolderAncestorsResponse> {
const inflight = ancestorsInflight.get(id);
if (inflight) return inflight;
const request = (async () => {
try {
const chain = await apiJson<FolderAncestorsResponse>(
`/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 {
/**
+65
View File
@@ -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;
}
@@ -0,0 +1,343 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { getFolderAncestors } from '$lib/api/endpoints/folders';
import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
/**
* Shared breadcrumb component consuming
* `GET /api/folders/{id}/ancestors`. Renders the root icon
* (`access_source.kind` — drive / share / link) + a clickable
* chain of caller-visible ancestors down to the leaf.
*
* The endpoint's walk stops at the caller's share/drive-membership
* boundary, so this component never shows a folder the caller can't
* Read. If `folderId` is null (e.g. `/search` in "Everywhere" scope,
* or /files at the root listing) the component renders nothing.
*
* Optional `onDrop` prop enables `/files`-style drop-target behavior
* on each crumb (move dragged items into the target folder). Absent
* everywhere else. Uses the `application/x-oxi-item` MIME the row-drag
* emits — pass a matching handler.
*/
interface Props {
/** Leaf folder id; null renders the component as empty. */
folderId: string | null | undefined;
/**
* Optional drop handler — enables per-crumb drop targets when
* provided. Called with the target folder id + the raw drop
* event; the caller performs the move.
*/
onDrop?: (targetFolderId: string, e: DragEvent) => void;
/** MIME type of the row-drag payload — defaults to the shipped one. */
dragMime?: string;
}
let { folderId, onDrop, dragMime = 'application/x-oxi-item' }: Props = $props();
// Fetch chain when folderId changes. `$state` + `$effect` primer
// avoids blocking the initial render — the breadcrumb slot appears
// empty until the first response, then fills in.
let chain = $state<FolderAncestorsResponse | null>(null);
let dropTargetId = $state<string | null>(null);
$effect(() => {
const id = folderId;
if (!id) {
chain = null;
return;
}
void getFolderAncestors(id)
.then((c) => {
// Guard against out-of-order responses if `folderId`
// changed while awaiting.
if (folderId === id) chain = c;
})
.catch(() => {
// Silent failure — the breadcrumb collapses to empty. The
// consuming page still shows its main content (folder
// listing / search results); a missing crumb strip is a
// degraded-but-usable state, not a fatal one.
if (folderId === id) chain = null;
});
});
/**
* Ancestors to render as crumbs, with the drive-root deduplicated
* when access is via drive-membership. Rationale (Ed 2026-07-26):
* for drive-kind access, the topmost accessible ancestor IS the
* drive's root folder, and the drive's display name equals the
* root folder's name (`docs/plan/drive.md §3` — a drive has no
* `name` column, its name lives on its root folder). So the pre-
* fix breadcrumb rendered `Personal > Personal > child > …` for
* personal drives and `my family > my family > child > …` for
* shared. The root chip already labels the drive; dropping the
* duplicate first crumb collapses to the natural `[home] Personal
* > child > …` shape.
*
* For `direct_share` / `token` access, the topmost ancestor is a
* shared folder (not a drive root), so no dedup — every ancestor
* survives.
*/
const visibleCrumbs = $derived<FolderAncestor[]>(
chain
? chain.access_source.kind === 'drive' && chain.ancestors.length > 0
? chain.ancestors.slice(1)
: chain.ancestors
: []
);
// ── Root-icon derivation ────────────────────────────────────────────
// One icon per `access_source.kind`. Personal drives use the home
// glyph (they're the caller's own storage — signalling "home base");
// shared drives use `users` (multi-member). Ed's 2026-07-26 UX call
// bumped from the pre-fix `hard-drive` because personal drives
// deserve the same "you're on your own turf" visual affordance the
// legacy /files rootIcon used.
function rootIcon(src: AccessSource): string {
if (src.kind === 'drive') {
return src.drive?.kind === 'shared' ? 'users' : 'home';
}
if (src.kind === 'direct_share') return 'share-alt';
if (src.kind === 'token') return 'link';
return 'home';
}
function rootTooltip(src: AccessSource): string {
if (src.kind === 'drive' && src.drive) {
return src.drive.kind === 'shared'
? t('breadcrumb.root.shared_drive', { name: src.drive.name }, 'Shared drive: {{name}}')
: t('breadcrumb.root.personal_drive', { name: src.drive.name }, 'Personal drive: {{name}}');
}
if (src.kind === 'direct_share') {
return t('breadcrumb.root.direct_share', 'Shared with you');
}
if (src.kind === 'token') {
return t('breadcrumb.root.token', 'Via shared link');
}
return t('breadcrumb.home', 'Home');
}
/**
* Href for the root chip. For drive-kind access, links to the
* drive's root folder (the ancestor we deduped above) so the user
* can jump home from any depth. For share/token access the "root"
* is an abstract boundary with no navigable page — stays null and
* the template renders the chip as a non-clickable `<span>`.
* Hoisted here (not `{@const}` inside `<nav>`) because Svelte 5
* only allows `{@const}` as an immediate child of specific block
* tags — plain HTML elements don't qualify.
*/
// Root chip href. Two "clickable root" cases:
// • drive-kind → the drive root folder (the ancestor we dedup
// out of the chain above), so users can jump home from any
// depth without leaving the /files context.
// • direct_share → `/shared-with-me`, so users can back out to
// the full listing of what's been shared with them (Ed's
// 2026-07-26 UX ask: "when I clic on it that goes back to
// /shared-with-me").
// Token access stays non-clickable — there's no equivalent user-
// facing surface for a public-link session.
//
// Store the UNRESOLVED path here; `resolve()` runs in the template
// so the `svelte/no-navigation-without-resolve` lint sees the
// resolve call at the href site (the rule can't follow a state
// variable back to its assignment).
// Narrow union so SvelteKit's route-checked `resolve()` accepts it.
// The two paths are the only ones this component ever emits.
type RootHref = '/shared-with-me' | `/files/${string}`;
// True when the caller is AT the drive root (or share boundary) —
// no descendant crumbs to render. The root chip IS the current
// location and gets the `breadcrumb-current` bold treatment.
const isRootTheLeaf = $derived(chain !== null && visibleCrumbs.length === 0);
// Root href stays populated even when root-is-leaf — clicking a leaf
// crumb is a real navigation (from `/search` it jumps INTO the folder;
// from `/files` at drive root it's a self-navigation no-op). Ed's
// 2026-07-26 UX call: "all elements clickable, only the leaf bold."
const rootHrefPath = $derived<RootHref | null>(
chain === null
? null
: chain.access_source.kind === 'drive' && chain.ancestors.length > 0
? `/files/${chain.ancestors[0].id}`
: chain.access_source.kind === 'direct_share'
? '/shared-with-me'
: null
);
// Drop target for the root chip. Only meaningful when the root
// resolves to a real folder (drive root). `/shared-with-me` is a
// virtual listing — nothing to drop INTO — so direct_share and
// token variants stay drop-inert even when the chip is clickable.
const rootDropTarget = $derived<string | null>(
chain && chain.access_source.kind === 'drive' ? (chain.ancestors[0]?.id ?? null) : null
);
</script>
{#if chain && (visibleCrumbs.length > 0 || chain.access_source.kind === 'drive')}
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
<!--
Root chip: `<a>` when drive-kind access (jumps to the drive
root — the ancestor we dedup out of the chain above), `<span>`
for share/token (abstract boundary, no navigable target).
Icon + tooltip both derive from `access_source.kind`; the drive
arm additionally paints the drive name next to the icon so the
user sees which drive they're browsing at a glance.
-->
{#if rootHrefPath}
<a
href={resolve(rootHrefPath)}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
class:breadcrumb-current={isRootTheLeaf}
class:drop-target={onDrop != null &&
rootDropTarget != null &&
dropTargetId === rootDropTarget}
title={rootTooltip(chain.access_source)}
data-testid="folder-breadcrumb-root-link"
data-access-kind={chain.access_source.kind}
ondragover={onDrop && rootDropTarget
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
: undefined}
ondragenter={onDrop && rootDropTarget
? (e) => {
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = rootDropTarget;
}
: undefined}
ondragleave={onDrop && rootDropTarget
? () => {
if (dropTargetId === rootDropTarget) dropTargetId = null;
}
: undefined}
ondrop={onDrop && rootDropTarget
? (e) => {
dropTargetId = null;
onDrop(rootDropTarget, e);
}
: undefined}
>
<Icon name={rootIcon(chain.access_source)} />
{#if chain.access_source.kind === 'drive' && chain.access_source.drive}
<span class="breadcrumb-root-name">{chain.access_source.drive.name}</span>
{/if}
</a>
{:else}
<!--
Non-link root chip. Three cases land here:
1. `access_source.kind === 'token'` — no navigable target.
2. Drive-kind AND caller is AT the drive root (no
descendant crumbs). Gets `breadcrumb-current` so the
styling matches a deep-folder leaf (bold, no
underline) — Ed's 2026-07-26 UX ask: keep the leaf
look consistent regardless of depth.
3. Drive-kind with no ancestors at all (degenerate).
Drop target only wires when there's a real folder id AND
the caller opted in with an `onDrop` handler.
-->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="breadcrumb-item breadcrumb-home"
class:breadcrumb-current={isRootTheLeaf}
class:drop-target={onDrop != null &&
rootDropTarget != null &&
dropTargetId === rootDropTarget}
title={rootTooltip(chain.access_source)}
data-testid="folder-breadcrumb-root-icon"
data-access-kind={chain.access_source.kind}
ondragover={onDrop && rootDropTarget
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
: undefined}
ondragenter={onDrop && rootDropTarget
? (e) => {
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = rootDropTarget;
}
: undefined}
ondragleave={onDrop && rootDropTarget
? () => {
if (dropTargetId === rootDropTarget) dropTargetId = null;
}
: undefined}
ondrop={onDrop && rootDropTarget
? (e) => {
dropTargetId = null;
onDrop(rootDropTarget, e);
}
: undefined}
>
<Icon name={rootIcon(chain.access_source)} />
{#if chain.access_source.kind === 'drive' && chain.access_source.drive}
<span class="breadcrumb-root-name">{chain.access_source.drive.name}</span>
{/if}
</span>
{/if}
{#each visibleCrumbs as c, i (c.id)}
<span class="breadcrumb-separator">&gt;</span>
<!--
Every crumb links to `/files/{id}` — leaf included (Ed's
2026-07-26 UX call: from `/search` clicking the leaf jumps
INTO the searched folder in one click; from `/files` a
leaf-click is a self-navigation no-op). The leaf gets
`breadcrumb-current` for bold styling; intermediates stay
regular weight. No underline on either — the hover
background alone is the affordance.
Drop-target props fire only when the host page passed an
`onDrop` handler. Absent everywhere except `/files`.
-->
{@const isLeaf = i === visibleCrumbs.length - 1}
<a
href={resolve(`/files/${c.id}`)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-current={isLeaf}
class:drop-target={onDrop != null && dropTargetId === c.id}
data-testid={isLeaf ? `folder-breadcrumb-current-${c.id}` : `folder-breadcrumb-${c.id}`}
ondragover={onDrop
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
: undefined}
ondragenter={onDrop
? (e) => {
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = c.id;
}
: undefined}
ondragleave={onDrop
? () => {
if (dropTargetId === c.id) dropTargetId = null;
}
: undefined}
ondrop={onDrop
? (e) => {
dropTargetId = null;
onDrop(c.id, e);
}
: undefined}
>
{c.name}
</a>
{/each}
</nav>
{/if}
<style>
/* Chip attached to the drive-root icon; only present in the drive
arm of access_source. Kept a tight max-width so a long drive name
truncates gracefully instead of shoving the breadcrumb off-screen. */
.breadcrumb-root-name {
margin-left: var(--space-1);
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Drop-target flicker fix: without this the SVG icon + name chip act
as event targets, so `dragenter` fires on the anchor → highlight
sets → pointer crosses into a child → `dragleave` fires on the
anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events
off on children collapses the whole chip to a single drag target;
drop still lands because the anchor's own handlers stay live.
Intermediate crumbs don't need this (they contain only a text
node — no child element to cross into). */
.breadcrumb-home > * {
pointer-events: none;
}
</style>
@@ -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}
<EmptyState icon="exclamation-circle" title={error} error />
{:else if loading && isEmpty}
{:else if renderSkeleton}
<!-- Only renders after the 150 ms delay elapses AND we're still
loading with no items — fast loads skip this entirely. -->
<SkeletonList count={SKELETON.length} />
{:else if isEmpty}
{:else if isEmpty && !loading}
<!-- Empty state gates on `!loading` (not just `isEmpty`) so
mid-load empty-content windows don't flash the "Folder is
empty" banner. Renders only when the fetch has definitively
completed with zero items. -->
<EmptyState
icon={emptyIcon}
title={emptyText ?? t('common.empty', 'Nothing here yet.')}
+14 -2
View File
@@ -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 {
@@ -571,9 +571,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 +594,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;
}
}
@@ -1391,10 +1403,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;
}
+99 -128
View File
@@ -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<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(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<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
@@ -219,24 +221,6 @@
}
}
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// 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<typeof setTimeout> | 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 `<FolderBreadcrumb>`'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<string | null>(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<string | null>(null);
// Per-crumb drop highlight state lived here until the breadcrumb
// migrated to the shared `<FolderBreadcrumb>` 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()}
<nav class="breadcrumb" aria-label="Breadcrumb">
<!-- Persistent home link → the root listing (bare /files canonicalizes to
the user's drive root). `buildCrumbs` returns only the path folders,
so this is the single always-present "go home" affordance. Both the
home link and every crumb accept row drops via the same
`application/x-oxi-item` MIME the item-drag uses. The
`.drop-target` class visually highlights the crumb during a
hover-over so the user sees WHICH crumb the drop will land on. -->
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
class:drop-target={dropCrumbId === CRUMB_HOME_ID}
title={t('breadcrumb.home', 'Home')}
data-testid="files-breadcrumb-home-link"
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondragenter={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = CRUMB_HOME_ID;
}}
ondragleave={() => {
if (dropCrumbId === CRUMB_HOME_ID) dropCrumbId = null;
}}
ondrop={(e) => {
dropCrumbId = null;
if (session.homeFolderId) onCrumbDrop(e, session.homeFolderId);
}}
>
<Icon name={rootIcon} />
</a>
{#each crumbs as c, i (c.id)}
<span class="breadcrumb-separator">&gt;</span>
{#if i === crumbs.length - 1}
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
{:else}
<a
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
class="breadcrumb-item breadcrumb-link"
class:drop-target={dropCrumbId === c.id}
data-testid={`files-breadcrumb-${c.id}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondragenter={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = c.id;
}}
ondragleave={() => {
if (dropCrumbId === c.id) dropCrumbId = null;
}}
ondrop={(e) => {
dropCrumbId = null;
onCrumbDrop(e, c.id);
}}
>
{c.name}
</a>
{/if}
{/each}
</nav>
<!--
Shared component (2026-07-26 migration). Fetches the ancestor
chain in ONE round-trip via `GET /api/folders/{id}/ancestors`
(replaces the per-segment `buildCrumbs` walker + N `getFolder`
requests). Root icon is derived from `access_source.kind` on
the endpoint response — no more `drivesStore.findByRootFolderId`
lookup here.
`onDrop` prop preserves the row-drop-to-crumb behaviour: the
component handles the `dragover`/`dragenter`/`dragleave` UI +
`.drop-target` highlight; we get the target folder id + the
raw event and dispatch to `onCrumbDrop`.
-->
<FolderBreadcrumb
folderId={currentId}
onDrop={(target, e) => 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}`));
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
>
<button
+7
View File
@@ -61,6 +61,13 @@ vi.mock('$lib/api/endpoints/folders', () => ({
folderZipUrl: () => '/zip',
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined,
// Consumed by the new shared `<FolderBreadcrumb>` component that
// `/files` mounts. Return an empty chain so the breadcrumb renders
// nothing — tests here don't assert on breadcrumb content.
getFolderAncestors: vi.fn(async (id: string) => ({
ancestors: [{ id, name: id, parent_id: null, drive_id: 'test-drive' }],
access_source: { kind: 'drive' as const }
})),
invalidateFolderCache: vi.fn(),
moveFolder: vi.fn(),
rememberFolderName: vi.fn(),
+21 -60
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import EmptyState from '$lib/components/EmptyState.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, {
isFile,
type ContextAction,
@@ -11,7 +12,7 @@
import { page } from '$app/state';
import { searchResources } from '$lib/api/endpoints/search';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder, getFolder, getFolderName } from '$lib/api/endpoints/folders';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import {
addFavorite,
removeFavorite,
@@ -47,36 +48,14 @@
// this session (the pre-URL-param behaviour).
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
// Breadcrumb — resolves the scope folder's display name so the sticky
// header can show WHICH directory the results come from ("we have no
// clue on which directory the search was done" — Ed 2026-07-26).
// `getFolderName` is a sync cache peek populated by prior /files
// listings; on a cold /search deep-link we fall back to `getFolder`
// once, cache the result, and re-render. `$state<string | null>`
// with a `$effect` primer avoids blocking the initial render.
let scopeFolderName = $state<string | null>(null);
$effect(() => {
if (!scopeFolderId) {
scopeFolderName = null;
return;
}
const cached = getFolderName(scopeFolderId);
if (cached) {
scopeFolderName = cached;
return;
}
// Cold deep-link — fire once, populate on resolve. If it fails
// (folder was deleted, caller lost Read), keep name null so the
// breadcrumb just falls back to a short UUID.
const id = scopeFolderId;
void getFolder(id)
.then((f) => {
if (scopeFolderId === id) scopeFolderName = f.name;
})
.catch(() => {
if (scopeFolderId === id) scopeFolderName = id.slice(0, 8);
});
});
// Breadcrumb rendering is delegated to the shared `<FolderBreadcrumb>`
// component (2026-07-26 migration). It consumes
// `GET /api/folders/{id}/ancestors` and renders the full parent chain
// with the access-source-appropriate root icon (drive / share / link).
// The per-name resolver that used to live here (`getFolder`/`getFolderName`
// on the scope folder) is retired — the ancestors endpoint returns the
// whole chain in one round-trip, and its inflight-dedup map means the
// component's fetch reuses whatever other pages have already primed.
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
// query time / result count into the title string because ResourceList
@@ -756,37 +735,19 @@
{/snippet}
{#snippet breadcrumb()}
<!--
Only render when the search is folder-scoped AND the URL
param is present — the sticky "Home > Photos" cue answers
the "which directory was this search done in?" question
Ed raised 2026-07-26. Hidden for scope='all' (searching
everywhere → no folder to breadcrumb) and for a fresh
`/search?q=…` with no `in=` param.
Single-segment for now (Home icon + scope folder as a
link). Full parent-chain walk is a follow-up; it needs
stepping through `parent_id` via `getFolder`, which
would be a second pass here.
Shared component (same one `/files` uses). Renders only when
the search is folder-scoped AND the URL carries `?in=<uuid>`
— in "Everywhere" mode `folderId={null}` and the component
collapses to empty. Root icon + tooltip come from the
ancestors endpoint's `access_source`, so a `/search?in=<X>`
where X sits inside a shared drive automatically shows the
`[users]` chip + drive name, and a share-boundary scope
shows `[share-alt]` + a "Shared with you" link back to
/shared-with-me. No `onDrop` — /search doesn't accept row
drops into folders.
-->
{#if scope === 'folder' && scopeFolderId}
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
data-testid="search-breadcrumb-home-link"
>
<Icon name="home" />
</a>
<span class="breadcrumb-separator">&gt;</span>
<a
href={resolve(`/files/${scopeFolderId}`)}
class="breadcrumb-item breadcrumb-current breadcrumb-link"
data-testid="search-breadcrumb-folder-link"
>
{scopeFolderName ?? '…'}
</a>
</nav>
<FolderBreadcrumb folderId={scopeFolderId} />
{/if}
{/snippet}
{#snippet itemActions(item)}