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