Merge pull request #648 from EdouardVanbelle/feat/folders-ancestors

This commit is contained in:
Dionisio Pozo
2026-07-27 07:09:56 +02:00
committed by GitHub
41 changed files with 1914 additions and 246 deletions
+9 -1
View File
@@ -57,7 +57,15 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
# Distinct from load-smoke's `load-v2`. Nightly runs on the
# self-hosted `nuc-loadtest` runner (homogeneous), where
# `-C target-cpu=native` from `.cargo/config.toml` gives the
# accurate perf baseline we want. Smoke runs on the GHA
# heterogeneous pool with `RUSTFLAGS=-C target-cpu=x86-64-v3`.
# Sharing the same cache key would let native-baked proc-macro
# dylibs from nightly leak into smoke's restore set → SIGILL
# on a leaner GHA runner. Separate namespaces prevent that.
shared-key: load-nightly-v2
- name: Install Node 20
# The self-hosted runner image ships Node 12, which can't parse the
+11 -1
View File
@@ -18,6 +18,14 @@ on:
env:
CARGO_TERM_COLOR: always
# `.cargo/config.toml` sets `-C target-cpu=native` for dev speed, but
# GitHub-hosted runners are a heterogeneous pool — cached rlibs / proc-
# macro dylibs baked with AVX-512 on one runner crash with SIGILL when
# rustc dlopens them on a leaner one. Env RUSTFLAGS fully replaces the
# config.toml value (they don't merge), so pinning to `x86-64-v3` here
# (AVX2 baseline — every GHA runner has it) makes cached artifacts
# portable across the pool without touching local dev.
RUSTFLAGS: "-C target-cpu=x86-64-v3"
jobs:
smoke:
@@ -31,7 +39,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
shared-key: load
# Bumped suffix busts any existing cache poisoned with native-CPU
# ISA from a prior build (see RUSTFLAGS note above).
shared-key: load-v2
- name: Install k6
uses: grafana/setup-k6-action@v1
+30 -1
View File
@@ -1,7 +1,7 @@
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import type { FileItem, FolderAncestorsResponse, FolderItem, ItemType } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const NO_CACHE: RequestInit = {
@@ -109,6 +109,35 @@ export function getFolder(id: string): Promise<FolderItem> {
return request;
}
// ── Ancestor chain (breadcrumb) ──────────────────────────────────────────
// Backing store + inflight dedup for `GET /api/folders/{id}/ancestors` —
// mirrors the folderInflight pattern for `getFolder`. Rapid navigation
// (files → sub → sub-sub in <1s) folds concurrent requests for the same
// leaf into one round-trip. Response also seeds `folderNames` for every
// ancestor, so subsequent `getFolderName(id)` lookups are cache-free.
const ancestorsInflight = new Map<string, Promise<FolderAncestorsResponse>>();
export function getFolderAncestors(id: string): Promise<FolderAncestorsResponse> {
const inflight = ancestorsInflight.get(id);
if (inflight) return inflight;
const request = (async () => {
try {
const chain = await apiJson<FolderAncestorsResponse>(
`/api/folders/${id}/ancestors`,
NO_CACHE
);
// Prime the shared folder-name cache — the breadcrumb walk
// happens to be the exact input that populates it.
for (const a of chain.ancestors) rememberFolderName(a.id, a.name);
return chain;
} finally {
ancestorsInflight.delete(id);
}
})();
ancestorsInflight.set(id, request);
return request;
}
/** One page of `/api/folders/{id}/resources`. */
export interface FolderPage {
/**
+111 -2
View File
@@ -298,8 +298,34 @@ export interface SearchResourcesResponse {
export type DriveKind = 'personal' | 'shared';
/** Role-keyed share strength. Matches `Role` in the backend authz model. */
export type DriveRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
/**
* Full role set from `storage.grant_role` — every value that can appear
* on a `role_grants` row regardless of `resource_type` (drive, folder,
* file, playlist, calendar, address_book, …). Use this for folder-level
* and file-level `caller_role` fields where all five values are valid.
* Matches `RoleDto` in the backend.
*/
export type GrantRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
/**
* Role assignable at DRIVE scope — a strict subset of `GrantRole`.
* Drives only meaningfully take the three management-ladder tiers:
* - `owner` — full control (rename, delete, quota, membership).
* - `editor` — can create/modify content anywhere in the drive.
* - `viewer` — read-only access to the whole drive.
*
* `contributor` (create-in-folder-without-touching-siblings) and
* `commenter` (react without modifying) are folder/file-scope
* semantics: they describe fine-grained access to a specific item,
* not to a whole drive. Grants of those roles happen at folder or
* file scope via a separate `role_grants` row, not at the drive
* boundary. Do NOT widen this type without a matching backend
* check — the DB ENUM permits all 5 today, so the constraint is
* conventional.
*
* Use `GrantRole` for folder/file-level `caller_role` fields.
*/
export type DriveRole = 'owner' | 'editor' | 'viewer';
/** Subject of a grant. Mirrors `SubjectDto`. */
export type SubjectKind = 'user' | 'group' | 'token';
@@ -413,3 +439,86 @@ 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;
/**
* SHARER — the user who created the grant that gave the caller
* access at the boundary (`role_grants.granted_by`). Kind is always
* `'user'` today (a group can't perform an action), but the type
* stays open in case a future model permits it. Null when the
* boundary can't be resolved to a single grant (e.g. `token`).
*/
subject?: AccessSourceSubject;
/**
* Caller's role via the boundary grant (`role_grants.role` on the
* same row that carries `granted_by`). Lets the FE render permission-
* aware affordances at the ancestor scope. Reflects the boundary grant
* only — aggregate effective role via other channels may be stronger.
* Null on `token` access.
*
* Typed as `GrantRole` (not `DriveRole`): the boundary can be a
* folder-level share where all five role_grant values are valid,
* not just the drive-scoped subset.
*/
caller_role?: GrantRole | null;
}
/**
* 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,423 @@
<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 UserAvatar from '$lib/components/UserAvatar.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
: []
);
/**
* Index of the crumb that carries the ACTUAL grant giving the
* caller access — the "boundary" crumb. Rendered with an inline
* sharer avatar (or group chip) so the breadcrumb answers both
* "how did I get here?" (root chip icon) AND "who shared this?"
* (avatar on the specific granted folder) — Ed's 2026-07-27 UX call.
*
* - `direct_share`: always index 0 of `visibleCrumbs` (the
* endpoint's walk stops at the granted folder, so the topmost
* accessible ancestor IS the share boundary).
* - `drive`: null — the grant lives on the drive itself, not on
* any visible folder. The drive-root chip already carries the
* drive name; adding a subject chip to a sub-folder would be
* semantically misleading (that sub-folder wasn't the grant).
* - `token`: null — no user-facing subject to render.
*/
const boundaryCrumbIndex = $derived<number | null>(
chain?.access_source.kind === 'direct_share' && visibleCrumbs.length > 0 ? 0 : null
);
// ── 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}
{@const isBoundary = i === boundaryCrumbIndex}
<a
href={resolve(`/files/${c.id}`)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-current={isLeaf}
class:breadcrumb-boundary={isBoundary}
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}
>
<!--
Sharer decoration on the BOUNDARY crumb (the topmost
accessible ancestor that carries the actual grant).
Only rendered for `direct_share` — drive-kind grants
live on the drive itself and the drive-root chip
already carries that context. Ed's 2026-07-27 design:
[share-alt] > folder[avatar] > sub …
so the root chip keeps the access-CHANNEL semantic
(share / drive / link) and the person/group chip
attaches to the folder that WAS shared.
-->
{#if isBoundary && chain?.access_source.subject}
{@const subj = chain.access_source.subject}
{#if subj.kind === 'user'}
<UserAvatar userId={subj.id} size={18} />
{:else}
<span
class="breadcrumb-group-chip"
title={t(
'breadcrumb.subject.group_tooltip',
{ name: subj.name ?? '' },
'Shared with group: {{name}}'
)}
>
<Icon name="users" />
</span>
{/if}
{/if}
<span class="breadcrumb-crumb-name">{c.name}</span>
</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.
Boundary crumbs (with an inline avatar/group chip child) get the
same treatment — same drop-flicker mechanism, one child element to
cross into. Plain intermediate crumbs are pure text nodes and
don't need it, but the selector is harmless there. */
.breadcrumb-home > *,
.breadcrumb-link > * {
pointer-events: none;
}
/* Boundary crumbs align the inline sharer decoration (avatar or
group chip) with the folder-name text on the vertical midline —
inline-flex + baseline gap. Non-boundary crumbs stay inline (no
flex overhead) so wide breadcrumbs still wrap the same way. */
.breadcrumb-boundary {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
/* Group-subject chip — small `users` glyph in a subtle badge, sits
next to the folder name on the boundary crumb. Matches the size
footprint of the inline UserAvatar (18px) so user- vs group-shared
crumbs feel visually consistent. */
.breadcrumb-group-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
font-size: 11px;
}
</style>
@@ -10,9 +10,12 @@
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { ui } from '$lib/stores/ui.svelte';
// A drive accepts new items only if the caller can Create on its root.
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
// A drive accepts new items only if the caller can Create on its
// root. Drive-scope roles are the management-ladder subset —
// Owner / Editor / Viewer — so writability collapses to the top two;
// Viewer cannot. `contributor`/`commenter` don't appear at drive
// scope (folder/file-scope semantics), so they're not in `DriveRole`.
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor'] as const;
function isWritable(d: Drive): boolean {
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
}
@@ -163,6 +163,17 @@
* the action is meaningful for and returns nothing otherwise.
*/
bucketAction?: Snippet<[string]>;
/**
* Optional custom renderer for the swimlane header label. Receives
* the bucket key + the default label string (from `labelOf`, or
* the key itself if no `labelOf`). Pages that want rich header
* content (e.g. `/shared-with-me` prefixing the "Shared by X"
* header with the sharer's avatar) use this; pages happy with
* a plain text label leave it undefined and ResourceList renders
* `{section.label}` as before. The default label is passed too
* so pages don't have to re-invoke `labelOf` themselves.
*/
bucketLabel?: Snippet<[string, string]>;
/** Show the owner column + vignette (list view) and hover tooltip. */
showOwner?: boolean;
/**
@@ -248,6 +259,20 @@
* don't have to piggyback on the bar.
*/
itemActions?: Snippet<[FileItem | FolderItem]>;
/**
* Free-form overlay rendered inside `.file-item` (as a sibling of
* `.action-cell`), so the page can absolute-position content
* anywhere on the card. Used by `/shared-with-me` to anchor the
* sharer avatar at the bottom-right of the card — the pre-fix
* `rowBadge` slot rendered inside `.file-icon` (a positioned
* ancestor), which couldn't align with `.action-cell`'s
* `.file-item`-scoped coordinates. The page provides its own
* absolute-positioning CSS via a scoped style block.
*
* Fires in grid view only — list view has explicit columns
* (owner cell, etc.) for the same information.
*/
cardOverlay?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>;
/**
* Action-bar left cluster — always-visible page action buttons
* (Upload / New folder / Empty trash / Clear recent / …). Swaps
@@ -385,6 +410,7 @@
dateLabel,
dateCell,
bucketAction,
bucketLabel,
showOwner = false,
ownerLabel,
showViewToggle = true,
@@ -402,6 +428,7 @@
oncontextmenu: onContextMenuOverride,
menuPrepare,
itemActions,
cardOverlay,
actions,
batchActions,
rowBadge,
@@ -504,6 +531,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));
@@ -1213,6 +1267,17 @@
{/if}
</div>
{/if}
<!--
Page-provided card overlay — grid view only. Fires as the
last child of `.file-item` (which is already `position:
relative` to anchor `.action-cell`), so the page can
absolute-position content anywhere inside the card without
creating a new positioned ancestor. See `/shared-with-me`
for the sharer-avatar consumer.
-->
{#if cardOverlay && filesStore.viewMode === 'grid'}
{@render cardOverlay(item, ctx)}
{/if}
</div>
{/snippet}
@@ -1319,9 +1384,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.')}
@@ -1337,7 +1408,13 @@
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__label">
{#if bucketLabel}
{@render bucketLabel(section.key, section.label)}
{:else}
{section.label}
{/if}
</span>
{#if bucketAction}
<span class="rl-swimlane-header__action">
{@render bucketAction(section.key)}
@@ -1362,7 +1439,13 @@
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__label">
{#if bucketLabel}
{@render bucketLabel(section.key, section.label)}
{:else}
{section.label}
{/if}
</span>
{#if bucketAction}
<span class="rl-swimlane-header__action">
{@render bucketAction(section.key)}
@@ -1647,6 +1730,17 @@
align-items: center;
}
/* Label slot — inline-flex + gap so pages injecting rich content
via the `bucketLabel` snippet (e.g. `/shared-with-me` prefixing
with a sharer avatar) render avatar-then-text on one baseline
without hand-tuned spacing. Plain-text labels (no snippet) still
look identical — flex on a single text node is a no-op. */
.rl-swimlane-header__label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
/* Grouped-grid container: a vertical stack of (header + its own windowed
card grid) per section. Not `.files-grid-view` — the grid is on each
VirtualList's inner window, so this outer element just stacks. */
@@ -0,0 +1,131 @@
<script lang="ts">
/**
* Avatar-only chip — no name / email, just the circle. Extracted from
* `<UserVignette>` (which pairs avatar + name + email for share
* dialogs / owner cells) so surfaces that only have room for the
* avatar (grid-card overlay, swimlane header prefix) don't drag the
* text half in with them.
*
* Same visual grammar as UserVignette: uploaded photo when present,
* otherwise coloured initials from `userInitials()` + `avatarColorIndex()`.
* Same lazy `resolveUser` fetch (cached) so external-user avatars
* populate without a per-mount round-trip.
*/
import { resolveUser, type ResolvedUser } from '$lib/api/endpoints/users';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
interface Props {
userId: string;
/** Fallback label if `resolveUser` fails or is still resolving. */
fallbackLabel?: string;
/** Pixel size — defaults to 24 (badge / swimlane scale). */
size?: number;
/**
* Optional title text override. Defaults to the resolved display
* name so browsers show a tooltip on hover. Pass empty string
* to suppress the tooltip.
*/
title?: string;
}
let { userId, fallbackLabel, size = 24, title }: Props = $props();
let resolved = $state<ResolvedUser | null>(null);
$effect(() => {
let alive = true;
resolved = null;
void resolveUser(userId).then((u) => {
if (alive) resolved = u;
});
return () => {
alive = false;
};
});
const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
const image = $derived(resolved?.image ?? null);
const colorIndex = $derived(avatarColorIndex(userId));
const initials = $derived(userInitials(label));
const tooltip = $derived(title !== undefined ? title : label);
</script>
<span class="ua" style:width={`${size}px`} style:height={`${size}px`} title={tooltip || undefined}>
{#if image}
<img class="ua__photo" src={image} alt="" />
{:else}
<span class="ua__initials ua__initials--c{colorIndex}" style:font-size={`${size * 0.42}px`}>
{initials}
</span>
{/if}
</span>
<style>
.ua {
/* `inline-block` (not `inline-flex`) so children with
`width/height: 100%` reliably fill the box. Ed 2026-07-26:
the previous `inline-flex + align-items: center` shape gave
the `<img>` a cross-axis intrinsic height (~6 px in a 22 px
box); `align-self: stretch` on the img wasn't enough to
defeat the parent's alignment rule in some contexts. Block
sizing sidesteps flex-alignment quirks entirely.
`vertical-align: middle` aligns the chip with adjacent
text (swimlane header prefix, etc.). */
display: inline-block;
position: relative;
flex-shrink: 0;
border-radius: var(--radius-full);
overflow: hidden;
vertical-align: middle;
}
.ua__photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ua__initials {
/* `display: flex` (block-level) — NOT `inline-flex`. An inline-
flex child inside the `.ua` `inline-block` parent participates
in inline layout, gets baseline-aligned, and rendered outside
its parent's box for text with descenders (Ed 2026-07-26).
Block-flex fills the container unambiguously. */
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
color: var(--color-on-accent);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
/* Colour buckets mirror the shared avatar palette used by
`AppShell` / `UserVignette` — 5 slots (see `avatarColorIndex()`
which does `Math.abs(hash) % 5`). Same badge tokens so a user's
avatar renders in the identical colour across surfaces. */
.ua__initials--c0 {
background: var(--color-badge-indigo-bg);
color: var(--color-badge-indigo-text);
}
.ua__initials--c1 {
background: var(--color-badge-green-bg);
color: var(--color-badge-green-text);
}
.ua__initials--c2 {
background: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.ua__initials--c3 {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.ua__initials--c4 {
background: var(--color-badge-amber-bg);
color: var(--color-badge-amber-text);
}
</style>
@@ -109,7 +109,7 @@ describe('round18 §F1 — ResourceList itemIndexById incremental Map', () => {
expect(reloaded).not.toBe(afterDelete);
});
it('a P-page drain builds the index ≥5x faster incrementally (perf gate)', () => {
it('a P-page drain builds the index ≥4x faster incrementally (perf gate)', () => {
const PAGES = 40;
const PER = 50; // 2 000 items total
const pages = Array.from({ length: PAGES }, (_, p) => pageOf(p * PER, PER));
@@ -129,6 +129,11 @@ describe('round18 §F1 — ResourceList itemIndexById incremental Map', () => {
console.info(
`§F1 ${PAGES} pages × ${PER}: rebuild-per-page ${beforeMs.toFixed(1)} ms vs incremental ${afterMs.toFixed(1)} ms (${(beforeMs / afterMs).toFixed(1)}x)`
);
expect(afterMs).toBeLessThan(beforeMs / 5);
// Ratio threshold: single-shot microbenches on shared CI runners
// (throttled CPU, cold caches, sibling load) routinely wobble
// ±20 %. 4× still catches any real regression — a correctness
// break would collapse the ratio to <2× — while surviving
// runner noise. Local dev machines see 6–10× consistently.
expect(afterMs).toBeLessThan(beforeMs / 4);
});
});
+14 -2
View File
@@ -21,6 +21,14 @@
.breadcrumb-link {
cursor: pointer;
color: var(--color-text-muted);
/* No underline at rest OR on hover — Ed's 2026-07-26 UX call: the
hover background alone is enough affordance, and the pre-fix
browser-default underline mixed awkwardly with the bold-leaf
styling (leaf was bold+plain, root link was underlined+plain,
and the styling difference read as "these do different things"
when in fact both are simple navigations). Uniform link chrome
via background-on-hover; the bold-current class flags the leaf. */
text-decoration: none;
}
.breadcrumb-link.drop-target {
@@ -29,15 +37,19 @@
}
.breadcrumb-link:hover {
text-decoration: underline;
color: var(--color-accent);
background: var(--color-accent-bg);
}
/* Applied to the LEAF crumb (last visible item in the chain) so it
reads as "you are here". Every crumb — leaf included — is now a
link (Ed's 2026-07-26 UX ask: from `/search` the fastest way to
jump into the searched folder is to click its name in the crumb
trail; making the leaf clickable serves that path with zero extra
clicks). Only the bold weight distinguishes it from an intermediate. */
.breadcrumb-current {
font-weight: var(--weight-semibold);
color: var(--color-text-black);
cursor: default;
}
.breadcrumb-separator {
+49 -11
View File
@@ -360,6 +360,19 @@
white-space: nowrap;
}
/* List-view rows are fixed 56px (VirtualList `rowHeight={56}`), and a
full UserVignette stacks avatar (32px) + name (20px) + email (15px)
→ ~40px block that doesn't visually fit the flex-centered cell —
the email tail was cropped. Hide the email in dense-row contexts
(Ed 2026-07-26); the identity signal remains (avatar + name), and
the email survives in the ShareDialog / recipient pickers where
UserVignette was originally scaled for. Grid view's owner cell is
unaffected (it never renders a UserVignette — the sharer surfaces
through the `.file-icon__badge` slot instead). */
.files-list-view .file-item .owner-cell .uv__email {
display: none;
}
/* Expand the grid track as soon as at least one owner cell is visible. */
.files-list-view:has(.owner-cell:not(.hidden)) {
--files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 200px;
@@ -409,7 +422,10 @@
overflow: hidden;
}
.files-list-view .file-item .name-cell span {
/* Same narrowing rationale as the grid view rule below — target only
the name text span, not every descendant span, so `.file-icon__badge`
overlay contents (avatars, chips) aren't sized as text. */
.files-list-view .file-item .name-cell__text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -571,9 +587,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 +610,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;
}
}
@@ -1024,11 +1052,16 @@
margin-top: var(--space-1);
}
.files-grid-view .file-item .name-cell span {
/* Narrowed from the pre-fix `.name-cell span` (descendant selector) to
the specific name-text span. The broad rule caught EVERY span inside
`.name-cell` — including the `.file-icon__badge` overlay and, inside
it, `<UserAvatar>`'s `.ua` wrapper — and slapped 8 px of top/bottom
padding on them, which crushed the badge's `<img>` from 22 × 22 down
to 22 × 6 (Ed 2026-07-26). Text ellipsis / padding stays on the text
span alone; overlay children in `.file-icon` are unaffected. */
.files-grid-view .file-item .name-cell__text {
display: block;
max-width: 100%;
/* Ellipsis must live on the span (the text node), not the flex parent,
or long names clip with no "…". */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1391,10 +1424,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;
}
@@ -125,10 +125,6 @@
return t('drive.role.owner', 'Owner');
case 'editor':
return t('drive.role.editor', 'Editor');
case 'contributor':
return t('drive.role.contributor', 'Contributor');
case 'commenter':
return t('drive.role.commenter', 'Commenter');
case 'viewer':
return t('drive.role.viewer', 'Viewer');
}
+99 -128
View File
@@ -10,8 +10,7 @@
createFolder,
deleteFolder,
fetchFolderPage,
getFolder,
getFolderName,
getFolderAncestors,
invalidateFolderCache,
moveFolder,
rememberFolderName,
@@ -41,6 +40,7 @@
import { preferences } from '$lib/stores/preferences.svelte';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, {
isFile,
type GroupByDef as RLGroupByDef
@@ -48,7 +48,7 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { drives as drivesStore } from '$lib/stores/drives.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
@@ -67,26 +67,16 @@
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
// First-crumb icon mirrors the drive at pathSegments[0]: `home` for the
// default-personal, `folder` for a secondary personal, `users` for a
// shared drive. Falls back to `home` while the drives list is loading
// or when the URL's leading segment isn't a known drive root (deep-link
// into a sub-folder bypasses drive identification — same limitation as
// the breadcrumb name resolution).
const rootIcon = $derived.by(() => {
const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null);
return drive ? driveIcon(drive) : 'home';
});
// The drive whose content the user is currently browsing.
//
// Priorities (first match wins):
// 1. `currentFolderDriveId` — set by `load()` after a `getFolder`
// fetch on the current folder. Authoritative for deep-links
// too (the URL's leading segment might not be a drive root).
// 1. `currentFolderDriveId` — set by `load()` from the ancestors
// response (`chain.ancestors.at(-1).drive_id`). Authoritative
// for deep-links too (the URL's leading segment might not be a
// drive root).
// 2. `listing.folders[0]?.drive_id` — fast-path when the folder
// has at least one subfolder; avoids the extra round-trip on
// the initial `applyListing` before `getFolder` returns.
// has at least one subfolder; avoids waiting on the ancestors
// response before the initial `applyListing`.
// (`FileDto` doesn't carry `drive_id` today, so we can't use
// files as a fallback source; folders alone.)
// 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy
@@ -156,11 +146,23 @@
const hiddenCount = $derived(
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
);
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
// Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it.
let showSkeleton = $state(false);
// Default `true` (not `false`) so the first render — before the
// `$effect` fires `load()` — shows the "loading" arm of ResourceList
// (skeleton, gated on 100 ms delay) instead of the "empty" arm
// ("No elements here"). Ed's 2026-07-26 report: a brief empty-state
// flash appeared between page mount and the first fetch landing.
// `load()` still writes `loading = true` before its first await, so
// mid-navigation clears work as before.
let loading = $state(true);
// `showSkeleton` used to sit 100 ms behind `loading` to avoid flashing
// skeleton bars on fast loads. Retired 2026-07-26 because ResourceList
// received `loading={showSkeleton}` (not the real `loading` state), so
// during those 100 ms it saw `loading=false && items=[]` and rendered
// the empty-state ("Folder is empty") — the flash Ed reported. Pass
// the real `loading` instead; the skeleton renders instantly for
// slow loads and instantly-disappears for fast loads (users don't
// perceive a sub-100 ms frame flip).
let error = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
@@ -219,24 +221,6 @@
}
}
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// Names come from the cache first (every listing names its children, so
// step-by-step navigation needs zero requests); only ids we've never seen
// — a cold deep-link's ancestors — are fetched, in parallel.
return Promise.all(
segments.map(async (id) => {
const known = getFolderName(id);
if (known !== undefined) return { id, name: known };
try {
const f = await getFolder(id);
return { id, name: f.name };
} catch {
return { id, name: '…' };
}
})
);
}
// Bumped on every load; a stale in-flight response checks this before it
// writes state, so a fast navigation can't be clobbered by an older fetch.
let loadSeq = 0;
@@ -262,7 +246,6 @@
const seq = ++loadSeq;
let folderId: string;
let skeletonTimer: ReturnType<typeof setTimeout> | undefined;
if (reset) {
// External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) {
@@ -294,32 +277,55 @@
currentId = folderId;
filesStore.currentFolder = folderId;
// Reset paging state: previous folder's cursor is meaningless here,
// and mixing its rows with the new folder's would flash a wrong list.
pageCursor = undefined;
listing = { folders: [], files: [] };
orderedItems = [];
// Reset paging state: previous folder's cursor is meaningless
// on the new folder — must clear or the first append would
// paginate the OLD folder's next-page slice.
//
// `listing` / `orderedItems` are deliberately NOT cleared —
// the previous folder's rows stay on screen during the (~25 ms)
// fetch, then the response handler swaps in the new folder's
// content atomically. Stale-while-revalidate for the inter-
// folder case (Ed 2026-07-26: the pre-refactor clear-then-
// fetch-then-render sequence flashed either the SkeletonList
// or the "Folder is empty" empty-state for the fetch window,
// depending on which arm ResourceList happened to render for
// the empty-loading state; neither is useful for a 25 ms
// transition). First-mount (no previous content) still hits
// the skeleton correctly because `orderedItems` defaults `[]`
// and `loading` defaults `true` — the empty-loading arm
// gates on that.
loading = true;
pageCursor = undefined;
// Delayed skeleton so fast loads don't flash it.
skeletonTimer = setTimeout(() => {
if (loading) showSkeleton = true;
}, 100);
// Legacy path-chain URLs canonicalize to the single-id form on
// load. `/files/A/B/C` still resolves (router matches `[...path]`)
// but the URL bar and any subsequent bookmark reflects the
// canonical `/files/C` — see 2026-07-26 URL-format discussion.
// `replaceState` (not `pushState`) so the back button doesn't
// gain a spurious entry.
if (pathSegments.length > 1 && typeof window !== 'undefined') {
window.history.replaceState({}, '', resolve(`/files/${folderId}`));
}
// Breadcrumbs resolve independently so they never block the grid paint.
void buildCrumbs(pathSegments).then((trail) => {
if (seq === loadSeq) crumbs = trail;
});
// Resolve the current folder's drive_id so the read-only banner
// works even on deep-links into a sub-folder. Guarded by `seq`.
void getFolder(folderId)
.then((folder) => {
if (seq === loadSeq) currentFolderDriveId = folder.drive_id;
// Resolve the current folder's drive_id via the ancestors
// response — every `FolderAncestor` carries `drive_id`, so
// the shared `<FolderBreadcrumb>`'s in-flight call is the
// same round-trip we'd otherwise duplicate here. The
// `ancestorsInflight` dedup map inside `getFolderAncestors`
// means this second caller gets the same promise, not a
// second HTTP request — the extra `getFolder(folderId)`
// that used to fire here is gone (2026-07-26 UX pass on
// /files load traffic).
void getFolderAncestors(folderId)
.then((chain) => {
if (seq !== loadSeq) return;
const leaf = chain.ancestors.at(-1);
if (leaf) currentFolderDriveId = leaf.drive_id;
})
.catch(() => {
// Fallback chain in `currentDrive` still gives us a
// best-effort drive resolution.
// best-effort drive resolution (listing.folders[0].drive_id,
// then drivesStore lookup by root-folder id).
});
} else {
// Append path: reuse `currentId`. `pageCursor === undefined` means
@@ -360,10 +366,8 @@
? e.message
: String(e);
} finally {
if (skeletonTimer !== undefined) clearTimeout(skeletonTimer);
if (seq === loadSeq && reset) {
loading = false;
showSkeleton = false;
}
}
}
@@ -421,7 +425,10 @@
}
function openFolder(folder: FolderItem) {
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves
// (canonicalize-on-load rewrites it inside `load()`), but new
// navigation lands directly on `/files/{id}`.
goto(resolve(`/files/${folder.id}`));
}
async function onNewFolder() {
@@ -1185,12 +1192,12 @@
// ── Drag-to-move ─────────────────────────────────────────────────────────
const DRAG_TYPE = 'application/x-oxi-item';
let dropFolderId = $state<string | null>(null);
// Highlighted breadcrumb crumb during an OxiCloud drag. Holds the
// crumb's folder id, or the sentinel `'__home__'` for the home link
// (which doesn't have a stable folder id — depends on the caller's
// home folder resolution).
const CRUMB_HOME_ID = '__home__';
let dropCrumbId = $state<string | null>(null);
// Per-crumb drop highlight state lived here until the breadcrumb
// migrated to the shared `<FolderBreadcrumb>` component (2026-07-26),
// which owns its own hover state. The `CRUMB_HOME_ID` sentinel is
// gone too — the shared component's root icon isn't a drop target
// (the drive root's ancestor is always the drive itself, and
// dropping "at the drive" is ambiguous).
// Copy-vs-move on drop.
//
@@ -1871,7 +1878,7 @@
)
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
loading={showSkeleton}
{loading}
error={error ?? undefined}
selectable
shiftRangeSelect
@@ -1923,61 +1930,24 @@
{/snippet}
{#snippet breadcrumb()}
<nav class="breadcrumb" aria-label="Breadcrumb">
<!-- Persistent home link → the root listing (bare /files canonicalizes to
the user's drive root). `buildCrumbs` returns only the path folders,
so this is the single always-present "go home" affordance. Both the
home link and every crumb accept row drops via the same
`application/x-oxi-item` MIME the item-drag uses. The
`.drop-target` class visually highlights the crumb during a
hover-over so the user sees WHICH crumb the drop will land on. -->
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
class:drop-target={dropCrumbId === CRUMB_HOME_ID}
title={t('breadcrumb.home', 'Home')}
data-testid="files-breadcrumb-home-link"
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondragenter={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = CRUMB_HOME_ID;
}}
ondragleave={() => {
if (dropCrumbId === CRUMB_HOME_ID) dropCrumbId = null;
}}
ondrop={(e) => {
dropCrumbId = null;
if (session.homeFolderId) onCrumbDrop(e, session.homeFolderId);
}}
>
<Icon name={rootIcon} />
</a>
{#each crumbs as c, i (c.id)}
<span class="breadcrumb-separator">&gt;</span>
{#if i === crumbs.length - 1}
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
{:else}
<a
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
class="breadcrumb-item breadcrumb-link"
class:drop-target={dropCrumbId === c.id}
data-testid={`files-breadcrumb-${c.id}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondragenter={(e) => {
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = c.id;
}}
ondragleave={() => {
if (dropCrumbId === c.id) dropCrumbId = null;
}}
ondrop={(e) => {
dropCrumbId = null;
onCrumbDrop(e, c.id);
}}
>
{c.name}
</a>
{/if}
{/each}
</nav>
<!--
Shared component (2026-07-26 migration). Fetches the ancestor
chain in ONE round-trip via `GET /api/folders/{id}/ancestors`
(replaces the per-segment `buildCrumbs` walker + N `getFolder`
requests). Root icon is derived from `access_source.kind` on
the endpoint response — no more `drivesStore.findByRootFolderId`
lookup here.
`onDrop` prop preserves the row-drop-to-crumb behaviour: the
component handles the `dragover`/`dragenter`/`dragleave` UI +
`.drop-target` highlight; we get the target folder id + the
raw event and dispatch to `onCrumbDrop`.
-->
<FolderBreadcrumb
folderId={currentId}
onDrop={(target, e) => onCrumbDrop(e, target)}
dragMime={DRAG_TYPE}
/>
{/snippet}
{#snippet actions()}
@@ -2142,7 +2112,8 @@
onclick={() => {
const id = ctxTarget!.id;
closeContext();
goto(resolve(`/files/${[...pathSegments, id].join('/')}`));
// Canonical single-id URL — see `openFolder` above.
goto(resolve(`/files/${id}`));
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
>
<button
+7
View File
@@ -61,6 +61,13 @@ vi.mock('$lib/api/endpoints/folders', () => ({
folderZipUrl: () => '/zip',
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined,
// Consumed by the new shared `<FolderBreadcrumb>` component that
// `/files` mounts. Return an empty chain so the breadcrumb renders
// nothing — tests here don't assert on breadcrumb content.
getFolderAncestors: vi.fn(async (id: string) => ({
ancestors: [{ id, name: id, parent_id: null, drive_id: 'test-drive' }],
access_source: { kind: 'drive' as const }
})),
invalidateFolderCache: vi.fn(),
moveFolder: vi.fn(),
rememberFolderName: vi.fn(),
+21 -60
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import EmptyState from '$lib/components/EmptyState.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, {
isFile,
type ContextAction,
@@ -11,7 +12,7 @@
import { page } from '$app/state';
import { searchResources } from '$lib/api/endpoints/search';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder, getFolder, getFolderName } from '$lib/api/endpoints/folders';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import {
addFavorite,
removeFavorite,
@@ -47,36 +48,14 @@
// this session (the pre-URL-param behaviour).
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
// Breadcrumb — resolves the scope folder's display name so the sticky
// header can show WHICH directory the results come from ("we have no
// clue on which directory the search was done" — Ed 2026-07-26).
// `getFolderName` is a sync cache peek populated by prior /files
// listings; on a cold /search deep-link we fall back to `getFolder`
// once, cache the result, and re-render. `$state<string | null>`
// with a `$effect` primer avoids blocking the initial render.
let scopeFolderName = $state<string | null>(null);
$effect(() => {
if (!scopeFolderId) {
scopeFolderName = null;
return;
}
const cached = getFolderName(scopeFolderId);
if (cached) {
scopeFolderName = cached;
return;
}
// Cold deep-link — fire once, populate on resolve. If it fails
// (folder was deleted, caller lost Read), keep name null so the
// breadcrumb just falls back to a short UUID.
const id = scopeFolderId;
void getFolder(id)
.then((f) => {
if (scopeFolderId === id) scopeFolderName = f.name;
})
.catch(() => {
if (scopeFolderId === id) scopeFolderName = id.slice(0, 8);
});
});
// Breadcrumb rendering is delegated to the shared `<FolderBreadcrumb>`
// component (2026-07-26 migration). It consumes
// `GET /api/folders/{id}/ancestors` and renders the full parent chain
// with the access-source-appropriate root icon (drive / share / link).
// The per-name resolver that used to live here (`getFolder`/`getFolderName`
// on the scope folder) is retired — the ancestors endpoint returns the
// whole chain in one round-trip, and its inflight-dedup map means the
// component's fetch reuses whatever other pages have already primed.
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
// query time / result count into the title string because ResourceList
@@ -756,37 +735,19 @@
{/snippet}
{#snippet breadcrumb()}
<!--
Only render when the search is folder-scoped AND the URL
param is present — the sticky "Home > Photos" cue answers
the "which directory was this search done in?" question
Ed raised 2026-07-26. Hidden for scope='all' (searching
everywhere → no folder to breadcrumb) and for a fresh
`/search?q=…` with no `in=` param.
Single-segment for now (Home icon + scope folder as a
link). Full parent-chain walk is a follow-up; it needs
stepping through `parent_id` via `getFolder`, which
would be a second pass here.
Shared component (same one `/files` uses). Renders only when
the search is folder-scoped AND the URL carries `?in=<uuid>`
— in "Everywhere" mode `folderId={null}` and the component
collapses to empty. Root icon + tooltip come from the
ancestors endpoint's `access_source`, so a `/search?in=<X>`
where X sits inside a shared drive automatically shows the
`[users]` chip + drive name, and a share-boundary scope
shows `[share-alt]` + a "Shared with you" link back to
/shared-with-me. No `onDrop` — /search doesn't accept row
drops into folders.
-->
{#if scope === 'folder' && scopeFolderId}
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
data-testid="search-breadcrumb-home-link"
>
<Icon name="home" />
</a>
<span class="breadcrumb-separator">&gt;</span>
<a
href={resolve(`/files/${scopeFolderId}`)}
class="breadcrumb-item breadcrumb-current breadcrumb-link"
data-testid="search-breadcrumb-folder-link"
>
{scopeFolderName ?? '…'}
</a>
</nav>
<FolderBreadcrumb folderId={scopeFolderId} />
{/if}
{/snippet}
{#snippet itemActions(item)}
@@ -24,6 +24,7 @@
type GroupByDef,
type ItemContext
} from '$lib/components/ResourceList.svelte';
import UserAvatar from '$lib/components/UserAvatar.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
@@ -259,7 +260,43 @@
cursor = undefined;
load(true, orderBy, rev);
}}
/>
>
{#snippet cardOverlay(_item, ctx)}
<!--
Sharer avatar anchored to the bottom-right of the CARD (not
the file-icon). Uses ResourceList's `cardOverlay` slot,
which renders inside `.file-item` as a sibling of
`.action-cell` — same positioned ancestor, so the offset
matches the top-right cluster exactly. Grid view only;
list view surfaces the sharer through the owner column.
Positioning lives in the `<style>` block below; the snippet
just emits the chip and lets CSS place it. `ctx.ownerId`
(seeded from `granted_by` in `load()`) is the source of
truth for "who shared this."
-->
{#if ctx?.ownerId}
<span class="shared-with-me__sharer">
<UserAvatar userId={ctx.ownerId} size={30} />
</span>
{/if}
{/snippet}
{#snippet bucketLabel(_key, label)}
<!--
"Shared by" swimlane header — prefix the label with the
sharer's avatar so the group is visually anchored to a
person, not just a UUID-ish name. Other group-by dimensions
(`type`, `sharedAt`, or the flat `Name` sort) don't have
an ownerId as the key, so render only the plain label —
`_key` for those is a category / date-bucket / empty string
that has no avatar equivalent.
-->
{#if groupBy === 'sharedBy' && _key}
<UserAvatar userId={_key} size={30} />
{/if}
<span class="rl-swimlane-header__text">{label}</span>
{/snippet}
</ResourceList>
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
@@ -267,6 +304,35 @@
{/if}
<style>
/*
* Sharer avatar chip — anchored to the bottom-right of the card
* (grid view). Rendered inside `.file-item` via ResourceList's
* `cardOverlay` snippet slot, so `position: absolute` climbs to
* `.file-item`'s own `position: relative` (the same containing
* block `.action-cell` uses in the top-right). The `right` +
* `bottom` offset matches `.action-cell`'s `top` + `right`
* offset so the two chips sit in the same vertical column at
* opposite corners — Ed 2026-07-26.
*
* The wrapper is `:global` so the scoped-CSS renaming Svelte
* applies to the `<span>` this snippet emits doesn't strip our
* selector. Only fires inside the grid view — the parent
* `{@render cardOverlay}` in ResourceList already gates on
* `filesStore.viewMode === 'grid'`, so no need to gate here.
*/
:global(.files-grid-view .file-item .shared-with-me__sharer) {
position: absolute;
right: 5px;
/* Pushed 15 px closer to the card bottom so the chip sits at
the same vertical band as the item title instead of hovering
above it (Ed 2026-07-26). `calc(var(--space-3) + 8px)` was
20 px from the bottom edge, right in the thumbnail area
overlapping the title's top; 5 px anchors the chip cleanly
at the card's bottom-right corner. */
bottom: 5px;
z-index: 10;
}
.upgrade-banner {
display: flex;
align-items: center;
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
},
"breadcrumb": {
"home": "الرئيسية"
"home": "الرئيسية",
"aria": "مسار التنقل",
"root": {
"personal_drive": "قرص شخصي: {{name}}",
"shared_drive": "قرص مشترك: {{name}}",
"direct_share": "تمت مشاركته معك",
"token": "عبر رابط مشترك"
}
},
"trash": {
"empty_trash": "تفريغ سلة المهملات",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
},
"breadcrumb": {
"home": "Startseite"
"home": "Startseite",
"aria": "Brotkrumen",
"root": {
"personal_drive": "Persönliches Laufwerk: {{name}}",
"shared_drive": "Geteiltes Laufwerk: {{name}}",
"direct_share": "Für Sie freigegeben",
"token": "Über freigegebenen Link"
}
},
"trash": {
"empty_trash": "Papierkorb leeren",
+8 -1
View File
@@ -605,7 +605,14 @@
"forbidden": "Could not load files"
},
"breadcrumb": {
"home": "Home"
"home": "Home",
"aria": "Breadcrumb",
"root": {
"personal_drive": "Personal drive: {{name}}",
"shared_drive": "Shared drive: {{name}}",
"direct_share": "Shared with you",
"token": "Via shared link"
}
},
"trash": {
"empty_trash": "Empty Trash",
+8 -1
View File
@@ -464,7 +464,14 @@
"group_name_taken": "Ya existe un grupo con este nombre."
},
"breadcrumb": {
"home": "Inicio"
"home": "Inicio",
"aria": "Ruta de navegación",
"root": {
"personal_drive": "Unidad personal: {{name}}",
"shared_drive": "Unidad compartida: {{name}}",
"direct_share": "Compartido contigo",
"token": "A través de enlace compartido"
}
},
"trash": {
"empty_trash": "Vaciar papelera",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "گروهی با این نام پیش‌از این وجود دارد."
},
"breadcrumb": {
"home": "صفحه اصلی"
"home": "صفحه اصلی",
"aria": "مسیر ناوبری",
"root": {
"personal_drive": "درایو شخصی: {{name}}",
"shared_drive": "درایو اشتراکی: {{name}}",
"direct_share": "با شما به اشتراک گذاشته شده",
"token": "از طریق پیوند اشتراکی"
}
},
"trash": {
"empty_trash": "خالی کردن سطل زباله",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un groupe portant ce nom existe déjà."
},
"breadcrumb": {
"home": "Accueil"
"home": "Accueil",
"aria": "Fil d’Ariane",
"root": {
"personal_drive": "Disque personnel : {{name}}",
"shared_drive": "Disque partagé : {{name}}",
"direct_share": "Partagé avec vous",
"token": "Via un lien partagé"
}
},
"trash": {
"empty_trash": "Vider la corbeille",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
},
"breadcrumb": {
"home": "होम"
"home": "होम",
"aria": "ब्रेडक्रम्ब",
"root": {
"personal_drive": "निजी ड्राइव: {{name}}",
"shared_drive": "साझा ड्राइव: {{name}}",
"direct_share": "आपके साथ साझा किया गया",
"token": "साझा लिंक के माध्यम से"
}
},
"trash": {
"empty_trash": "रद्दी खाली करें",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Un gruppo con questo nome esiste già."
},
"breadcrumb": {
"home": "Home"
"home": "Home",
"aria": "Percorso di navigazione",
"root": {
"personal_drive": "Unità personale: {{name}}",
"shared_drive": "Unità condivisa: {{name}}",
"direct_share": "Condiviso con te",
"token": "Tramite link condiviso"
}
},
"trash": {
"empty_trash": "Svuota il cestino",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "この名前のグループはすでに存在します。"
},
"breadcrumb": {
"home": "ホーム"
"home": "ホーム",
"aria": "パンくずリスト",
"root": {
"personal_drive": "個人ドライブ: {{name}}",
"shared_drive": "共有ドライブ: {{name}}",
"direct_share": "あなたに共有されました",
"token": "共有リンク経由"
}
},
"trash": {
"empty_trash": "ゴミ箱を空にする",
+8 -1
View File
@@ -570,7 +570,14 @@
"forbidden": "파일을 불러올 수 없습니다"
},
"breadcrumb": {
"home": "홈"
"home": "홈",
"aria": "탐색 경로",
"root": {
"personal_drive": "개인 드라이브: {{name}}",
"shared_drive": "공유 드라이브: {{name}}",
"direct_share": "내게 공유됨",
"token": "공유 링크로"
}
},
"trash": {
"empty_trash": "휴지통 비우기",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Er bestaat al een groep met deze naam."
},
"breadcrumb": {
"home": "Start"
"home": "Start",
"aria": "Kruimelpad",
"root": {
"personal_drive": "Persoonlijke schijf: {{name}}",
"shared_drive": "Gedeelde schijf: {{name}}",
"direct_share": "Met u gedeeld",
"token": "Via gedeelde link"
}
},
"trash": {
"empty_trash": "Prullenbak legen",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Grupa o tej nazwie już istnieje."
},
"breadcrumb": {
"home": "Strona główna"
"home": "Strona główna",
"aria": "Ścieżka nawigacji",
"root": {
"personal_drive": "Dysk osobisty: {{name}}",
"shared_drive": "Dysk współdzielony: {{name}}",
"direct_share": "Udostępniono Tobie",
"token": "Przez udostępniony link"
}
},
"trash": {
"empty_trash": "Opróżnij kosz",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Já existe um grupo com este nome."
},
"breadcrumb": {
"home": "Início"
"home": "Início",
"aria": "Trilha de navegação",
"root": {
"personal_drive": "Unidade pessoal: {{name}}",
"shared_drive": "Unidade compartilhada: {{name}}",
"direct_share": "Compartilhado com você",
"token": "Via link compartilhado"
}
},
"trash": {
"empty_trash": "Esvaziar lixeira",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "Группа с таким именем уже существует."
},
"breadcrumb": {
"home": "Главная"
"home": "Главная",
"aria": "Хлебные крошки",
"root": {
"personal_drive": "Личный диск: {{name}}",
"shared_drive": "Общий диск: {{name}}",
"direct_share": "Поделено с вами",
"token": "Через общую ссылку"
}
},
"trash": {
"empty_trash": "Очистить корзину",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "已存在同名群組。"
},
"breadcrumb": {
"home": "主頁"
"home": "主頁",
"aria": "導覽路徑",
"root": {
"personal_drive": "個人雲端硬碟:{{name}}",
"shared_drive": "共用雲端硬碟:{{name}}",
"direct_share": "已分享給您",
"token": "透過分享連結"
}
},
"trash": {
"empty_trash": "清空回收站",
+8 -1
View File
@@ -459,7 +459,14 @@
"group_name_taken": "同名组已存在。"
},
"breadcrumb": {
"home": "主页"
"home": "主页",
"aria": "面包屑",
"root": {
"personal_drive": "个人云盘:{{name}}",
"shared_drive": "共享云盘:{{name}}",
"direct_share": "已共享给您",
"token": "通过共享链接"
}
},
"trash": {
"empty_trash": "清空回收站",
+114 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
use crate::application::dtos::display_helpers::intern_display;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto, RoleDto};
use crate::domain::entities::folder::Folder;
use crate::domain::services::authorization::ResourceKind;
use chrono::{DateTime, Utc};
@@ -368,3 +368,116 @@ 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>,
/// SHARER — the user who created the grant that gave the caller
/// access at the boundary (`storage.role_grants.granted_by`). Kind
/// is always `User` today: `granted_by` references `auth.users` and
/// a group can't perform an action. Null when the boundary can't be
/// resolved to a single grant (e.g. `token` access).
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<AccessSourceSubjectDto>,
/// Caller's own role via the boundary grant (`role_grants.role` on
/// the same row that carries `granted_by`). Lets the FE render
/// permission-aware affordances — "you can Edit / Comment /
/// View this share" — without a second lookup. Reflects the boundary
/// grant only: aggregate effective role via other channels may be
/// stronger. Null on `token` access.
#[serde(skip_serializing_if = "Option::is_none")]
pub caller_role: Option<RoleDto>,
}
#[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,
}
+163 -3
View File
@@ -1,8 +1,11 @@
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::drive_dto::DriveKindDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
MoveFolderDto, RenameFolderDto,
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
FolderResourceCursor, FolderResourceRow, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::RoleDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::external_mount_ports::MountEntry;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
@@ -15,7 +18,7 @@ use crate::application::services::mount_dto::{
use crate::application::services::mount_registry::MountConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Role, Subject};
use crate::domain::services::external_mount_id::NodeId;
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -1004,6 +1007,163 @@ 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];
// Subject enrichment: identify the specific grant that gave the
// caller access to `top`, then resolve its subject's display
// name in the same query. Drives the tooltip on the breadcrumb
// root chip ("Shared with you by Alice" / "Shared with your
// team via Design"). No `expires_at` filter — the ancestor
// walk's guard already proved the caller is authorized to see
// this ancestor, so the follow-up name lookup is display-only
// (see `feedback_trust_grant_janitor_no_expires_at_read`).
let (grant_resource_type, grant_resource_id) = if top.has_drive_grant {
("drive", top.drive_id)
} else {
("folder", top.id)
};
let grant_by = self
.folder_storage
.fetch_grant_by(caller_id, grant_resource_type, grant_resource_id)
.await?;
let subject = grant_by
.as_ref()
.map(
|(subject_type_str, subject_id, name, _role)| AccessSourceSubjectDto {
kind: match subject_type_str.as_str() {
"group" => AccessSourceSubjectKind::Group,
_ => AccessSourceSubjectKind::User,
},
id: *subject_id,
name: name.clone(),
},
);
// Caller's role via the boundary grant. `Role::parse` returns
// None only if the SQL stored a role we don't understand — the
// ENUM constraint makes that a schema drift, not a runtime case
// to chase. Silent None keeps the endpoint working with an older
// deployment if a future role is added ahead of the code.
let caller_role = grant_by
.as_ref()
.and_then(|(_, _, _, role_str)| Role::parse(role_str))
.map(RoleDto::from);
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,
caller_role,
}
} else {
// Direct folder-level grant (share). Subject carries who
// shared it (user or group), enabling "shared with you by X"
// in the FE tooltip.
AccessSourceDto {
kind: AccessSourceKind::DirectShare,
drive: None,
subject,
caller_role,
}
};
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,155 @@ 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}")))
}
/// Boundary-grant SHARER (`granted_by`) for `AccessSourceDto.subject`.
/// Given the resource (drive or folder) the caller reached the topmost
/// accessible ancestor through, find one active grant THAT CONCERNS
/// THE CALLER (user grant on caller, or group grant on one of the
/// caller's groups) and return `(kind, id, name)` for the user who
/// CREATED that grant — the sharer, not the grantee. The breadcrumb
/// consumer wants "who shared this with me?" not "who has permission?"
/// (Ed 2026-07-27).
///
/// Kind is always `user`: `granted_by` references `auth.users` and
/// is never a group (a group can't perform an action). Name is
/// looked up from `auth.users.username` in the same round-trip.
///
/// Grant selection prefers a user grant on the caller over a group
/// grant on one of their groups when both exist on the same resource
/// (the more specific one is likely the truer "who shared this with
/// me"). Only the OUTPUT pivots to the grantor.
///
/// No `expires_at` filter — the ancestor-walk guard has already
/// established the caller is authorized to see this ancestor
/// (visibility decision made upstream). See
/// `feedback_trust_grant_janitor_no_expires_at_read` — this is the
/// narrow exception where skipping is safe.
pub async fn fetch_grant_by(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<Option<(String, Uuid, Option<String>, String)>, DomainError> {
// Same row also carries the caller's role — piggyback the lookup
// so consumers can render "who shared this" AND "what can I do
// with it" from one round-trip (Ed 2026-07-27). The role is the
// grant's own role, i.e. the caller's effective role via THIS
// specific boundary grant. If the caller has additional grants
// via other channels the aggregate effective role may differ;
// `caller_role` on the boundary DTO reflects the boundary grant
// only.
let sql = r#"
SELECT
'user'::text AS kind,
g.granted_by AS id,
(SELECT u.username FROM auth.users u WHERE u.id = g.granted_by) AS name,
g.role::text AS role
FROM storage.role_grants g
WHERE g.resource_type = $2
AND g.resource_id = $3::uuid
AND ( (g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1))) )
ORDER BY g.subject_type = 'user' DESC
LIMIT 1
"#;
sqlx::query_as::<_, (String, Uuid, Option<String>, String)>(sql)
.bind(caller_id)
.bind(resource_type)
.bind(resource_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("grant by lookup: {e}")))
}
/// Drive header (`id + name + kind`) for the drive-source arm of
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
/// proved drive-membership via the ancestor walk before invoking.
///
/// Drive name lives on the drive's root folder, not the drive row
/// itself (`docs/plan/drive.md §3`). The JOIN resolves it; a drive
/// with a NULL `root_folder_id` returns None (backfill invariant
/// violation — surfaced as "drive vanished mid-query" in the caller).
pub async fn fetch_drive_header(
&self,
drive_id: Uuid,
) -> Result<Option<(Uuid, String, String)>, DomainError> {
sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT d.id, fo.name, d.kind::text \
FROM storage.drives d \
JOIN storage.folders fo ON fo.id = d.root_folder_id \
WHERE d.id = $1::uuid",
)
.bind(drive_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive header lookup: {e}")))
}
/// Cursor-paginated combined listing of sub-folders and files inside
/// `parent_id`, sorted by `order_by`.
///
+36 -2
View File
@@ -12,8 +12,8 @@ use crate::application::dtos::display_helpers::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
CreateFolderDto, FolderAncestorsDto, FolderDto, FolderResourceItemDto, FolderResourcesDto,
FolderResourcesQuery, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry;
@@ -112,6 +112,21 @@ impl FolderHandler {
}
}
/// `GET /api/folders/{id}/ancestors` — parent-chain + access-source
/// for the shared breadcrumb component. See `FolderAncestorsDto`
/// for the response shape. Anti-enum via `NotFound` on Read denial.
pub(super) async fn get_folder_ancestors_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_ancestors_with_perms(&id, auth_user.id).await {
Ok(dto) => (StatusCode::OK, Json(dto)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
/// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure.
pub(super) async fn list_root_folders_impl(
@@ -367,6 +382,25 @@ pub async fn get_folder(
FolderHandler::get_folder_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders/{id}/ancestors",
params(("id" = String, Path, description = "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary.")),
responses(
(status = 200, description = "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). See `FolderAncestorsDto`.", body = FolderAncestorsDto),
(status = 404, description = "Folder not found or caller lacks Read (anti-enum)"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn get_folder_ancestors(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
FolderHandler::get_folder_ancestors_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders",
+12 -1
View File
@@ -20,7 +20,9 @@ use crate::application::dtos::favorites_dto::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
@@ -96,6 +98,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// Folder handlers (free functions — see folder_handler.rs for why)
handlers::folder_handler::create_folder,
handlers::folder_handler::get_folder,
handlers::folder_handler::get_folder_ancestors,
handlers::folder_handler::list_root_folders,
handlers::folder_handler::list_folder_resources,
handlers::folder_handler::rename_folder,
@@ -264,6 +267,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
FolderListingDto,
FolderResourceItemDto,
ResourceContentDto,
// Folder ancestor chain (breadcrumb endpoint)
FolderAncestorsDto,
FolderAncestorDto,
AccessSourceDto,
AccessSourceKind,
AccessSourceDriveDto,
AccessSourceSubjectDto,
AccessSourceSubjectKind,
// File schemas
FileDto,
// Delta-upload schemas
+6 -1
View File
@@ -82,7 +82,7 @@ use crate::interfaces::api::handlers::file_handler::{
list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
};
use crate::interfaces::api::handlers::folder_handler::{
create_folder, delete_folder_with_trash, download_folder_zip, get_folder,
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, get_folder_ancestors,
list_folder_resources, list_root_folders, move_folder, rename_folder,
};
use crate::interfaces::api::handlers::i18n_handler::{
@@ -218,6 +218,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
// Ancestor chain for the shared breadcrumb component — one
// round-trip vs the pre-2026-07-26 per-segment `getFolder` walk
// on the /files client. Returns caller-visible parents (walk
// stops at share/drive-membership boundary) + access_source.
.route("/{id}/ancestors", get(get_folder_ancestors))
.route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder))
.with_state(app_state.clone());
+192
View File
@@ -0,0 +1,192 @@
# =============================================================
# OxiCloud — GET /api/folders/{id}/ancestors
# =============================================================
# Pins the shared-breadcrumb endpoint. Coverage:
# 1. Own personal drive: leaf returns full chain [root, sub, leaf]
# with access_source.kind = "drive" + drive info.
# 2. Drive-root leaf: chain has a single element (the root itself).
# 3. Anti-enum: unknown UUID / no-Read → 404 (not 403).
# 4. Cross-user: ancestors_stranger can't read admin's folder → 404.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login + ancestors_stranger provisioning
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
GET {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
admin_home_id: jsonpath "$[0].id"
# Anti-enum registration: 200 whether ancestors_stranger existed or not.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "ancestors_stranger",
"email": "ancestors_stranger@example.com",
"password": "BobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "ancestors_stranger", "password": "BobPassword1!" }
HTTP 200
[Captures]
ancestors_stranger_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — Create a small tree under admin's home:
# home > ancestors-test > child > grandchild
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "ancestors-test", "parent_id": "{{admin_home_id}}" }
HTTP 201
[Captures]
mid_folder_id: jsonpath "$.id"
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "child", "parent_id": "{{mid_folder_id}}" }
HTTP 201
[Captures]
child_folder_id: jsonpath "$.id"
POST {{base_url}}/api/folders
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "grandchild", "parent_id": "{{child_folder_id}}" }
HTTP 201
[Captures]
leaf_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Ancestors on the deepest leaf.
# Chain must be root → mid → child → grandchild.
# access_source.kind = "drive" (admin owns the personal drive).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{leaf_folder_id}}/ancestors
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ancestors" count == 4
jsonpath "$.ancestors[0].id" == "{{admin_home_id}}"
jsonpath "$.ancestors[0].parent_id" == null
# Every ancestor carries drive_id (post-2026-07-26 addition) so
# /files can derive the current drive without an extra `getFolder`.
# Folders in one chain share a drive; checking `isString` on the
# leaf is enough — Hurl can't cleanly assert field equality across
# path indices.
jsonpath "$.ancestors[0].drive_id" isString
jsonpath "$.ancestors[3].drive_id" isString
jsonpath "$.ancestors[1].id" == "{{mid_folder_id}}"
jsonpath "$.ancestors[1].name" == "ancestors-test"
jsonpath "$.ancestors[1].parent_id" == "{{admin_home_id}}"
jsonpath "$.ancestors[2].id" == "{{child_folder_id}}"
jsonpath "$.ancestors[2].name" == "child"
jsonpath "$.ancestors[3].id" == "{{leaf_folder_id}}"
jsonpath "$.ancestors[3].name" == "grandchild"
jsonpath "$.access_source.kind" == "drive"
jsonpath "$.access_source.drive.id" isString
jsonpath "$.access_source.drive.name" isString
jsonpath "$.access_source.drive.kind" == "personal"
# Subject enrichment (2026-07-27): field carries the SHARER
# (`role_grants.granted_by`), not the grantee. On admin's own personal
# drive the drive grant is self-seeded with `granted_by = admin`, so
# the assertion still resolves to `{{username}}` — but the semantic is
# "who shared this?" and would surface a different name on a folder
# shared with admin by someone else.
jsonpath "$.access_source.subject.kind" == "user"
jsonpath "$.access_source.subject.id" isString
jsonpath "$.access_source.subject.name" == "{{username}}"
# Caller's role via the boundary grant (2026-07-27) — piggybacked on
# the same `role_grants` row that carries `granted_by`. Personal
# drive owner grant is `owner`.
jsonpath "$.access_source.caller_role" == "owner"
# ─────────────────────────────────────────────────────────────
# Step 3 — Drive-root leaf. Chain is one element (the root).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/{{admin_home_id}}/ancestors
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ancestors" count == 1
jsonpath "$.ancestors[0].id" == "{{admin_home_id}}"
jsonpath "$.ancestors[0].parent_id" == null
jsonpath "$.access_source.kind" == "drive"
# ─────────────────────────────────────────────────────────────
# Step 4 — Anti-enum: unknown-UUID and cross-user access both
# return 404 (never 403). A well-formed UUID that
# doesn't exist and a real folder the caller can't
# Read produce the same shape — attackers can't
# distinguish "no such folder" from "not yours."
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders/00000000-0000-0000-0000-000000000000/ancestors
Authorization: Bearer {{admin_token}}
HTTP 404
GET {{base_url}}/api/folders/{{leaf_folder_id}}/ancestors
Authorization: Bearer {{ancestors_stranger_token}}
HTTP 404
# Middleware-level 401 (no auth) is deliberately NOT tested here.
# Prior login steps in this file leave Hurl's cookie jar populated,
# so an omitted `Authorization:` header still authenticates via cookie
# and lands on the handler — which returns the endpoint's anti-enum
# 404 rather than the middleware 401. The middleware 401 case is
# pinned separately at the TOP of `search_basic.hurl`, before any
# login has run.
# ─────────────────────────────────────────────────────────────
# Step 5 — Teardown: recursive delete of the top folder takes
# the whole subtree. `DELETE /api/folders/{id}` is a
# soft-delete-to-trash — every downstream test that
# expects an empty trash (`trash.hurl`, `trash_resources.hurl`,
# …) would find our orphan. Follow up with `empty` so
# the trash returns to its clean baseline.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{mid_folder_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/trash/empty
Authorization: Bearer {{admin_token}}
HTTP 200
+1
View File
@@ -157,6 +157,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/nc_ocs_user_info.hurl" \
"$API_DIR/nc_avatar_preview.hurl" \
"$API_DIR/files-folders.hurl" \
"$API_DIR/folder_ancestors.hurl" \
"$API_DIR/photos_etag.hurl" \
"$API_DIR/favorites.hurl" \
"$API_DIR/trash.hurl" \
+12 -2
View File
@@ -205,9 +205,12 @@ test('breadcrumb navigates back to home', async ({ page }) => {
const folderName = uniq('Crumb');
const folder = await apiCreateFolder(page, folderName);
await page.goto(`/files/${folder.id}`);
// Breadcrumb home link leaves the subfolder for the root listing. Bare /files
// Breadcrumb root chip leaves the subfolder for the root listing. Bare /files
// canonicalizes to the user's drive root, where the just-created folder lives.
await page.getByTestId('files-breadcrumb-home-link').click();
// Testid changed to `folder-breadcrumb-root-link` when the breadcrumb was
// extracted into the shared `<FolderBreadcrumb>` component (used by /files
// AND /search) — the /files-specific `files-breadcrumb-*` prefix retired.
await page.getByTestId('folder-breadcrumb-root-link').click();
await expect(page).not.toHaveURL(new RegExp(folder.id));
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
});
@@ -247,6 +250,13 @@ test('copy a folder into another (copy mode keeps the source)', async ({ page })
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
await page.getByTestId('move-dialog-confirm-btn').click();
// MoveDialog closes only AFTER `await copyFolders(...)` resolves, so
// waiting for its disappearance is equivalent to waiting for the
// batch-copy request to complete. Without this the navigation to
// /files/{dest.id} below can race the in-flight POST and land on
// an empty listing.
await expect(page.getByTestId('move-dialog')).toHaveCount(0, { timeout: 15_000 });
// Copy leaves the source in place.
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
// And a copy now lives in the destination.