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. */ /** Folder endpoints — ported from filesModel.js + fileOperations.js. */
import { apiFetch, apiJson } from '$lib/api/client'; import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; 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 JSON_HEADERS = { 'Content-Type': 'application/json' };
const NO_CACHE: RequestInit = { const NO_CACHE: RequestInit = {
@@ -109,6 +109,35 @@ export function getFolder(id: string): Promise<FolderItem> {
return request; 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`. */ /** One page of `/api/folders/{id}/resources`. */
export interface FolderPage { export interface FolderPage {
/** /**
+65
View File
@@ -413,3 +413,68 @@ export interface DriveMember {
granted_at: string; granted_at: string;
expires_at?: string | null; 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]; 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 ────────────────────────────────────────────────── // ── Group-by / direction ──────────────────────────────────────────────────
const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy)); const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy));
@@ -1319,9 +1346,15 @@
{#if error} {#if error}
<EmptyState icon="exclamation-circle" title={error} 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} /> <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 <EmptyState
icon={emptyIcon} icon={emptyIcon}
title={emptyText ?? t('common.empty', 'Nothing here yet.')} title={emptyText ?? t('common.empty', 'Nothing here yet.')}
+14 -2
View File
@@ -21,6 +21,14 @@
.breadcrumb-link { .breadcrumb-link {
cursor: pointer; cursor: pointer;
color: var(--color-text-muted); 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 { .breadcrumb-link.drop-target {
@@ -29,15 +37,19 @@
} }
.breadcrumb-link:hover { .breadcrumb-link:hover {
text-decoration: underline;
color: var(--color-accent); color: var(--color-accent);
background: var(--color-accent-bg); 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 { .breadcrumb-current {
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
color: var(--color-text-black); color: var(--color-text-black);
cursor: default;
} }
.breadcrumb-separator { .breadcrumb-separator {
@@ -571,9 +571,21 @@
} }
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable /* 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 devices, so touch users (no hover) keep it always tappable. Applies
on keyboard focus within the row. Applies to both list and grid views to both list and grid views because both keep the kebab inside
because both keep the kebab inside `.action-cell`. */ `.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) { @media (hover: hover) {
.files-list-view .file-item .action-cell button.file-actions, .files-list-view .file-item .action-cell button.file-actions,
.files-grid-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: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: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; opacity: 1;
} }
} }
@@ -1391,10 +1403,15 @@
transition: opacity var(--motion-fast) var(--ease-standard); 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: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: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; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
+99 -128
View File
@@ -10,8 +10,7 @@
createFolder, createFolder,
deleteFolder, deleteFolder,
fetchFolderPage, fetchFolderPage,
getFolder, getFolderAncestors,
getFolderName,
invalidateFolderCache, invalidateFolderCache,
moveFolder, moveFolder,
rememberFolderName, rememberFolderName,
@@ -41,6 +40,7 @@
import { preferences } from '$lib/stores/preferences.svelte'; import { preferences } from '$lib/stores/preferences.svelte';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, { import ResourceList, {
isFile, isFile,
type GroupByDef as RLGroupByDef type GroupByDef as RLGroupByDef
@@ -48,7 +48,7 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.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 { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
@@ -67,26 +67,16 @@
// /files → home root; /files/a/b → folder b inside a inside home. // /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0)); 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. // The drive whose content the user is currently browsing.
// //
// Priorities (first match wins): // Priorities (first match wins):
// 1. `currentFolderDriveId` — set by `load()` after a `getFolder` // 1. `currentFolderDriveId` — set by `load()` from the ancestors
// fetch on the current folder. Authoritative for deep-links // response (`chain.ancestors.at(-1).drive_id`). Authoritative
// too (the URL's leading segment might not be a drive root). // 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 // 2. `listing.folders[0]?.drive_id` — fast-path when the folder
// has at least one subfolder; avoids the extra round-trip on // has at least one subfolder; avoids waiting on the ancestors
// the initial `applyListing` before `getFolder` returns. // response before the initial `applyListing`.
// (`FileDto` doesn't carry `drive_id` today, so we can't use // (`FileDto` doesn't carry `drive_id` today, so we can't use
// files as a fallback source; folders alone.) // files as a fallback source; folders alone.)
// 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy // 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy
@@ -156,11 +146,23 @@
const hiddenCount = $derived( const hiddenCount = $derived(
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0 preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
); );
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null); let currentId = $state<string | null>(null);
let loading = $state(false); // Default `true` (not `false`) so the first render — before the
// Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it. // `$effect` fires `load()` — shows the "loading" arm of ResourceList
let showSkeleton = $state(false); // (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 error = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false); 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 // 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. // writes state, so a fast navigation can't be clobbered by an older fetch.
let loadSeq = 0; let loadSeq = 0;
@@ -262,7 +246,6 @@
const seq = ++loadSeq; const seq = ++loadSeq;
let folderId: string; let folderId: string;
let skeletonTimer: ReturnType<typeof setTimeout> | undefined;
if (reset) { if (reset) {
// External users have no home folder; send them to shared-with-me. // External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) { if (session.isExternalUser && pathSegments.length === 0) {
@@ -294,32 +277,55 @@
currentId = folderId; currentId = folderId;
filesStore.currentFolder = folderId; filesStore.currentFolder = folderId;
// Reset paging state: previous folder's cursor is meaningless here, // Reset paging state: previous folder's cursor is meaningless
// and mixing its rows with the new folder's would flash a wrong list. // on the new folder — must clear or the first append would
pageCursor = undefined; // paginate the OLD folder's next-page slice.
listing = { folders: [], files: [] }; //
orderedItems = []; // `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; loading = true;
pageCursor = undefined;
// Delayed skeleton so fast loads don't flash it. // Legacy path-chain URLs canonicalize to the single-id form on
skeletonTimer = setTimeout(() => { // load. `/files/A/B/C` still resolves (router matches `[...path]`)
if (loading) showSkeleton = true; // but the URL bar and any subsequent bookmark reflects the
}, 100); // 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. // Resolve the current folder's drive_id via the ancestors
void buildCrumbs(pathSegments).then((trail) => { // response — every `FolderAncestor` carries `drive_id`, so
if (seq === loadSeq) crumbs = trail; // the shared `<FolderBreadcrumb>`'s in-flight call is the
}); // same round-trip we'd otherwise duplicate here. The
// `ancestorsInflight` dedup map inside `getFolderAncestors`
// Resolve the current folder's drive_id so the read-only banner // means this second caller gets the same promise, not a
// works even on deep-links into a sub-folder. Guarded by `seq`. // second HTTP request — the extra `getFolder(folderId)`
void getFolder(folderId) // that used to fire here is gone (2026-07-26 UX pass on
.then((folder) => { // /files load traffic).
if (seq === loadSeq) currentFolderDriveId = folder.drive_id; void getFolderAncestors(folderId)
.then((chain) => {
if (seq !== loadSeq) return;
const leaf = chain.ancestors.at(-1);
if (leaf) currentFolderDriveId = leaf.drive_id;
}) })
.catch(() => { .catch(() => {
// Fallback chain in `currentDrive` still gives us a // 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 { } else {
// Append path: reuse `currentId`. `pageCursor === undefined` means // Append path: reuse `currentId`. `pageCursor === undefined` means
@@ -360,10 +366,8 @@
? e.message ? e.message
: String(e); : String(e);
} finally { } finally {
if (skeletonTimer !== undefined) clearTimeout(skeletonTimer);
if (seq === loadSeq && reset) { if (seq === loadSeq && reset) {
loading = false; loading = false;
showSkeleton = false;
} }
} }
} }
@@ -421,7 +425,10 @@
} }
function openFolder(folder: FolderItem) { 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() { async function onNewFolder() {
@@ -1185,12 +1192,12 @@
// ── Drag-to-move ───────────────────────────────────────────────────────── // ── Drag-to-move ─────────────────────────────────────────────────────────
const DRAG_TYPE = 'application/x-oxi-item'; const DRAG_TYPE = 'application/x-oxi-item';
let dropFolderId = $state<string | null>(null); let dropFolderId = $state<string | null>(null);
// Highlighted breadcrumb crumb during an OxiCloud drag. Holds the // Per-crumb drop highlight state lived here until the breadcrumb
// crumb's folder id, or the sentinel `'__home__'` for the home link // migrated to the shared `<FolderBreadcrumb>` component (2026-07-26),
// (which doesn't have a stable folder id — depends on the caller's // which owns its own hover state. The `CRUMB_HOME_ID` sentinel is
// home folder resolution). // gone too — the shared component's root icon isn't a drop target
const CRUMB_HOME_ID = '__home__'; // (the drive root's ancestor is always the drive itself, and
let dropCrumbId = $state<string | null>(null); // dropping "at the drive" is ambiguous).
// Copy-vs-move on drop. // Copy-vs-move on drop.
// //
@@ -1871,7 +1878,7 @@
) )
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')} : t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined} emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
loading={showSkeleton} {loading}
error={error ?? undefined} error={error ?? undefined}
selectable selectable
shiftRangeSelect shiftRangeSelect
@@ -1923,61 +1930,24 @@
{/snippet} {/snippet}
{#snippet breadcrumb()} {#snippet breadcrumb()}
<nav class="breadcrumb" aria-label="Breadcrumb"> <!--
<!-- Persistent home link → the root listing (bare /files canonicalizes to Shared component (2026-07-26 migration). Fetches the ancestor
the user's drive root). `buildCrumbs` returns only the path folders, chain in ONE round-trip via `GET /api/folders/{id}/ancestors`
so this is the single always-present "go home" affordance. Both the (replaces the per-segment `buildCrumbs` walker + N `getFolder`
home link and every crumb accept row drops via the same requests). Root icon is derived from `access_source.kind` on
`application/x-oxi-item` MIME the item-drag uses. The the endpoint response — no more `drivesStore.findByRootFolderId`
`.drop-target` class visually highlights the crumb during a lookup here.
hover-over so the user sees WHICH crumb the drop will land on. -->
<a `onDrop` prop preserves the row-drop-to-crumb behaviour: the
href={resolve('/files')} component handles the `dragover`/`dragenter`/`dragleave` UI +
class="breadcrumb-item breadcrumb-home breadcrumb-link" `.drop-target` highlight; we get the target folder id + the
class:drop-target={dropCrumbId === CRUMB_HOME_ID} raw event and dispatch to `onCrumbDrop`.
title={t('breadcrumb.home', 'Home')} -->
data-testid="files-breadcrumb-home-link" <FolderBreadcrumb
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()} folderId={currentId}
ondragenter={(e) => { onDrop={(target, e) => onCrumbDrop(e, target)}
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = CRUMB_HOME_ID; dragMime={DRAG_TYPE}
}} />
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>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
@@ -2142,7 +2112,8 @@
onclick={() => { onclick={() => {
const id = ctxTarget!.id; const id = ctxTarget!.id;
closeContext(); 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 }}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
> >
<button <button
+7
View File
@@ -61,6 +61,13 @@ vi.mock('$lib/api/endpoints/folders', () => ({
folderZipUrl: () => '/zip', folderZipUrl: () => '/zip',
getFolder: vi.fn(async (id: string) => ({ id, name: id })), getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined, 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(), invalidateFolderCache: vi.fn(),
moveFolder: vi.fn(), moveFolder: vi.fn(),
rememberFolderName: vi.fn(), rememberFolderName: vi.fn(),
+21 -60
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, { import ResourceList, {
isFile, isFile,
type ContextAction, type ContextAction,
@@ -11,7 +12,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files'; 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 { import {
addFavorite, addFavorite,
removeFavorite, removeFavorite,
@@ -47,36 +48,14 @@
// this session (the pre-URL-param behaviour). // this session (the pre-URL-param behaviour).
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null); const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
// Breadcrumb — resolves the scope folder's display name so the sticky // Breadcrumb rendering is delegated to the shared `<FolderBreadcrumb>`
// header can show WHICH directory the results come from ("we have no // component (2026-07-26 migration). It consumes
// clue on which directory the search was done" — Ed 2026-07-26). // `GET /api/folders/{id}/ancestors` and renders the full parent chain
// `getFolderName` is a sync cache peek populated by prior /files // with the access-source-appropriate root icon (drive / share / link).
// listings; on a cold /search deep-link we fall back to `getFolder` // The per-name resolver that used to live here (`getFolder`/`getFolderName`
// once, cache the result, and re-render. `$state<string | null>` // on the scope folder) is retired — the ancestors endpoint returns the
// with a `$effect` primer avoids blocking the initial render. // whole chain in one round-trip, and its inflight-dedup map means the
let scopeFolderName = $state<string | null>(null); // component's fetch reuses whatever other pages have already primed.
$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);
});
});
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the // Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
// query time / result count into the title string because ResourceList // query time / result count into the title string because ResourceList
@@ -756,37 +735,19 @@
{/snippet} {/snippet}
{#snippet breadcrumb()} {#snippet breadcrumb()}
<!-- <!--
Only render when the search is folder-scoped AND the URL Shared component (same one `/files` uses). Renders only when
param is present — the sticky "Home > Photos" cue answers the search is folder-scoped AND the URL carries `?in=<uuid>`
the "which directory was this search done in?" question — in "Everywhere" mode `folderId={null}` and the component
Ed raised 2026-07-26. Hidden for scope='all' (searching collapses to empty. Root icon + tooltip come from the
everywhere → no folder to breadcrumb) and for a fresh ancestors endpoint's `access_source`, so a `/search?in=<X>`
`/search?q=…` with no `in=` param. where X sits inside a shared drive automatically shows the
`[users]` chip + drive name, and a share-boundary scope
Single-segment for now (Home icon + scope folder as a shows `[share-alt]` + a "Shared with you" link back to
link). Full parent-chain walk is a follow-up; it needs /shared-with-me. No `onDrop` — /search doesn't accept row
stepping through `parent_id` via `getFolder`, which drops into folders.
would be a second pass here.
--> -->
{#if scope === 'folder' && scopeFolderId} {#if scope === 'folder' && scopeFolderId}
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}> <FolderBreadcrumb folderId={scopeFolderId} />
<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>
{/if} {/if}
{/snippet} {/snippet}
{#snippet itemActions(item)} {#snippet itemActions(item)}
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم." "group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
}, },
"breadcrumb": { "breadcrumb": {
"home": "الرئيسية" "home": "الرئيسية",
"aria": "مسار التنقل",
"root": {
"personal_drive": "قرص شخصي: {{name}}",
"shared_drive": "قرص مشترك: {{name}}",
"direct_share": "تمت مشاركته معك",
"token": "عبر رابط مشترك"
}
}, },
"trash": { "trash": {
"empty_trash": "تفريغ سلة المهملات", "empty_trash": "تفريغ سلة المهملات",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits." "group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Papierkorb leeren", "empty_trash": "Papierkorb leeren",
+8 -1
View File
@@ -605,7 +605,14 @@
"forbidden": "Could not load files" "forbidden": "Could not load files"
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Empty Trash", "empty_trash": "Empty Trash",
+8 -1
View File
@@ -464,7 +464,14 @@
"group_name_taken": "Ya existe un grupo con este nombre." "group_name_taken": "Ya existe un grupo con este nombre."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Vaciar papelera", "empty_trash": "Vaciar papelera",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "گروهی با این نام پیش‌از این وجود دارد." "group_name_taken": "گروهی با این نام پیش‌از این وجود دارد."
}, },
"breadcrumb": { "breadcrumb": {
"home": "صفحه اصلی" "home": "صفحه اصلی",
"aria": "مسیر ناوبری",
"root": {
"personal_drive": "درایو شخصی: {{name}}",
"shared_drive": "درایو اشتراکی: {{name}}",
"direct_share": "با شما به اشتراک گذاشته شده",
"token": "از طریق پیوند اشتراکی"
}
}, },
"trash": { "trash": {
"empty_trash": "خالی کردن سطل زباله", "empty_trash": "خالی کردن سطل زباله",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un groupe portant ce nom existe déjà." "group_name_taken": "Un groupe portant ce nom existe déjà."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Vider la corbeille", "empty_trash": "Vider la corbeille",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।" "group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
}, },
"breadcrumb": { "breadcrumb": {
"home": "होम" "home": "होम",
"aria": "ब्रेडक्रम्ब",
"root": {
"personal_drive": "निजी ड्राइव: {{name}}",
"shared_drive": "साझा ड्राइव: {{name}}",
"direct_share": "आपके साथ साझा किया गया",
"token": "साझा लिंक के माध्यम से"
}
}, },
"trash": { "trash": {
"empty_trash": "रद्दी खाली करें", "empty_trash": "रद्दी खाली करें",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un gruppo con questo nome esiste già." "group_name_taken": "Un gruppo con questo nome esiste già."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Svuota il cestino", "empty_trash": "Svuota il cestino",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "この名前のグループはすでに存在します。" "group_name_taken": "この名前のグループはすでに存在します。"
}, },
"breadcrumb": { "breadcrumb": {
"home": "ホーム" "home": "ホーム",
"aria": "パンくずリスト",
"root": {
"personal_drive": "個人ドライブ: {{name}}",
"shared_drive": "共有ドライブ: {{name}}",
"direct_share": "あなたに共有されました",
"token": "共有リンク経由"
}
}, },
"trash": { "trash": {
"empty_trash": "ゴミ箱を空にする", "empty_trash": "ゴミ箱を空にする",
+8 -1
View File
@@ -570,7 +570,14 @@
"forbidden": "파일을 불러올 수 없습니다" "forbidden": "파일을 불러올 수 없습니다"
}, },
"breadcrumb": { "breadcrumb": {
"home": "홈" "home": "홈",
"aria": "탐색 경로",
"root": {
"personal_drive": "개인 드라이브: {{name}}",
"shared_drive": "공유 드라이브: {{name}}",
"direct_share": "내게 공유됨",
"token": "공유 링크로"
}
}, },
"trash": { "trash": {
"empty_trash": "휴지통 비우기", "empty_trash": "휴지통 비우기",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Er bestaat al een groep met deze naam." "group_name_taken": "Er bestaat al een groep met deze naam."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Prullenbak legen", "empty_trash": "Prullenbak legen",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Grupa o tej nazwie już istnieje." "group_name_taken": "Grupa o tej nazwie już istnieje."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Opróżnij kosz", "empty_trash": "Opróżnij kosz",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Já existe um grupo com este nome." "group_name_taken": "Já existe um grupo com este nome."
}, },
"breadcrumb": { "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": { "trash": {
"empty_trash": "Esvaziar lixeira", "empty_trash": "Esvaziar lixeira",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Группа с таким именем уже существует." "group_name_taken": "Группа с таким именем уже существует."
}, },
"breadcrumb": { "breadcrumb": {
"home": "Главная" "home": "Главная",
"aria": "Хлебные крошки",
"root": {
"personal_drive": "Личный диск: {{name}}",
"shared_drive": "Общий диск: {{name}}",
"direct_share": "Поделено с вами",
"token": "Через общую ссылку"
}
}, },
"trash": { "trash": {
"empty_trash": "Очистить корзину", "empty_trash": "Очистить корзину",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "已存在同名群組。" "group_name_taken": "已存在同名群組。"
}, },
"breadcrumb": { "breadcrumb": {
"home": "主頁" "home": "主頁",
"aria": "導覽路徑",
"root": {
"personal_drive": "個人雲端硬碟:{{name}}",
"shared_drive": "共用雲端硬碟:{{name}}",
"direct_share": "已分享給您",
"token": "透過分享連結"
}
}, },
"trash": { "trash": {
"empty_trash": "清空回收站", "empty_trash": "清空回收站",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "同名组已存在。" "group_name_taken": "同名组已存在。"
}, },
"breadcrumb": { "breadcrumb": {
"home": "主页" "home": "主页",
"aria": "面包屑",
"root": {
"personal_drive": "个人云盘:{{name}}",
"shared_drive": "共享云盘:{{name}}",
"direct_share": "已共享给您",
"token": "通过共享链接"
}
}, },
"trash": { "trash": {
"empty_trash": "清空回收站", "empty_trash": "清空回收站",
+105
View File
@@ -368,3 +368,108 @@ pub struct FolderResourceItemDto {
/// Response envelope for `GET /api/folders/{id}/resources`. /// Response envelope for `GET /api/folders/{id}/resources`.
pub type FolderResourcesDto = CursorListResponse<FolderResourceItemDto>; 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::cursor::PageCursor;
use crate::application::dtos::drive_dto::DriveKindDto;
use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions, AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
MoveFolderDto, RenameFolderDto, MoveFolderDto, RenameFolderDto,
}; };
use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::authorization_ports::AuthorizationEngine;
@@ -1004,6 +1006,121 @@ fn cross_boundary_move_err() -> DomainError {
// ── FolderService — cursor-paginated resource listing ──────────────────────── // ── FolderService — cursor-paginated resource listing ────────────────────────
impl FolderService { 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`. /// Cursor-paginated listing of sub-folders **and** files inside `parent_id`.
/// ///
/// Enforces `Permission::Read` on the parent folder before querying. /// Enforces `Permission::Read` on the parent folder before querying.
@@ -91,6 +91,24 @@ fn build_folders_with_flags(
Ok((folders, 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 /// Type alias for paginated folder rows (includes total_count as
/// the last element after the §14 provenance columns). Same /// the last element after the §14 provenance columns). Same
/// column set as [`FolderRow`] plus the trailing count. /// column set as [`FolderRow`] plus the trailing count.
@@ -1467,6 +1485,94 @@ impl FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", folder_id)) .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 /// Cursor-paginated combined listing of sub-folders and files inside
/// `parent_id`, sorted by `order_by`. /// `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::file_dto::FileDto;
use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery, CreateFolderDto, FolderAncestorsDto, FolderDto, FolderResourceItemDto, FolderResourcesDto,
ListResourcesOptions, MoveFolderDto, RenameFolderDto, FolderResourcesQuery, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
}; };
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry; 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. /// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure. /// Only returns folders owned by this user — no information disclosure.
pub(super) async fn list_root_folders_impl( 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 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( #[utoipa::path(
get, get,
path = "/api/folders", 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::file_dto::FileDto;
use crate::application::dtos::folder_dto::{ 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::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{ 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) // Folder handlers (free functions — see folder_handler.rs for why)
handlers::folder_handler::create_folder, handlers::folder_handler::create_folder,
handlers::folder_handler::get_folder, handlers::folder_handler::get_folder,
handlers::folder_handler::get_folder_ancestors,
handlers::folder_handler::list_root_folders, handlers::folder_handler::list_root_folders,
handlers::folder_handler::list_folder_resources, handlers::folder_handler::list_folder_resources,
handlers::folder_handler::rename_folder, handlers::folder_handler::rename_folder,
@@ -264,6 +267,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
FolderListingDto, FolderListingDto,
FolderResourceItemDto, FolderResourceItemDto,
ResourceContentDto, ResourceContentDto,
// Folder ancestor chain (breadcrumb endpoint)
FolderAncestorsDto,
FolderAncestorDto,
AccessSourceDto,
AccessSourceKind,
AccessSourceDriveDto,
AccessSourceSubjectDto,
AccessSourceSubjectKind,
// File schemas // File schemas
FileDto, FileDto,
// Delta-upload schemas // 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, list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
}; };
use crate::interfaces::api::handlers::folder_handler::{ 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, list_folder_resources, list_root_folders, move_folder, rename_folder,
}; };
use crate::interfaces::api::handlers::i18n_handler::{ 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() let folders_crud_router = Router::new()
.route("/", post(create_folder)) .route("/", post(create_folder))
.route("/{id}", get(get_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}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder)) .route("/{id}/move", put(move_folder))
.with_state(app_state.clone()); .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_ocs_user_info.hurl" \
"$API_DIR/nc_avatar_preview.hurl" \ "$API_DIR/nc_avatar_preview.hurl" \
"$API_DIR/files-folders.hurl" \ "$API_DIR/files-folders.hurl" \
"$API_DIR/folder_ancestors.hurl" \
"$API_DIR/photos_etag.hurl" \ "$API_DIR/photos_etag.hurl" \
"$API_DIR/favorites.hurl" \ "$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \ "$API_DIR/trash.hurl" \