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;
}