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:
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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">></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.')}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">></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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">></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)}
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "الرئيسية"
|
||||
"home": "الرئيسية",
|
||||
"aria": "مسار التنقل",
|
||||
"root": {
|
||||
"personal_drive": "قرص شخصي: {{name}}",
|
||||
"shared_drive": "قرص مشترك: {{name}}",
|
||||
"direct_share": "تمت مشاركته معك",
|
||||
"token": "عبر رابط مشترك"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "تفريغ سلة المهملات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "گروهی با این نام پیشاز این وجود دارد."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "صفحه اصلی"
|
||||
"home": "صفحه اصلی",
|
||||
"aria": "مسیر ناوبری",
|
||||
"root": {
|
||||
"personal_drive": "درایو شخصی: {{name}}",
|
||||
"shared_drive": "درایو اشتراکی: {{name}}",
|
||||
"direct_share": "با شما به اشتراک گذاشته شده",
|
||||
"token": "از طریق پیوند اشتراکی"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "خالی کردن سطل زباله",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "होम"
|
||||
"home": "होम",
|
||||
"aria": "ब्रेडक्रम्ब",
|
||||
"root": {
|
||||
"personal_drive": "निजी ड्राइव: {{name}}",
|
||||
"shared_drive": "साझा ड्राइव: {{name}}",
|
||||
"direct_share": "आपके साथ साझा किया गया",
|
||||
"token": "साझा लिंक के माध्यम से"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "रद्दी खाली करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "この名前のグループはすでに存在します。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "ホーム"
|
||||
"home": "ホーム",
|
||||
"aria": "パンくずリスト",
|
||||
"root": {
|
||||
"personal_drive": "個人ドライブ: {{name}}",
|
||||
"shared_drive": "共有ドライブ: {{name}}",
|
||||
"direct_share": "あなたに共有されました",
|
||||
"token": "共有リンク経由"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "ゴミ箱を空にする",
|
||||
|
||||
@@ -570,7 +570,14 @@
|
||||
"forbidden": "파일을 불러올 수 없습니다"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "홈"
|
||||
"home": "홈",
|
||||
"aria": "탐색 경로",
|
||||
"root": {
|
||||
"personal_drive": "개인 드라이브: {{name}}",
|
||||
"shared_drive": "공유 드라이브: {{name}}",
|
||||
"direct_share": "내게 공유됨",
|
||||
"token": "공유 링크로"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "휴지통 비우기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "Группа с таким именем уже существует."
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Главная"
|
||||
"home": "Главная",
|
||||
"aria": "Хлебные крошки",
|
||||
"root": {
|
||||
"personal_drive": "Личный диск: {{name}}",
|
||||
"shared_drive": "Общий диск: {{name}}",
|
||||
"direct_share": "Поделено с вами",
|
||||
"token": "Через общую ссылку"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "Очистить корзину",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "已存在同名群組。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "主頁"
|
||||
"home": "主頁",
|
||||
"aria": "導覽路徑",
|
||||
"root": {
|
||||
"personal_drive": "個人雲端硬碟:{{name}}",
|
||||
"shared_drive": "共用雲端硬碟:{{name}}",
|
||||
"direct_share": "已分享給您",
|
||||
"token": "透過分享連結"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "清空回收站",
|
||||
|
||||
@@ -459,7 +459,14 @@
|
||||
"group_name_taken": "同名组已存在。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "主页"
|
||||
"home": "主页",
|
||||
"aria": "面包屑",
|
||||
"root": {
|
||||
"personal_drive": "个人云盘:{{name}}",
|
||||
"shared_drive": "共享云盘:{{name}}",
|
||||
"direct_share": "已共享给您",
|
||||
"token": "通过共享链接"
|
||||
}
|
||||
},
|
||||
"trash": {
|
||||
"empty_trash": "清空回收站",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
///
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user