feat(breadcrumb): build breadcrumb in 1 API call
add /api/folders/{id}/ancestors
this API to iterate parent up to the drive root or the shared folder
this will help UI to build the breadcrumb in 1 API call
and to identify the root element (is it a drive users has access to or
a shared folder ?)
ui: now only 1 API call is now required to build the breadcrumb
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
import type { FileItem, FolderAncestorsResponse, FolderItem, ItemType } from '$lib/api/types';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
const NO_CACHE: RequestInit = {
|
||||
@@ -109,6 +109,35 @@ export function getFolder(id: string): Promise<FolderItem> {
|
||||
return request;
|
||||
}
|
||||
|
||||
// ── Ancestor chain (breadcrumb) ──────────────────────────────────────────
|
||||
// Backing store + inflight dedup for `GET /api/folders/{id}/ancestors` —
|
||||
// mirrors the folderInflight pattern for `getFolder`. Rapid navigation
|
||||
// (files → sub → sub-sub in <1s) folds concurrent requests for the same
|
||||
// leaf into one round-trip. Response also seeds `folderNames` for every
|
||||
// ancestor, so subsequent `getFolderName(id)` lookups are cache-free.
|
||||
const ancestorsInflight = new Map<string, Promise<FolderAncestorsResponse>>();
|
||||
|
||||
export function getFolderAncestors(id: string): Promise<FolderAncestorsResponse> {
|
||||
const inflight = ancestorsInflight.get(id);
|
||||
if (inflight) return inflight;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const chain = await apiJson<FolderAncestorsResponse>(
|
||||
`/api/folders/${id}/ancestors`,
|
||||
NO_CACHE
|
||||
);
|
||||
// Prime the shared folder-name cache — the breadcrumb walk
|
||||
// happens to be the exact input that populates it.
|
||||
for (const a of chain.ancestors) rememberFolderName(a.id, a.name);
|
||||
return chain;
|
||||
} finally {
|
||||
ancestorsInflight.delete(id);
|
||||
}
|
||||
})();
|
||||
ancestorsInflight.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** One page of `/api/folders/{id}/resources`. */
|
||||
export interface FolderPage {
|
||||
/**
|
||||
|
||||
@@ -413,3 +413,68 @@ export interface DriveMember {
|
||||
granted_at: string;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
// ─── Folder ancestors (breadcrumb endpoint) ──────────────────────────────
|
||||
// Wire shape of `GET /api/folders/{id}/ancestors`. Mirrors the backend
|
||||
// `FolderAncestorsDto` — see `src/application/dtos/folder_dto.rs`. One
|
||||
// round-trip returns the whole caller-visible parent chain plus an
|
||||
// `access_source` telling the breadcrumb component which root icon /
|
||||
// tooltip to render.
|
||||
|
||||
export interface FolderAncestor {
|
||||
id: string;
|
||||
name: string;
|
||||
/** `null` on the drive-root ancestor. */
|
||||
parent_id: string | null;
|
||||
/**
|
||||
* Drive the folder belongs to (always populated — every folder has a
|
||||
* drive_id post-D0). Lets `/files` derive `currentFolderDriveId` from
|
||||
* the ancestors response instead of firing an extra
|
||||
* `GET /api/folders/{id}` on load. Same value across every entry in
|
||||
* `ancestors` (all folders in a chain live in one drive).
|
||||
*/
|
||||
drive_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the caller reached the topmost accessible ancestor.
|
||||
* - `drive` — via drive membership (own personal, secondary personal, or
|
||||
* shared drive). `drive` field carries the drive's id/name/kind for
|
||||
* the root icon.
|
||||
* - `direct_share` — via a folder-level `role_grants` row (share).
|
||||
* `subject` may name the grantee (self or a group) once subject
|
||||
* enrichment lands; MVP leaves it null.
|
||||
* - `token` — reserved for public-link callers. Not emitted today.
|
||||
*/
|
||||
export type AccessSourceKind = 'drive' | 'direct_share' | 'token';
|
||||
|
||||
export interface AccessSourceDrive {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DriveKind;
|
||||
}
|
||||
|
||||
export interface AccessSourceSubject {
|
||||
kind: 'user' | 'group';
|
||||
id: string;
|
||||
/** Nullable in MVP (subject enrichment deferred). */
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface AccessSource {
|
||||
kind: AccessSourceKind;
|
||||
/** Populated when `kind === 'drive'`. */
|
||||
drive?: AccessSourceDrive;
|
||||
/** Optional grantee info for shares / group grants. */
|
||||
subject?: AccessSourceSubject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response envelope of `GET /api/folders/{id}/ancestors`. `ancestors`
|
||||
* is root-first, leaf-last (length ≥ 1). `access_source` describes
|
||||
* the boundary at element 0 (drive root or share boundary).
|
||||
*/
|
||||
export interface FolderAncestorsResponse {
|
||||
ancestors: FolderAncestor[];
|
||||
access_source: AccessSource;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { getFolderAncestors } from '$lib/api/endpoints/folders';
|
||||
import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
/**
|
||||
* Shared breadcrumb component consuming
|
||||
* `GET /api/folders/{id}/ancestors`. Renders the root icon
|
||||
* (`access_source.kind` — drive / share / link) + a clickable
|
||||
* chain of caller-visible ancestors down to the leaf.
|
||||
*
|
||||
* The endpoint's walk stops at the caller's share/drive-membership
|
||||
* boundary, so this component never shows a folder the caller can't
|
||||
* Read. If `folderId` is null (e.g. `/search` in "Everywhere" scope,
|
||||
* or /files at the root listing) the component renders nothing.
|
||||
*
|
||||
* Optional `onDrop` prop enables `/files`-style drop-target behavior
|
||||
* on each crumb (move dragged items into the target folder). Absent
|
||||
* everywhere else. Uses the `application/x-oxi-item` MIME the row-drag
|
||||
* emits — pass a matching handler.
|
||||
*/
|
||||
interface Props {
|
||||
/** Leaf folder id; null renders the component as empty. */
|
||||
folderId: string | null | undefined;
|
||||
/**
|
||||
* Optional drop handler — enables per-crumb drop targets when
|
||||
* provided. Called with the target folder id + the raw drop
|
||||
* event; the caller performs the move.
|
||||
*/
|
||||
onDrop?: (targetFolderId: string, e: DragEvent) => void;
|
||||
/** MIME type of the row-drag payload — defaults to the shipped one. */
|
||||
dragMime?: string;
|
||||
}
|
||||
let { folderId, onDrop, dragMime = 'application/x-oxi-item' }: Props = $props();
|
||||
|
||||
// Fetch chain when folderId changes. `$state` + `$effect` primer
|
||||
// avoids blocking the initial render — the breadcrumb slot appears
|
||||
// empty until the first response, then fills in.
|
||||
let chain = $state<FolderAncestorsResponse | null>(null);
|
||||
let dropTargetId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const id = folderId;
|
||||
if (!id) {
|
||||
chain = null;
|
||||
return;
|
||||
}
|
||||
void getFolderAncestors(id)
|
||||
.then((c) => {
|
||||
// Guard against out-of-order responses if `folderId`
|
||||
// changed while awaiting.
|
||||
if (folderId === id) chain = c;
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent failure — the breadcrumb collapses to empty. The
|
||||
// consuming page still shows its main content (folder
|
||||
// listing / search results); a missing crumb strip is a
|
||||
// degraded-but-usable state, not a fatal one.
|
||||
if (folderId === id) chain = null;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ancestors to render as crumbs, with the drive-root deduplicated
|
||||
* when access is via drive-membership. Rationale (Ed 2026-07-26):
|
||||
* for drive-kind access, the topmost accessible ancestor IS the
|
||||
* drive's root folder, and the drive's display name equals the
|
||||
* root folder's name (`docs/plan/drive.md §3` — a drive has no
|
||||
* `name` column, its name lives on its root folder). So the pre-
|
||||
* fix breadcrumb rendered `Personal > Personal > child > …` for
|
||||
* personal drives and `my family > my family > child > …` for
|
||||
* shared. The root chip already labels the drive; dropping the
|
||||
* duplicate first crumb collapses to the natural `[home] Personal
|
||||
* > child > …` shape.
|
||||
*
|
||||
* For `direct_share` / `token` access, the topmost ancestor is a
|
||||
* shared folder (not a drive root), so no dedup — every ancestor
|
||||
* survives.
|
||||
*/
|
||||
const visibleCrumbs = $derived<FolderAncestor[]>(
|
||||
chain
|
||||
? chain.access_source.kind === 'drive' && chain.ancestors.length > 0
|
||||
? chain.ancestors.slice(1)
|
||||
: chain.ancestors
|
||||
: []
|
||||
);
|
||||
|
||||
// ── Root-icon derivation ────────────────────────────────────────────
|
||||
// One icon per `access_source.kind`. Personal drives use the home
|
||||
// glyph (they're the caller's own storage — signalling "home base");
|
||||
// shared drives use `users` (multi-member). Ed's 2026-07-26 UX call
|
||||
// bumped from the pre-fix `hard-drive` because personal drives
|
||||
// deserve the same "you're on your own turf" visual affordance the
|
||||
// legacy /files rootIcon used.
|
||||
function rootIcon(src: AccessSource): string {
|
||||
if (src.kind === 'drive') {
|
||||
return src.drive?.kind === 'shared' ? 'users' : 'home';
|
||||
}
|
||||
if (src.kind === 'direct_share') return 'share-alt';
|
||||
if (src.kind === 'token') return 'link';
|
||||
return 'home';
|
||||
}
|
||||
|
||||
function rootTooltip(src: AccessSource): string {
|
||||
if (src.kind === 'drive' && src.drive) {
|
||||
return src.drive.kind === 'shared'
|
||||
? t('breadcrumb.root.shared_drive', { name: src.drive.name }, 'Shared drive: {{name}}')
|
||||
: t('breadcrumb.root.personal_drive', { name: src.drive.name }, 'Personal drive: {{name}}');
|
||||
}
|
||||
if (src.kind === 'direct_share') {
|
||||
return t('breadcrumb.root.direct_share', 'Shared with you');
|
||||
}
|
||||
if (src.kind === 'token') {
|
||||
return t('breadcrumb.root.token', 'Via shared link');
|
||||
}
|
||||
return t('breadcrumb.home', 'Home');
|
||||
}
|
||||
|
||||
/**
|
||||
* Href for the root chip. For drive-kind access, links to the
|
||||
* drive's root folder (the ancestor we deduped above) so the user
|
||||
* can jump home from any depth. For share/token access the "root"
|
||||
* is an abstract boundary with no navigable page — stays null and
|
||||
* the template renders the chip as a non-clickable `<span>`.
|
||||
* Hoisted here (not `{@const}` inside `<nav>`) because Svelte 5
|
||||
* only allows `{@const}` as an immediate child of specific block
|
||||
* tags — plain HTML elements don't qualify.
|
||||
*/
|
||||
// Root chip href. Two "clickable root" cases:
|
||||
// • drive-kind → the drive root folder (the ancestor we dedup
|
||||
// out of the chain above), so users can jump home from any
|
||||
// depth without leaving the /files context.
|
||||
// • direct_share → `/shared-with-me`, so users can back out to
|
||||
// the full listing of what's been shared with them (Ed's
|
||||
// 2026-07-26 UX ask: "when I clic on it that goes back to
|
||||
// /shared-with-me").
|
||||
// Token access stays non-clickable — there's no equivalent user-
|
||||
// facing surface for a public-link session.
|
||||
//
|
||||
// Store the UNRESOLVED path here; `resolve()` runs in the template
|
||||
// so the `svelte/no-navigation-without-resolve` lint sees the
|
||||
// resolve call at the href site (the rule can't follow a state
|
||||
// variable back to its assignment).
|
||||
// Narrow union so SvelteKit's route-checked `resolve()` accepts it.
|
||||
// The two paths are the only ones this component ever emits.
|
||||
type RootHref = '/shared-with-me' | `/files/${string}`;
|
||||
|
||||
// True when the caller is AT the drive root (or share boundary) —
|
||||
// no descendant crumbs to render. The root chip IS the current
|
||||
// location and gets the `breadcrumb-current` bold treatment.
|
||||
const isRootTheLeaf = $derived(chain !== null && visibleCrumbs.length === 0);
|
||||
|
||||
// Root href stays populated even when root-is-leaf — clicking a leaf
|
||||
// crumb is a real navigation (from `/search` it jumps INTO the folder;
|
||||
// from `/files` at drive root it's a self-navigation no-op). Ed's
|
||||
// 2026-07-26 UX call: "all elements clickable, only the leaf bold."
|
||||
const rootHrefPath = $derived<RootHref | null>(
|
||||
chain === null
|
||||
? null
|
||||
: chain.access_source.kind === 'drive' && chain.ancestors.length > 0
|
||||
? `/files/${chain.ancestors[0].id}`
|
||||
: chain.access_source.kind === 'direct_share'
|
||||
? '/shared-with-me'
|
||||
: null
|
||||
);
|
||||
|
||||
// Drop target for the root chip. Only meaningful when the root
|
||||
// resolves to a real folder (drive root). `/shared-with-me` is a
|
||||
// virtual listing — nothing to drop INTO — so direct_share and
|
||||
// token variants stay drop-inert even when the chip is clickable.
|
||||
const rootDropTarget = $derived<string | null>(
|
||||
chain && chain.access_source.kind === 'drive' ? (chain.ancestors[0]?.id ?? null) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if chain && (visibleCrumbs.length > 0 || chain.access_source.kind === 'drive')}
|
||||
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
|
||||
<!--
|
||||
Root chip: `<a>` when drive-kind access (jumps to the drive
|
||||
root — the ancestor we dedup out of the chain above), `<span>`
|
||||
for share/token (abstract boundary, no navigable target).
|
||||
Icon + tooltip both derive from `access_source.kind`; the drive
|
||||
arm additionally paints the drive name next to the icon so the
|
||||
user sees which drive they're browsing at a glance.
|
||||
-->
|
||||
{#if rootHrefPath}
|
||||
<a
|
||||
href={resolve(rootHrefPath)}
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
class:breadcrumb-current={isRootTheLeaf}
|
||||
class:drop-target={onDrop != null &&
|
||||
rootDropTarget != null &&
|
||||
dropTargetId === rootDropTarget}
|
||||
title={rootTooltip(chain.access_source)}
|
||||
data-testid="folder-breadcrumb-root-link"
|
||||
data-access-kind={chain.access_source.kind}
|
||||
ondragover={onDrop && rootDropTarget
|
||||
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
|
||||
: undefined}
|
||||
ondragenter={onDrop && rootDropTarget
|
||||
? (e) => {
|
||||
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = rootDropTarget;
|
||||
}
|
||||
: undefined}
|
||||
ondragleave={onDrop && rootDropTarget
|
||||
? () => {
|
||||
if (dropTargetId === rootDropTarget) dropTargetId = null;
|
||||
}
|
||||
: undefined}
|
||||
ondrop={onDrop && rootDropTarget
|
||||
? (e) => {
|
||||
dropTargetId = null;
|
||||
onDrop(rootDropTarget, e);
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<Icon name={rootIcon(chain.access_source)} />
|
||||
{#if chain.access_source.kind === 'drive' && chain.access_source.drive}
|
||||
<span class="breadcrumb-root-name">{chain.access_source.drive.name}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<!--
|
||||
Non-link root chip. Three cases land here:
|
||||
1. `access_source.kind === 'token'` — no navigable target.
|
||||
2. Drive-kind AND caller is AT the drive root (no
|
||||
descendant crumbs). Gets `breadcrumb-current` so the
|
||||
styling matches a deep-folder leaf (bold, no
|
||||
underline) — Ed's 2026-07-26 UX ask: keep the leaf
|
||||
look consistent regardless of depth.
|
||||
3. Drive-kind with no ancestors at all (degenerate).
|
||||
Drop target only wires when there's a real folder id AND
|
||||
the caller opted in with an `onDrop` handler.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="breadcrumb-item breadcrumb-home"
|
||||
class:breadcrumb-current={isRootTheLeaf}
|
||||
class:drop-target={onDrop != null &&
|
||||
rootDropTarget != null &&
|
||||
dropTargetId === rootDropTarget}
|
||||
title={rootTooltip(chain.access_source)}
|
||||
data-testid="folder-breadcrumb-root-icon"
|
||||
data-access-kind={chain.access_source.kind}
|
||||
ondragover={onDrop && rootDropTarget
|
||||
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
|
||||
: undefined}
|
||||
ondragenter={onDrop && rootDropTarget
|
||||
? (e) => {
|
||||
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = rootDropTarget;
|
||||
}
|
||||
: undefined}
|
||||
ondragleave={onDrop && rootDropTarget
|
||||
? () => {
|
||||
if (dropTargetId === rootDropTarget) dropTargetId = null;
|
||||
}
|
||||
: undefined}
|
||||
ondrop={onDrop && rootDropTarget
|
||||
? (e) => {
|
||||
dropTargetId = null;
|
||||
onDrop(rootDropTarget, e);
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<Icon name={rootIcon(chain.access_source)} />
|
||||
{#if chain.access_source.kind === 'drive' && chain.access_source.drive}
|
||||
<span class="breadcrumb-root-name">{chain.access_source.drive.name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#each visibleCrumbs as c, i (c.id)}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
<!--
|
||||
Every crumb links to `/files/{id}` — leaf included (Ed's
|
||||
2026-07-26 UX call: from `/search` clicking the leaf jumps
|
||||
INTO the searched folder in one click; from `/files` a
|
||||
leaf-click is a self-navigation no-op). The leaf gets
|
||||
`breadcrumb-current` for bold styling; intermediates stay
|
||||
regular weight. No underline on either — the hover
|
||||
background alone is the affordance.
|
||||
|
||||
Drop-target props fire only when the host page passed an
|
||||
`onDrop` handler. Absent everywhere except `/files`.
|
||||
-->
|
||||
{@const isLeaf = i === visibleCrumbs.length - 1}
|
||||
<a
|
||||
href={resolve(`/files/${c.id}`)}
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-current={isLeaf}
|
||||
class:drop-target={onDrop != null && dropTargetId === c.id}
|
||||
data-testid={isLeaf ? `folder-breadcrumb-current-${c.id}` : `folder-breadcrumb-${c.id}`}
|
||||
ondragover={onDrop
|
||||
? (e) => e.dataTransfer?.types.includes(dragMime) && e.preventDefault()
|
||||
: undefined}
|
||||
ondragenter={onDrop
|
||||
? (e) => {
|
||||
if (e.dataTransfer?.types.includes(dragMime)) dropTargetId = c.id;
|
||||
}
|
||||
: undefined}
|
||||
ondragleave={onDrop
|
||||
? () => {
|
||||
if (dropTargetId === c.id) dropTargetId = null;
|
||||
}
|
||||
: undefined}
|
||||
ondrop={onDrop
|
||||
? (e) => {
|
||||
dropTargetId = null;
|
||||
onDrop(c.id, e);
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Chip attached to the drive-root icon; only present in the drive
|
||||
arm of access_source. Kept a tight max-width so a long drive name
|
||||
truncates gracefully instead of shoving the breadcrumb off-screen. */
|
||||
.breadcrumb-root-name {
|
||||
margin-left: var(--space-1);
|
||||
max-width: 12ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Drop-target flicker fix: without this the SVG icon + name chip act
|
||||
as event targets, so `dragenter` fires on the anchor → highlight
|
||||
sets → pointer crosses into a child → `dragleave` fires on the
|
||||
anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events
|
||||
off on children collapses the whole chip to a single drag target;
|
||||
drop still lands because the anchor's own handlers stay live.
|
||||
Intermediate crumbs don't need this (they contain only a text
|
||||
node — no child element to cross into). */
|
||||
.breadcrumb-home > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -504,6 +504,33 @@
|
||||
|
||||
const SKELETON = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
// ── Delayed-skeleton reveal ──────────────────────────────────────────
|
||||
// Fast fetches (< 150 ms) don't render the skeleton bars — the flash
|
||||
// is worse UX than briefly-empty content. The skeleton appears only
|
||||
// when a load is genuinely slow. Ed's 2026-07-26 report: navigating
|
||||
// from an empty folder to its parent showed "6 blank elements" (the
|
||||
// skeleton) for the ~25 ms fetch window because stale-while-revalidate
|
||||
// at the /files layer has no previous content to keep on screen here.
|
||||
//
|
||||
// Pairs with the empty-state gate below (`!loading && isEmpty`) so
|
||||
// the pre-fix "Folder is empty" flash during the delay window
|
||||
// doesn't come back — during load, neither skeleton nor empty state
|
||||
// renders; the container just holds empty until content or the
|
||||
// 150 ms timer elapses.
|
||||
let renderSkeleton = $state(false);
|
||||
$effect(() => {
|
||||
if (loading && items.length === 0) {
|
||||
const timer = setTimeout(() => {
|
||||
renderSkeleton = true;
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
renderSkeleton = false;
|
||||
};
|
||||
}
|
||||
renderSkeleton = false;
|
||||
});
|
||||
|
||||
// ── Group-by / direction ──────────────────────────────────────────────────
|
||||
const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy));
|
||||
|
||||
@@ -1319,9 +1346,15 @@
|
||||
|
||||
{#if error}
|
||||
<EmptyState icon="exclamation-circle" title={error} error />
|
||||
{:else if loading && isEmpty}
|
||||
{:else if renderSkeleton}
|
||||
<!-- Only renders after the 150 ms delay elapses AND we're still
|
||||
loading with no items — fast loads skip this entirely. -->
|
||||
<SkeletonList count={SKELETON.length} />
|
||||
{:else if isEmpty}
|
||||
{:else if isEmpty && !loading}
|
||||
<!-- Empty state gates on `!loading` (not just `isEmpty`) so
|
||||
mid-load empty-content windows don't flash the "Folder is
|
||||
empty" banner. Renders only when the fetch has definitively
|
||||
completed with zero items. -->
|
||||
<EmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyText ?? t('common.empty', 'Nothing here yet.')}
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
/* No underline at rest OR on hover — Ed's 2026-07-26 UX call: the
|
||||
hover background alone is enough affordance, and the pre-fix
|
||||
browser-default underline mixed awkwardly with the bold-leaf
|
||||
styling (leaf was bold+plain, root link was underlined+plain,
|
||||
and the styling difference read as "these do different things"
|
||||
when in fact both are simple navigations). Uniform link chrome
|
||||
via background-on-hover; the bold-current class flags the leaf. */
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-link.drop-target {
|
||||
@@ -29,15 +37,19 @@
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-bg);
|
||||
}
|
||||
|
||||
/* Applied to the LEAF crumb (last visible item in the chain) so it
|
||||
reads as "you are here". Every crumb — leaf included — is now a
|
||||
link (Ed's 2026-07-26 UX ask: from `/search` the fastest way to
|
||||
jump into the searched folder is to click its name in the crumb
|
||||
trail; making the leaf clickable serves that path with zero extra
|
||||
clicks). Only the bold weight distinguishes it from an intermediate. */
|
||||
.breadcrumb-current {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-black);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
|
||||
@@ -571,9 +571,21 @@
|
||||
}
|
||||
|
||||
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable
|
||||
devices, so touch users (no hover) keep it always tappable. Stays visible
|
||||
on keyboard focus within the row. Applies to both list and grid views
|
||||
because both keep the kebab inside `.action-cell`. */
|
||||
devices, so touch users (no hover) keep it always tappable. Applies
|
||||
to both list and grid views because both keep the kebab inside
|
||||
`.action-cell`.
|
||||
|
||||
Keyboard accessibility comes from `:focus-visible` on the kebab
|
||||
button itself (below), NOT `:focus-within` on the row. Using
|
||||
`:focus-within` on the row was a lingering-visibility trap:
|
||||
• dragstart landed focus on the dragged descendant → row
|
||||
`:focus-within` stayed true after the pointer left → kebab
|
||||
stayed visible on an otherwise-idle row.
|
||||
• Opening a context-menu / ShareDialog portal moved focus outside
|
||||
the row (good) but if focus briefly bounced through the kebab
|
||||
first, the reveal could persist through the transition.
|
||||
Ed's 2026-07-26 report: "when starting dragging or when using the
|
||||
share dialog, I have the [...] button that remains visible." */
|
||||
@media (hover: hover) {
|
||||
.files-list-view .file-item .action-cell button.file-actions,
|
||||
.files-grid-view .file-item .action-cell button.file-actions {
|
||||
@@ -582,9 +594,9 @@
|
||||
}
|
||||
|
||||
.files-list-view .file-item:hover .action-cell button.file-actions,
|
||||
.files-list-view .file-item:focus-within .action-cell button.file-actions,
|
||||
.files-grid-view .file-item:hover .action-cell button.file-actions,
|
||||
.files-grid-view .file-item:focus-within .action-cell button.file-actions {
|
||||
.files-list-view .file-item .action-cell button.file-actions:focus-visible,
|
||||
.files-grid-view .file-item .action-cell button.file-actions:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1391,10 +1403,15 @@
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Reveal on hover OR when the button itself has keyboard focus. The
|
||||
pre-fix `:focus-within` on the row was a lingering-visibility trap
|
||||
during drag / dialog transitions — see the `.file-actions` block
|
||||
above for the full rationale. `:focus-visible` on the button gives
|
||||
keyboard users the same reveal without the row-scope side effect. */
|
||||
.files-list-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-list-view .file-item:focus-within .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:focus-within .action-cell .btn-action--hover {
|
||||
.files-list-view .file-item .action-cell .btn-action--hover:focus-visible,
|
||||
.files-grid-view .file-item .action-cell .btn-action--hover:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
fetchFolderPage,
|
||||
getFolder,
|
||||
getFolderName,
|
||||
getFolderAncestors,
|
||||
invalidateFolderCache,
|
||||
moveFolder,
|
||||
rememberFolderName,
|
||||
@@ -41,6 +40,7 @@
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
||||
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
|
||||
import ResourceList, {
|
||||
isFile,
|
||||
type GroupByDef as RLGroupByDef
|
||||
@@ -48,7 +48,7 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { drives as drivesStore } from '$lib/stores/drives.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
@@ -67,26 +67,16 @@
|
||||
// /files → home root; /files/a/b → folder b inside a inside home.
|
||||
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
|
||||
|
||||
// First-crumb icon mirrors the drive at pathSegments[0]: `home` for the
|
||||
// default-personal, `folder` for a secondary personal, `users` for a
|
||||
// shared drive. Falls back to `home` while the drives list is loading
|
||||
// or when the URL's leading segment isn't a known drive root (deep-link
|
||||
// into a sub-folder bypasses drive identification — same limitation as
|
||||
// the breadcrumb name resolution).
|
||||
const rootIcon = $derived.by(() => {
|
||||
const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null);
|
||||
return drive ? driveIcon(drive) : 'home';
|
||||
});
|
||||
|
||||
// The drive whose content the user is currently browsing.
|
||||
//
|
||||
// Priorities (first match wins):
|
||||
// 1. `currentFolderDriveId` — set by `load()` after a `getFolder`
|
||||
// fetch on the current folder. Authoritative for deep-links
|
||||
// too (the URL's leading segment might not be a drive root).
|
||||
// 1. `currentFolderDriveId` — set by `load()` from the ancestors
|
||||
// response (`chain.ancestors.at(-1).drive_id`). Authoritative
|
||||
// for deep-links too (the URL's leading segment might not be a
|
||||
// drive root).
|
||||
// 2. `listing.folders[0]?.drive_id` — fast-path when the folder
|
||||
// has at least one subfolder; avoids the extra round-trip on
|
||||
// the initial `applyListing` before `getFolder` returns.
|
||||
// has at least one subfolder; avoids waiting on the ancestors
|
||||
// response before the initial `applyListing`.
|
||||
// (`FileDto` doesn't carry `drive_id` today, so we can't use
|
||||
// files as a fallback source; folders alone.)
|
||||
// 3. `drivesStore.findByRootFolderId(pathSegments[0])` — legacy
|
||||
@@ -156,11 +146,23 @@
|
||||
const hiddenCount = $derived(
|
||||
preferences.hideDotfiles ? countHidden(listing.folders) + countHidden(listing.files) : 0
|
||||
);
|
||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||
let currentId = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
// Skeleton is delayed ~100ms behind `loading` so fast loads don't flash it.
|
||||
let showSkeleton = $state(false);
|
||||
// Default `true` (not `false`) so the first render — before the
|
||||
// `$effect` fires `load()` — shows the "loading" arm of ResourceList
|
||||
// (skeleton, gated on 100 ms delay) instead of the "empty" arm
|
||||
// ("No elements here"). Ed's 2026-07-26 report: a brief empty-state
|
||||
// flash appeared between page mount and the first fetch landing.
|
||||
// `load()` still writes `loading = true` before its first await, so
|
||||
// mid-navigation clears work as before.
|
||||
let loading = $state(true);
|
||||
// `showSkeleton` used to sit 100 ms behind `loading` to avoid flashing
|
||||
// skeleton bars on fast loads. Retired 2026-07-26 because ResourceList
|
||||
// received `loading={showSkeleton}` (not the real `loading` state), so
|
||||
// during those 100 ms it saw `loading=false && items=[]` and rendered
|
||||
// the empty-state ("Folder is empty") — the flash Ed reported. Pass
|
||||
// the real `loading` instead; the skeleton renders instantly for
|
||||
// slow loads and instantly-disappears for fast loads (users don't
|
||||
// perceive a sub-100 ms frame flip).
|
||||
let error = $state<string | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let uploading = $state(false);
|
||||
@@ -219,24 +221,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
|
||||
// Names come from the cache first (every listing names its children, so
|
||||
// step-by-step navigation needs zero requests); only ids we've never seen
|
||||
// — a cold deep-link's ancestors — are fetched, in parallel.
|
||||
return Promise.all(
|
||||
segments.map(async (id) => {
|
||||
const known = getFolderName(id);
|
||||
if (known !== undefined) return { id, name: known };
|
||||
try {
|
||||
const f = await getFolder(id);
|
||||
return { id, name: f.name };
|
||||
} catch {
|
||||
return { id, name: '…' };
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Bumped on every load; a stale in-flight response checks this before it
|
||||
// writes state, so a fast navigation can't be clobbered by an older fetch.
|
||||
let loadSeq = 0;
|
||||
@@ -262,7 +246,6 @@
|
||||
const seq = ++loadSeq;
|
||||
|
||||
let folderId: string;
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (reset) {
|
||||
// External users have no home folder; send them to shared-with-me.
|
||||
if (session.isExternalUser && pathSegments.length === 0) {
|
||||
@@ -294,32 +277,55 @@
|
||||
currentId = folderId;
|
||||
filesStore.currentFolder = folderId;
|
||||
|
||||
// Reset paging state: previous folder's cursor is meaningless here,
|
||||
// and mixing its rows with the new folder's would flash a wrong list.
|
||||
pageCursor = undefined;
|
||||
listing = { folders: [], files: [] };
|
||||
orderedItems = [];
|
||||
// Reset paging state: previous folder's cursor is meaningless
|
||||
// on the new folder — must clear or the first append would
|
||||
// paginate the OLD folder's next-page slice.
|
||||
//
|
||||
// `listing` / `orderedItems` are deliberately NOT cleared —
|
||||
// the previous folder's rows stay on screen during the (~25 ms)
|
||||
// fetch, then the response handler swaps in the new folder's
|
||||
// content atomically. Stale-while-revalidate for the inter-
|
||||
// folder case (Ed 2026-07-26: the pre-refactor clear-then-
|
||||
// fetch-then-render sequence flashed either the SkeletonList
|
||||
// or the "Folder is empty" empty-state for the fetch window,
|
||||
// depending on which arm ResourceList happened to render for
|
||||
// the empty-loading state; neither is useful for a 25 ms
|
||||
// transition). First-mount (no previous content) still hits
|
||||
// the skeleton correctly because `orderedItems` defaults `[]`
|
||||
// and `loading` defaults `true` — the empty-loading arm
|
||||
// gates on that.
|
||||
loading = true;
|
||||
pageCursor = undefined;
|
||||
|
||||
// Delayed skeleton so fast loads don't flash it.
|
||||
skeletonTimer = setTimeout(() => {
|
||||
if (loading) showSkeleton = true;
|
||||
}, 100);
|
||||
// Legacy path-chain URLs canonicalize to the single-id form on
|
||||
// load. `/files/A/B/C` still resolves (router matches `[...path]`)
|
||||
// but the URL bar and any subsequent bookmark reflects the
|
||||
// canonical `/files/C` — see 2026-07-26 URL-format discussion.
|
||||
// `replaceState` (not `pushState`) so the back button doesn't
|
||||
// gain a spurious entry.
|
||||
if (pathSegments.length > 1 && typeof window !== 'undefined') {
|
||||
window.history.replaceState({}, '', resolve(`/files/${folderId}`));
|
||||
}
|
||||
|
||||
// Breadcrumbs resolve independently so they never block the grid paint.
|
||||
void buildCrumbs(pathSegments).then((trail) => {
|
||||
if (seq === loadSeq) crumbs = trail;
|
||||
});
|
||||
|
||||
// Resolve the current folder's drive_id so the read-only banner
|
||||
// works even on deep-links into a sub-folder. Guarded by `seq`.
|
||||
void getFolder(folderId)
|
||||
.then((folder) => {
|
||||
if (seq === loadSeq) currentFolderDriveId = folder.drive_id;
|
||||
// Resolve the current folder's drive_id via the ancestors
|
||||
// response — every `FolderAncestor` carries `drive_id`, so
|
||||
// the shared `<FolderBreadcrumb>`'s in-flight call is the
|
||||
// same round-trip we'd otherwise duplicate here. The
|
||||
// `ancestorsInflight` dedup map inside `getFolderAncestors`
|
||||
// means this second caller gets the same promise, not a
|
||||
// second HTTP request — the extra `getFolder(folderId)`
|
||||
// that used to fire here is gone (2026-07-26 UX pass on
|
||||
// /files load traffic).
|
||||
void getFolderAncestors(folderId)
|
||||
.then((chain) => {
|
||||
if (seq !== loadSeq) return;
|
||||
const leaf = chain.ancestors.at(-1);
|
||||
if (leaf) currentFolderDriveId = leaf.drive_id;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback chain in `currentDrive` still gives us a
|
||||
// best-effort drive resolution.
|
||||
// best-effort drive resolution (listing.folders[0].drive_id,
|
||||
// then drivesStore lookup by root-folder id).
|
||||
});
|
||||
} else {
|
||||
// Append path: reuse `currentId`. `pageCursor === undefined` means
|
||||
@@ -360,10 +366,8 @@
|
||||
? e.message
|
||||
: String(e);
|
||||
} finally {
|
||||
if (skeletonTimer !== undefined) clearTimeout(skeletonTimer);
|
||||
if (seq === loadSeq && reset) {
|
||||
loading = false;
|
||||
showSkeleton = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,7 +425,10 @@
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
|
||||
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves
|
||||
// (canonicalize-on-load rewrites it inside `load()`), but new
|
||||
// navigation lands directly on `/files/{id}`.
|
||||
goto(resolve(`/files/${folder.id}`));
|
||||
}
|
||||
|
||||
async function onNewFolder() {
|
||||
@@ -1185,12 +1192,12 @@
|
||||
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
||||
const DRAG_TYPE = 'application/x-oxi-item';
|
||||
let dropFolderId = $state<string | null>(null);
|
||||
// Highlighted breadcrumb crumb during an OxiCloud drag. Holds the
|
||||
// crumb's folder id, or the sentinel `'__home__'` for the home link
|
||||
// (which doesn't have a stable folder id — depends on the caller's
|
||||
// home folder resolution).
|
||||
const CRUMB_HOME_ID = '__home__';
|
||||
let dropCrumbId = $state<string | null>(null);
|
||||
// Per-crumb drop highlight state lived here until the breadcrumb
|
||||
// migrated to the shared `<FolderBreadcrumb>` component (2026-07-26),
|
||||
// which owns its own hover state. The `CRUMB_HOME_ID` sentinel is
|
||||
// gone too — the shared component's root icon isn't a drop target
|
||||
// (the drive root's ancestor is always the drive itself, and
|
||||
// dropping "at the drive" is ambiguous).
|
||||
|
||||
// Copy-vs-move on drop.
|
||||
//
|
||||
@@ -1871,7 +1878,7 @@
|
||||
)
|
||||
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
|
||||
loading={showSkeleton}
|
||||
{loading}
|
||||
error={error ?? undefined}
|
||||
selectable
|
||||
shiftRangeSelect
|
||||
@@ -1923,61 +1930,24 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet breadcrumb()}
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<!-- Persistent home link → the root listing (bare /files canonicalizes to
|
||||
the user's drive root). `buildCrumbs` returns only the path folders,
|
||||
so this is the single always-present "go home" affordance. Both the
|
||||
home link and every crumb accept row drops via the same
|
||||
`application/x-oxi-item` MIME the item-drag uses. The
|
||||
`.drop-target` class visually highlights the crumb during a
|
||||
hover-over so the user sees WHICH crumb the drop will land on. -->
|
||||
<a
|
||||
href={resolve('/files')}
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
class:drop-target={dropCrumbId === CRUMB_HOME_ID}
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
data-testid="files-breadcrumb-home-link"
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondragenter={(e) => {
|
||||
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = CRUMB_HOME_ID;
|
||||
}}
|
||||
ondragleave={() => {
|
||||
if (dropCrumbId === CRUMB_HOME_ID) dropCrumbId = null;
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
dropCrumbId = null;
|
||||
if (session.homeFolderId) onCrumbDrop(e, session.homeFolderId);
|
||||
}}
|
||||
>
|
||||
<Icon name={rootIcon} />
|
||||
</a>
|
||||
{#each crumbs as c, i (c.id)}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:drop-target={dropCrumbId === c.id}
|
||||
data-testid={`files-breadcrumb-${c.id}`}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondragenter={(e) => {
|
||||
if (e.dataTransfer?.types.includes(DRAG_TYPE)) dropCrumbId = c.id;
|
||||
}}
|
||||
ondragleave={() => {
|
||||
if (dropCrumbId === c.id) dropCrumbId = null;
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
dropCrumbId = null;
|
||||
onCrumbDrop(e, c.id);
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<!--
|
||||
Shared component (2026-07-26 migration). Fetches the ancestor
|
||||
chain in ONE round-trip via `GET /api/folders/{id}/ancestors`
|
||||
(replaces the per-segment `buildCrumbs` walker + N `getFolder`
|
||||
requests). Root icon is derived from `access_source.kind` on
|
||||
the endpoint response — no more `drivesStore.findByRootFolderId`
|
||||
lookup here.
|
||||
|
||||
`onDrop` prop preserves the row-drop-to-crumb behaviour: the
|
||||
component handles the `dragover`/`dragenter`/`dragleave` UI +
|
||||
`.drop-target` highlight; we get the target folder id + the
|
||||
raw event and dispatch to `onCrumbDrop`.
|
||||
-->
|
||||
<FolderBreadcrumb
|
||||
folderId={currentId}
|
||||
onDrop={(target, e) => onCrumbDrop(e, target)}
|
||||
dragMime={DRAG_TYPE}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
@@ -2142,7 +2112,8 @@
|
||||
onclick={() => {
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
goto(resolve(`/files/${[...pathSegments, id].join('/')}`));
|
||||
// Canonical single-id URL — see `openFolder` above.
|
||||
goto(resolve(`/files/${id}`));
|
||||
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -61,6 +61,13 @@ vi.mock('$lib/api/endpoints/folders', () => ({
|
||||
folderZipUrl: () => '/zip',
|
||||
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
||||
getFolderName: () => undefined,
|
||||
// Consumed by the new shared `<FolderBreadcrumb>` component that
|
||||
// `/files` mounts. Return an empty chain so the breadcrumb renders
|
||||
// nothing — tests here don't assert on breadcrumb content.
|
||||
getFolderAncestors: vi.fn(async (id: string) => ({
|
||||
ancestors: [{ id, name: id, parent_id: null, drive_id: 'test-drive' }],
|
||||
access_source: { kind: 'drive' as const }
|
||||
})),
|
||||
invalidateFolderCache: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
rememberFolderName: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
|
||||
import ResourceList, {
|
||||
isFile,
|
||||
type ContextAction,
|
||||
@@ -11,7 +12,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||
import { renameFolder, deleteFolder, getFolder, getFolderName } from '$lib/api/endpoints/folders';
|
||||
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
@@ -47,36 +48,14 @@
|
||||
// this session (the pre-URL-param behaviour).
|
||||
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
|
||||
|
||||
// Breadcrumb — resolves the scope folder's display name so the sticky
|
||||
// header can show WHICH directory the results come from ("we have no
|
||||
// clue on which directory the search was done" — Ed 2026-07-26).
|
||||
// `getFolderName` is a sync cache peek populated by prior /files
|
||||
// listings; on a cold /search deep-link we fall back to `getFolder`
|
||||
// once, cache the result, and re-render. `$state<string | null>`
|
||||
// with a `$effect` primer avoids blocking the initial render.
|
||||
let scopeFolderName = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
if (!scopeFolderId) {
|
||||
scopeFolderName = null;
|
||||
return;
|
||||
}
|
||||
const cached = getFolderName(scopeFolderId);
|
||||
if (cached) {
|
||||
scopeFolderName = cached;
|
||||
return;
|
||||
}
|
||||
// Cold deep-link — fire once, populate on resolve. If it fails
|
||||
// (folder was deleted, caller lost Read), keep name null so the
|
||||
// breadcrumb just falls back to a short UUID.
|
||||
const id = scopeFolderId;
|
||||
void getFolder(id)
|
||||
.then((f) => {
|
||||
if (scopeFolderId === id) scopeFolderName = f.name;
|
||||
})
|
||||
.catch(() => {
|
||||
if (scopeFolderId === id) scopeFolderName = id.slice(0, 8);
|
||||
});
|
||||
});
|
||||
// Breadcrumb rendering is delegated to the shared `<FolderBreadcrumb>`
|
||||
// component (2026-07-26 migration). It consumes
|
||||
// `GET /api/folders/{id}/ancestors` and renders the full parent chain
|
||||
// with the access-source-appropriate root icon (drive / share / link).
|
||||
// The per-name resolver that used to live here (`getFolder`/`getFolderName`
|
||||
// on the scope folder) is retired — the ancestors endpoint returns the
|
||||
// whole chain in one round-trip, and its inflight-dedup map means the
|
||||
// component's fetch reuses whatever other pages have already primed.
|
||||
|
||||
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
|
||||
// query time / result count into the title string because ResourceList
|
||||
@@ -756,37 +735,19 @@
|
||||
{/snippet}
|
||||
{#snippet breadcrumb()}
|
||||
<!--
|
||||
Only render when the search is folder-scoped AND the URL
|
||||
param is present — the sticky "Home > Photos" cue answers
|
||||
the "which directory was this search done in?" question
|
||||
Ed raised 2026-07-26. Hidden for scope='all' (searching
|
||||
everywhere → no folder to breadcrumb) and for a fresh
|
||||
`/search?q=…` with no `in=` param.
|
||||
|
||||
Single-segment for now (Home icon + scope folder as a
|
||||
link). Full parent-chain walk is a follow-up; it needs
|
||||
stepping through `parent_id` via `getFolder`, which
|
||||
would be a second pass here.
|
||||
Shared component (same one `/files` uses). Renders only when
|
||||
the search is folder-scoped AND the URL carries `?in=<uuid>`
|
||||
— in "Everywhere" mode `folderId={null}` and the component
|
||||
collapses to empty. Root icon + tooltip come from the
|
||||
ancestors endpoint's `access_source`, so a `/search?in=<X>`
|
||||
where X sits inside a shared drive automatically shows the
|
||||
`[users]` chip + drive name, and a share-boundary scope
|
||||
shows `[share-alt]` + a "Shared with you" link back to
|
||||
/shared-with-me. No `onDrop` — /search doesn't accept row
|
||||
drops into folders.
|
||||
-->
|
||||
{#if scope === 'folder' && scopeFolderId}
|
||||
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
|
||||
<a
|
||||
href={resolve('/files')}
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
data-testid="search-breadcrumb-home-link"
|
||||
>
|
||||
<Icon name="home" />
|
||||
</a>
|
||||
<span class="breadcrumb-separator">></span>
|
||||
<a
|
||||
href={resolve(`/files/${scopeFolderId}`)}
|
||||
class="breadcrumb-item breadcrumb-current breadcrumb-link"
|
||||
data-testid="search-breadcrumb-folder-link"
|
||||
>
|
||||
{scopeFolderName ?? '…'}
|
||||
</a>
|
||||
</nav>
|
||||
<FolderBreadcrumb folderId={scopeFolderId} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet itemActions(item)}
|
||||
|
||||
Reference in New Issue
Block a user