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)}
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
},
"breadcrumb": {
"home": "الرئيسية"
"home": "الرئيسية",
"aria": "مسار التنقل",
"root": {
"personal_drive": "قرص شخصي: {{name}}",
"shared_drive": "قرص مشترك: {{name}}",
"direct_share": "تمت مشاركته معك",
"token": "عبر رابط مشترك"
}
},
"trash": {
"empty_trash": "تفريغ سلة المهملات",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
},
"breadcrumb": {
"home": "Startseite"
"home": "Startseite",
"aria": "Brotkrumen",
"root": {
"personal_drive": "Persönliches Laufwerk: {{name}}",
"shared_drive": "Geteiltes Laufwerk: {{name}}",
"direct_share": "Für Sie freigegeben",
"token": "Über freigegebenen Link"
}
},
"trash": {
"empty_trash": "Papierkorb leeren",
+8 -1
View File
@@ -605,7 +605,14 @@
"forbidden": "Could not load files"
},
"breadcrumb": {
"home": "Home"
"home": "Home",
"aria": "Breadcrumb",
"root": {
"personal_drive": "Personal drive: {{name}}",
"shared_drive": "Shared drive: {{name}}",
"direct_share": "Shared with you",
"token": "Via shared link"
}
},
"trash": {
"empty_trash": "Empty Trash",
+8 -1
View File
@@ -464,7 +464,14 @@
"group_name_taken": "Ya existe un grupo con este nombre."
},
"breadcrumb": {
"home": "Inicio"
"home": "Inicio",
"aria": "Ruta de navegación",
"root": {
"personal_drive": "Unidad personal: {{name}}",
"shared_drive": "Unidad compartida: {{name}}",
"direct_share": "Compartido contigo",
"token": "A través de enlace compartido"
}
},
"trash": {
"empty_trash": "Vaciar papelera",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "گروهی با این نام پیش‌از این وجود دارد."
},
"breadcrumb": {
"home": "صفحه اصلی"
"home": "صفحه اصلی",
"aria": "مسیر ناوبری",
"root": {
"personal_drive": "درایو شخصی: {{name}}",
"shared_drive": "درایو اشتراکی: {{name}}",
"direct_share": "با شما به اشتراک گذاشته شده",
"token": "از طریق پیوند اشتراکی"
}
},
"trash": {
"empty_trash": "خالی کردن سطل زباله",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un groupe portant ce nom existe déjà."
},
"breadcrumb": {
"home": "Accueil"
"home": "Accueil",
"aria": "Fil d’Ariane",
"root": {
"personal_drive": "Disque personnel : {{name}}",
"shared_drive": "Disque partagé : {{name}}",
"direct_share": "Partagé avec vous",
"token": "Via un lien partagé"
}
},
"trash": {
"empty_trash": "Vider la corbeille",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
},
"breadcrumb": {
"home": "होम"
"home": "होम",
"aria": "ब्रेडक्रम्ब",
"root": {
"personal_drive": "निजी ड्राइव: {{name}}",
"shared_drive": "साझा ड्राइव: {{name}}",
"direct_share": "आपके साथ साझा किया गया",
"token": "साझा लिंक के माध्यम से"
}
},
"trash": {
"empty_trash": "रद्दी खाली करें",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un gruppo con questo nome esiste già."
},
"breadcrumb": {
"home": "Home"
"home": "Home",
"aria": "Percorso di navigazione",
"root": {
"personal_drive": "Unità personale: {{name}}",
"shared_drive": "Unità condivisa: {{name}}",
"direct_share": "Condiviso con te",
"token": "Tramite link condiviso"
}
},
"trash": {
"empty_trash": "Svuota il cestino",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "この名前のグループはすでに存在します。"
},
"breadcrumb": {
"home": "ホーム"
"home": "ホーム",
"aria": "パンくずリスト",
"root": {
"personal_drive": "個人ドライブ: {{name}}",
"shared_drive": "共有ドライブ: {{name}}",
"direct_share": "あなたに共有されました",
"token": "共有リンク経由"
}
},
"trash": {
"empty_trash": "ゴミ箱を空にする",
+8 -1
View File
@@ -570,7 +570,14 @@
"forbidden": "파일을 불러올 수 없습니다"
},
"breadcrumb": {
"home": "홈"
"home": "홈",
"aria": "탐색 경로",
"root": {
"personal_drive": "개인 드라이브: {{name}}",
"shared_drive": "공유 드라이브: {{name}}",
"direct_share": "내게 공유됨",
"token": "공유 링크로"
}
},
"trash": {
"empty_trash": "휴지통 비우기",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Er bestaat al een groep met deze naam."
},
"breadcrumb": {
"home": "Start"
"home": "Start",
"aria": "Kruimelpad",
"root": {
"personal_drive": "Persoonlijke schijf: {{name}}",
"shared_drive": "Gedeelde schijf: {{name}}",
"direct_share": "Met u gedeeld",
"token": "Via gedeelde link"
}
},
"trash": {
"empty_trash": "Prullenbak legen",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Grupa o tej nazwie już istnieje."
},
"breadcrumb": {
"home": "Strona główna"
"home": "Strona główna",
"aria": "Ścieżka nawigacji",
"root": {
"personal_drive": "Dysk osobisty: {{name}}",
"shared_drive": "Dysk współdzielony: {{name}}",
"direct_share": "Udostępniono Tobie",
"token": "Przez udostępniony link"
}
},
"trash": {
"empty_trash": "Opróżnij kosz",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Já existe um grupo com este nome."
},
"breadcrumb": {
"home": "Início"
"home": "Início",
"aria": "Trilha de navegação",
"root": {
"personal_drive": "Unidade pessoal: {{name}}",
"shared_drive": "Unidade compartilhada: {{name}}",
"direct_share": "Compartilhado com você",
"token": "Via link compartilhado"
}
},
"trash": {
"empty_trash": "Esvaziar lixeira",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Группа с таким именем уже существует."
},
"breadcrumb": {
"home": "Главная"
"home": "Главная",
"aria": "Хлебные крошки",
"root": {
"personal_drive": "Личный диск: {{name}}",
"shared_drive": "Общий диск: {{name}}",
"direct_share": "Поделено с вами",
"token": "Через общую ссылку"
}
},
"trash": {
"empty_trash": "Очистить корзину",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "已存在同名群組。"
},
"breadcrumb": {
"home": "主頁"
"home": "主頁",
"aria": "導覽路徑",
"root": {
"personal_drive": "個人雲端硬碟:{{name}}",
"shared_drive": "共用雲端硬碟:{{name}}",
"direct_share": "已分享給您",
"token": "透過分享連結"
}
},
"trash": {
"empty_trash": "清空回收站",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "同名组已存在。"
},
"breadcrumb": {
"home": "主页"
"home": "主页",
"aria": "面包屑",
"root": {
"personal_drive": "个人云盘:{{name}}",
"shared_drive": "共享云盘:{{name}}",
"direct_share": "已共享给您",
"token": "通过共享链接"
}
},
"trash": {
"empty_trash": "清空回收站",
+105
View File
@@ -368,3 +368,108 @@ pub struct FolderResourceItemDto {
/// Response envelope for `GET /api/folders/{id}/resources`.
pub type FolderResourcesDto = CursorListResponse<FolderResourceItemDto>;
// ═══════════════════════════════════════════════════════════════════════════
// Folder ancestor chain (`GET /api/folders/{id}/ancestors`)
// ═══════════════════════════════════════════════════════════════════════════
//
// Serves the shared breadcrumb component on `/files` (and, when re-wired,
// `/search`). One round-trip returns the whole caller-visible parent chain
// plus an `access_source` describing HOW the caller reached the topmost
// accessible ancestor (own drive / shared drive / direct folder share).
// See docs/plan/… — added 2026-07-26.
/// Single crumb in the walk from the drive root (or share-boundary) down
/// to the leaf. Present only for ancestors the caller has Read on; the
/// walk stops at the first inaccessible parent.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FolderAncestorDto {
pub id: Uuid,
pub name: String,
/// `None` on the drive-root folder. On boundary crumbs it's the id
/// of the (invisible-to-caller) parent — clients don't render it
/// but the field is preserved for debugging.
pub parent_id: Option<Uuid>,
/// Drive the folder belongs to. Always populated (every folder has
/// a drive_id in the D0+ schema). Lets clients derive the current
/// drive from `ancestors.at(-1).drive_id` without a second
/// `GET /api/folders/{id}` round-trip — the ancestors response is
/// the authoritative "everything I need for the folder-context
/// header" call. See 2026-07-26 UX pass on /files load traffic.
pub drive_id: Uuid,
}
/// How the caller reached the topmost accessible ancestor. Drives the
/// breadcrumb's root icon + tooltip.
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AccessSourceKind {
/// Caller reached the topmost ancestor via drive membership (own
/// personal drive OR a shared drive they are a member of). The
/// `drive` field carries the drive info; render its `kind`-specific
/// icon + name.
Drive,
/// Caller reached the topmost ancestor via a direct folder-level
/// `role_grants` row (share). No drive-membership Read on any
/// ancestor. The `subject` field (if known) says who was granted
/// (self or a group); render the share icon.
DirectShare,
/// Reserved for public/token access. Not emitted by the MVP
/// endpoint — no live UI code path drives an authenticated /files
/// request via token yet.
#[allow(dead_code)]
Token,
}
/// Drive info for `AccessSourceKind::Drive`. Split out so serde can drop
/// it (`skip_serializing_if = "Option::is_none"`) when the kind isn't drive.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceDriveDto {
pub id: Uuid,
pub name: String,
pub kind: crate::application::dtos::drive_dto::DriveKindDto,
}
/// Access-source detail returned alongside the ancestors chain.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceDto {
pub kind: AccessSourceKind,
/// Populated when `kind == Drive`. Null otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
pub drive: Option<AccessSourceDriveDto>,
/// Populated when a `role_grants` row identifies the grantee (self
/// or a group). MVP leaves this null — subject enrichment (grantor
/// name / group name lookup) is a follow-up. Once populated the FE
/// tooltip becomes "shared with **your team**" / "shared with **you
/// by X**" instead of the generic "shared with you".
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<AccessSourceSubjectDto>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AccessSourceSubjectKind {
User,
Group,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceSubjectDto {
pub kind: AccessSourceSubjectKind,
pub id: Uuid,
/// Display name (username / group name). MVP leaves this out — the
/// endpoint returns `subject: None` entirely rather than emitting a
/// half-populated `{id, name: null}`.
pub name: Option<String>,
}
/// Response envelope for `GET /api/folders/{id}/ancestors`.
///
/// `ancestors` is root-first (drive root or share boundary as element
/// 0), leaf-last. Length ≥ 1 (the leaf itself is always included).
/// `access_source` describes the boundary at element 0.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FolderAncestorsDto {
pub ancestors: Vec<FolderAncestorDto>,
pub access_source: AccessSourceDto,
}
+118 -1
View File
@@ -1,6 +1,8 @@
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::drive_dto::DriveKindDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
MoveFolderDto, RenameFolderDto,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
@@ -1004,6 +1006,121 @@ fn cross_boundary_move_err() -> DomainError {
// ── FolderService — cursor-paginated resource listing ────────────────────────
impl FolderService {
/// Ancestor chain for the shared breadcrumb component. Returns the
/// list of folders from the caller-visible root (drive root or
/// share boundary) down to the leaf, plus an `access_source`
/// describing HOW the caller reached that topmost ancestor.
///
/// AuthZ: requires `Read` on the leaf. Anti-enum via `NotFound` on
/// denial (the `require` helper turns denials into 404 to match
/// listing endpoints — same pattern used by `get_folder_with_perms`).
///
/// Boundary detection: the recursive SQL walks all the way to the
/// drive root and reports two Read predicates per ancestor
/// (`has_folder_grant`, `has_drive_grant`). We drop ancestors that
/// have NEITHER — that's a folder the caller can't Read, which by
/// definition means everything above it is also invisible to them.
/// The last surviving ancestor is the "root of this caller's view."
///
/// Access-source kind: `Drive` when the topmost accessible ancestor's
/// Read came (even in part) from drive-membership; `DirectShare`
/// otherwise. `Token` is reserved for future public-link callers.
/// Subject enrichment (grantor / group name) is deferred — MVP
/// returns `subject: None` and the FE renders a generic tooltip.
pub async fn get_ancestors_with_perms(
&self,
leaf_id: &str,
caller_id: Uuid,
) -> Result<FolderAncestorsDto, DomainError> {
// Gate: caller must have Read on the leaf. Denial → 404 (anti-enum).
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
Self::folder_resource(leaf_id)?,
)
.await?;
let leaf_uuid =
Uuid::parse_str(leaf_id).map_err(|_| DomainError::not_found("Folder", leaf_id))?;
let mut rows = self
.folder_storage
.fetch_ancestor_walk(caller_id, leaf_uuid)
.await?;
if rows.is_empty() {
return Err(DomainError::not_found("Folder", leaf_id));
}
// Repo returns root-first (ORDER BY depth DESC). Walk from index 0
// (topmost) and drop entries with NO Read grant — that's the
// share/drive boundary, everything above is invisible.
let boundary = rows
.iter()
.position(|r| r.has_folder_grant || r.has_drive_grant)
.unwrap_or(rows.len());
rows.drain(..boundary);
if rows.is_empty() {
// Shouldn't happen: `authz.require(Read, leaf)` above passed,
// so at least the leaf must have some Read source. Defensive
// 404 rather than emit an empty chain.
return Err(DomainError::not_found("Folder", leaf_id));
}
// The topmost surviving row is the root of the caller's view.
// Its grant profile drives `AccessSource`.
let top = &rows[0];
let access_source = if top.has_drive_grant {
// Drive-membership Read — even if a direct folder grant also
// exists, the drive channel is the more useful "how did I
// get here" signal (it names the drive the caller sees in
// their picker). Fetch the drive header for id/name/kind.
// `.map` (not `match`) — the drive-vanished-mid-query fallback
// is a straight `None`, no side effects; clippy's manual_map
// lint prefers this shape.
let drive = self
.folder_storage
.fetch_drive_header(top.drive_id)
.await?
.map(|(id, name, kind_str)| AccessSourceDriveDto {
id,
name,
kind: match kind_str.as_str() {
"personal" => DriveKindDto::Personal,
_ => DriveKindDto::Shared,
},
});
AccessSourceDto {
kind: AccessSourceKind::Drive,
drive,
subject: None,
}
} else {
// Direct folder-level grant (share). Subject enrichment is a
// follow-up (see the DTO comment) — MVP surfaces the kind and
// lets the FE render a generic "shared with you" tooltip.
AccessSourceDto {
kind: AccessSourceKind::DirectShare,
drive: None,
subject: None,
}
};
let ancestors = rows
.into_iter()
.map(|r| FolderAncestorDto {
id: r.id,
name: r.name,
parent_id: r.parent_id,
drive_id: r.drive_id,
})
.collect();
Ok(FolderAncestorsDto {
ancestors,
access_source,
})
}
/// Cursor-paginated listing of sub-folders **and** files inside `parent_id`.
///
/// Enforces `Permission::Read` on the parent folder before querying.
@@ -91,6 +91,24 @@ fn build_folders_with_flags(
Ok((folders, flags))
}
/// Row projected by `fetch_ancestor_walk`. One per folder in the
/// leaf→root walk (root order is reversed to root-first by the caller).
/// `has_folder_grant` = caller has a `role_grants` row on THIS folder;
/// `has_drive_grant` = caller has drive-membership on the containing drive.
/// Either grant satisfies Read; the split lets the service pick the right
/// `AccessSource` kind (`Drive` vs `DirectShare`).
#[derive(Debug, sqlx::FromRow)]
pub struct AncestorRow {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub drive_id: Uuid,
#[allow(dead_code)]
pub depth: i32,
pub has_folder_grant: bool,
pub has_drive_grant: bool,
}
/// Type alias for paginated folder rows (includes total_count as
/// the last element after the §14 provenance columns). Same
/// column set as [`FolderRow`] plus the trailing count.
@@ -1467,6 +1485,94 @@ impl FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
}
/// Raw ancestor row returned by the recursive walk. `depth` is 0 at
/// the leaf, growing as we move up. `has_drive_grant` / `has_folder_grant`
/// are the two Read predicates the service uses to identify the
/// share/drive boundary and choose the access-source kind.
pub async fn fetch_ancestor_walk(
&self,
caller_id: Uuid,
leaf_id: Uuid,
) -> Result<Vec<AncestorRow>, DomainError> {
// Recursive CTE walks `parent_id` from the leaf upward. Group ids
// are hoisted into a one-row CTE so `caller_group_ids($1)` fires
// once per query instead of per ancestor row (perf: the function
// is `RECURSIVE` and non-trivial). Grant EXISTS are unions over
// user + group subjects; the drive-grant subquery matches the
// ambient `CALLER_CAN_READ_DRIVE` predicate used elsewhere so
// access decisions stay consistent across the repo.
let sql = r#"
WITH RECURSIVE
groups AS (
SELECT ARRAY(SELECT storage.caller_group_ids($1)) AS ids
),
chain AS (
SELECT id, name, parent_id, drive_id, 0::int AS depth
FROM storage.folders WHERE id = $2::uuid
UNION ALL
SELECT f.id, f.name, f.parent_id, f.drive_id, c.depth + 1
FROM storage.folders f
JOIN chain c ON f.id = c.parent_id
WHERE c.parent_id IS NOT NULL
AND c.depth < 64
)
SELECT
c.id,
c.name,
c.parent_id,
c.drive_id,
c.depth,
EXISTS (
SELECT 1 FROM storage.role_grants g, groups
WHERE g.resource_type = 'folder'
AND g.resource_id = c.id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND ((g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
) AS has_folder_grant,
EXISTS (
SELECT 1 FROM storage.role_grants g, groups
WHERE g.resource_type = 'drive'
AND g.resource_id = c.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND ((g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
) AS has_drive_grant
FROM chain c
ORDER BY c.depth DESC
"#;
sqlx::query_as::<_, AncestorRow>(sql)
.bind(caller_id)
.bind(leaf_id)
.fetch_all(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
}
/// Drive header (`id + name + kind`) for the drive-source arm of
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
/// proved drive-membership via the ancestor walk before invoking.
///
/// Drive name lives on the drive's root folder, not the drive row
/// itself (`docs/plan/drive.md §3`). The JOIN resolves it; a drive
/// with a NULL `root_folder_id` returns None (backfill invariant
/// violation — surfaced as "drive vanished mid-query" in the caller).
pub async fn fetch_drive_header(
&self,
drive_id: Uuid,
) -> Result<Option<(Uuid, String, String)>, DomainError> {
sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT d.id, fo.name, d.kind::text \
FROM storage.drives d \
JOIN storage.folders fo ON fo.id = d.root_folder_id \
WHERE d.id = $1::uuid",
)
.bind(drive_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive header lookup: {e}")))
}
/// Cursor-paginated combined listing of sub-folders and files inside
/// `parent_id`, sorted by `order_by`.
///
+36 -2
View File
@@ -12,8 +12,8 @@ use crate::application::dtos::display_helpers::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
CreateFolderDto, FolderAncestorsDto, FolderDto, FolderResourceItemDto, FolderResourcesDto,
FolderResourcesQuery, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry;
@@ -112,6 +112,21 @@ impl FolderHandler {
}
}
/// `GET /api/folders/{id}/ancestors` — parent-chain + access-source
/// for the shared breadcrumb component. See `FolderAncestorsDto`
/// for the response shape. Anti-enum via `NotFound` on Read denial.
pub(super) async fn get_folder_ancestors_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_ancestors_with_perms(&id, auth_user.id).await {
Ok(dto) => (StatusCode::OK, Json(dto)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
/// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure.
pub(super) async fn list_root_folders_impl(
@@ -367,6 +382,25 @@ pub async fn get_folder(
FolderHandler::get_folder_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders/{id}/ancestors",
params(("id" = String, Path, description = "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary.")),
responses(
(status = 200, description = "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). See `FolderAncestorsDto`.", body = FolderAncestorsDto),
(status = 404, description = "Folder not found or caller lacks Read (anti-enum)"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn get_folder_ancestors(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
FolderHandler::get_folder_ancestors_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders",
+12 -1
View File
@@ -20,7 +20,9 @@ use crate::application::dtos::favorites_dto::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
@@ -96,6 +98,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// Folder handlers (free functions — see folder_handler.rs for why)
handlers::folder_handler::create_folder,
handlers::folder_handler::get_folder,
handlers::folder_handler::get_folder_ancestors,
handlers::folder_handler::list_root_folders,
handlers::folder_handler::list_folder_resources,
handlers::folder_handler::rename_folder,
@@ -264,6 +267,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
FolderListingDto,
FolderResourceItemDto,
ResourceContentDto,
// Folder ancestor chain (breadcrumb endpoint)
FolderAncestorsDto,
FolderAncestorDto,
AccessSourceDto,
AccessSourceKind,
AccessSourceDriveDto,
AccessSourceSubjectDto,
AccessSourceSubjectKind,
// File schemas
FileDto,
// Delta-upload schemas
+6 -1
View File
@@ -82,7 +82,7 @@ use crate::interfaces::api::handlers::file_handler::{
list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
};
use crate::interfaces::api::handlers::folder_handler::{
create_folder, delete_folder_with_trash, download_folder_zip, get_folder,
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, get_folder_ancestors,
list_folder_resources, list_root_folders, move_folder, rename_folder,
};
use crate::interfaces::api::handlers::i18n_handler::{
@@ -218,6 +218,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
// Ancestor chain for the shared breadcrumb component — one
// round-trip vs the pre-2026-07-26 per-segment `getFolder` walk
// on the /files client. Returns caller-visible parents (walk
// stops at share/drive-membership boundary) + access_source.
.route("/{id}/ancestors", get(get_folder_ancestors))
.route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder))
.with_state(app_state.clone());
+192
View File
@@ -0,0 +1,192 @@
# =============================================================
# OxiCloud — GET /api/folders/{id}/ancestors
# =============================================================
# Pins the shared-breadcrumb endpoint. Coverage:
# 1. Own personal drive: leaf returns full chain [root, sub, leaf]
# with access_source.kind = "drive" + drive info.
# 2. Drive-root leaf: chain has a single element (the root itself).
# 3. Anti-enum: unknown UUID / no-Read → 404 (not 403).
# 4. Cross-user: ancestors_stranger can't read admin's folder → 404.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login + ancestors_stranger provisioning
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
admin_home_id: jsonpath "$[0].id"
# Anti-enum registration: 200 whether ancestors_stranger existed or not.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "ancestors_stranger",
"email": "ancestors_stranger@example.com",
"password": "BobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "ancestors_stranger", "password": "BobPassword1!" }
HTTP 200
[Captures]
ancestors_stranger_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — Create a small tree under admin's home:
# home > ancestors-test > child > grandchild
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "ancestors-test", "parent_id": "{{admin_home_id}}" }
HTTP 201
[Captures]
mid_folder_id: jsonpath "$.id"
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "child", "parent_id": "{{mid_folder_id}}" }
HTTP 201
[Captures]
child_folder_id: jsonpath "$.id"
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "grandchild", "parent_id": "{{child_folder_id}}" }
HTTP 201
[Captures]
leaf_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Ancestors on the deepest leaf.
# Chain must be root → mid → child → grandchild.
# access_source.kind = "drive" (admin owns the personal drive).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{leaf_folder_id}}/ancestors
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ancestors" count == 4
jsonpath "$.ancestors[0].id" == "{{admin_home_id}}"
jsonpath "$.ancestors[0].parent_id" == null
# Every ancestor carries drive_id (post-2026-07-26 addition) so
# /files can derive the current drive without an extra `getFolder`.
# Folders in one chain share a drive; checking `isString` on the
# leaf is enough — Hurl can't cleanly assert field equality across
# path indices.
jsonpath "$.ancestors[0].drive_id" isString
jsonpath "$.ancestors[3].drive_id" isString
jsonpath "$.ancestors[1].id" == "{{mid_folder_id}}"
jsonpath "$.ancestors[1].name" == "ancestors-test"
jsonpath "$.ancestors[1].parent_id" == "{{admin_home_id}}"
jsonpath "$.ancestors[2].id" == "{{child_folder_id}}"
jsonpath "$.ancestors[2].name" == "child"
jsonpath "$.ancestors[3].id" == "{{leaf_folder_id}}"
jsonpath "$.ancestors[3].name" == "grandchild"
jsonpath "$.access_source.kind" == "drive"
jsonpath "$.access_source.drive.id" isString
jsonpath "$.access_source.drive.name" isString
jsonpath "$.access_source.drive.kind" == "personal"
# Subject enrichment (2026-07-27): field carries the SHARER
# (`role_grants.granted_by`), not the grantee. On admin's own personal
# drive the drive grant is self-seeded with `granted_by = admin`, so
# the assertion still resolves to `{{username}}` — but the semantic is
# "who shared this?" and would surface a different name on a folder
# shared with admin by someone else.
jsonpath "$.access_source.subject.kind" == "user"
jsonpath "$.access_source.subject.id" isString
jsonpath "$.access_source.subject.name" == "{{username}}"
# Caller's role via the boundary grant (2026-07-27) — piggybacked on
# the same `role_grants` row that carries `granted_by`. Personal
# drive owner grant is `owner`.
jsonpath "$.access_source.caller_role" == "owner"
# ─────────────────────────────────────────────────────────────
# Step 3 — Drive-root leaf. Chain is one element (the root).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{admin_home_id}}/ancestors
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ancestors" count == 1
jsonpath "$.ancestors[0].id" == "{{admin_home_id}}"
jsonpath "$.ancestors[0].parent_id" == null
jsonpath "$.access_source.kind" == "drive"
# ─────────────────────────────────────────────────────────────
# Step 4 — Anti-enum: unknown-UUID and cross-user access both
# return 404 (never 403). A well-formed UUID that
# doesn't exist and a real folder the caller can't
# Read produce the same shape — attackers can't
# distinguish "no such folder" from "not yours."
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/00000000-0000-0000-0000-000000000000/ancestors
Authorization: Bearer {{admin_token}}
HTTP 404
GET {{base_url}}/api/folders/{{leaf_folder_id}}/ancestors
Authorization: Bearer {{ancestors_stranger_token}}
HTTP 404
# Middleware-level 401 (no auth) is deliberately NOT tested here.
# Prior login steps in this file leave Hurl's cookie jar populated,
# so an omitted `Authorization:` header still authenticates via cookie
# and lands on the handler — which returns the endpoint's anti-enum
# 404 rather than the middleware 401. The middleware 401 case is
# pinned separately at the TOP of `search_basic.hurl`, before any
# login has run.
# ─────────────────────────────────────────────────────────────
# Step 5 — Teardown: recursive delete of the top folder takes
# the whole subtree. `DELETE /api/folders/{id}` is a
# soft-delete-to-trash — every downstream test that
# expects an empty trash (`trash.hurl`, `trash_resources.hurl`,
# …) would find our orphan. Follow up with `empty` so
# the trash returns to its clean baseline.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{mid_folder_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/trash/empty
Authorization: Bearer {{admin_token}}
HTTP 200
+1
View File
@@ -157,6 +157,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/nc_ocs_user_info.hurl" \
"$API_DIR/nc_avatar_preview.hurl" \
"$API_DIR/files-folders.hurl" \
"$API_DIR/folder_ancestors.hurl" \
"$API_DIR/photos_etag.hurl" \
"$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \