x
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy Docs / build (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Has been cancelled
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Has been cancelled
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Has been cancelled
CI / Migration ordering (new migrations postdate target branch) (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Wasm — fmt + clippy (push) Has been cancelled
CI / Wasm — release tests (push) Has been cancelled
CI / Plugins — fixtures + runtime tests (push) Has been cancelled
CI / Server Unit and Functionnal Tests (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
Deploy Docs / deploy (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled

This commit is contained in:
2026-09-20 00:28:52 +08:00
parent d33d1932b6
commit b7640e9be4
34 changed files with 2026 additions and 302 deletions
+267 -220
View File
@@ -40,11 +40,9 @@
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter';
import { preferences } from '$lib/stores/preferences.svelte';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import type { FileItem, FolderItem, ItemType, SortBy } from '$lib/api/types';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, {
@@ -62,6 +60,21 @@
import { ui } from '$lib/stores/ui.svelte';
import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte';
import { replaceSet } from '$lib/utils/sets';
import { mapLimit } from '$lib/utils/mapLimit';
import {
defaultFilterState,
clearFilterState,
isFilterActive,
filterToSearchOptions,
type ResourceFilterState
} from '$lib/utils/searchFilters';
import { searchResources } from '$lib/api/endpoints/search';
import {
useResourceActions,
batchZipName,
type ActionTarget
} from '$lib/composables/useResourceActions.svelte';
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
// Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -180,16 +193,11 @@
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
interface ActionTarget {
id: string;
name: string;
kind: ItemType;
}
let moveOpen = $state(false);
let moveMode = $state<'move' | 'copy'>('move');
// Move/copy dialog state lives in the shared `useResourceActions`
// composable (`resActions.moveDialog.*`); this page only keeps the share
// dialog target, which the composable doesn't own.
let shareOpen = $state(false);
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite / shared state now lives inline on every `FileItem` /
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
@@ -200,16 +208,10 @@
// inside `orderedItems`. No more `SvelteSet` shadowing.
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
moveItems = null;
moveMode = 'move';
moveOpen = true;
resActions.openMove({ id, name, kind });
}
function openCopy(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
moveItems = null;
moveMode = 'copy';
moveOpen = true;
resActions.openCopy({ id, name, kind });
}
function openShare(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
@@ -389,9 +391,15 @@
* Fetch and append the next page. Invoked by ResourceList's
* IntersectionObserver when the bottom sentinel enters the viewport.
* The `loadingMore` guard collapses a double-fire (the observer can
* tick twice on the same intersection edge).
* tick twice on the same intersection edge). Mode-aware: appends to
* the search results while the filter bar is active, to the folder
* page otherwise.
*/
async function loadMore() {
if (searchActive) {
await loadMoreSearch();
return;
}
if (loadingMore || pageCursor === undefined) return;
loadingMore = true;
try {
@@ -437,6 +445,74 @@
}
}
// ── Filter / search mode ─────────────────────────────────────────────────
// While any SearchFilterBar dimension is active the listing switches from
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
// with folder_id = currentId). The two data paths keep independent cursors
// and stale guards; entering/leaving the mode neutralizes the other path's
// in-flight response so a slow folder page can never clobber fresh search
// rows (and vice versa).
//
// The backend treats an absent/empty `query` as "match everything"
// (`SearchResourcesQuery.query` is `Option<String>`), so filter-only
// searches (type/size/date, no keyword) work; its Tantivy content index
// additionally requires ≥2 chars before it engages, so empty queries stay
// name/filter-driven.
let filter = $state<ResourceFilterState>(defaultFilterState());
const searchActive = $derived(isFilterActive(filter));
let searchItems = $state<Array<FileItem | FolderItem>>([]);
let searchCursor = $state<string | undefined>(undefined);
let searchSeq = 0;
let searchAbort: AbortController | null = null;
async function runSearch(reset: boolean = true) {
const folderId = currentId;
if (!searchActive || !folderId) return;
error = null;
const seq = ++searchSeq;
searchAbort?.abort();
const ctl = new AbortController();
searchAbort = ctl;
loading = true;
const activeAtStart = searchActive;
try {
// The search wire has no `type` order (that's a client-side group-by)
// and calls modified time `updated_at` — map both before sending.
// Relevance is meaningless with a (possibly empty) filter query, so
// the current sort field is always sent instead.
const sortBy: SortBy =
sortField === 'type' ? 'name' : sortField === 'modified_at' ? 'updated_at' : sortField;
const res = await searchResources(filter.query.trim(), {
folderId,
...filterToSearchOptions(filter),
sortBy,
reverse: reversed,
limit: 50,
cursor: reset ? undefined : searchCursor,
signal: ctl.signal
});
if (seq !== searchSeq || searchActive !== activeAtStart) return; // superseded
// Unwrap the search envelope: each hit's `resource` is already the
// shared FileItem | FolderItem shape ResourceList consumes.
const hits = res.items.map((it) => it.resource);
searchItems = reset ? hits : [...searchItems, ...hits];
searchCursor = res.next_cursor;
loading = false;
} catch (e) {
if (seq !== searchSeq || searchActive !== activeAtStart) return;
loading = false;
if ((e as Error)?.name !== 'AbortError') error = errorMessage(e);
} finally {
if (searchAbort === ctl) searchAbort = null;
}
}
/** Append the next search page — the search-mode twin of `loadMore()`. */
async function loadMoreSearch() {
if (searchCursor === undefined) return;
await runSearch(false);
}
// ── Live folder updates (message bus) ────────────────────────────
// Subscribe to `folder:{currentId}` and refresh when THIS session's
// tabs, another tab of the same user, or another user with a share
@@ -465,7 +541,11 @@
// event upload without feeling laggy.
setTimeout(() => {
reloadScheduled = false;
void reload();
// In search mode re-run the SEARCH, not the folder page: a
// recursive filter covers subfolders, and a mutation in any of
// them (or of a matched row itself) can invalidate the results.
if (searchActive) void runSearch(true);
else void reload();
}, 100);
}
useFolderTopic(() => currentId, {
@@ -670,24 +750,6 @@
}
}
/** Map `fn` over `items` with at most `limit` concurrent calls, preserving order. */
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
/** Split items into the readable ones and the unreadable (FIFO/socket/…) ones. */
async function partitionReadable<T>(
items: T[],
@@ -1117,7 +1179,11 @@
// viewer-state changes, so a user-initiated close can't be re-opened here.
$effect(() => {
const fileId = page.url.searchParams.get('file');
const files = listing.files;
// Deep links must also resolve while the filter bar is active — the
// hit may only exist in the search results, not the folder page.
const files = searchActive
? searchItems.filter((it): it is FileItem => isFile(it))
: listing.files;
untrack(() => {
if (!fileId) {
if (viewerOpen) viewerOpen = false;
@@ -1167,140 +1233,35 @@
selected.clear();
}
/**
* Download the whole selection as a single zip via POST /api/batch/download —
* folders are included (the old per-item loop silently skipped them). A lone
* file still streams directly so it keeps its original name/extension.
*/
/** Name for a server-zipped multi-item archive (matches the legacy format). */
function batchZipName(): string {
const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-');
return `oxicloud ${stamp}.zip`;
}
async function batchDownload() {
const fileIds: string[] = [];
const folderIds: string[] = [];
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
const fileIdSet = new Set(listing.files.map((f) => f.id));
for (const id of selected) {
if (folderIdSet.has(id)) folderIds.push(id);
else if (fileIdSet.has(id)) fileIds.push(id);
}
if (fileIds.length === 0 && folderIds.length === 0) return;
// Single file, no folders → direct download (preserves the real name).
if (fileIds.length === 1 && folderIds.length === 0) {
const file = listing.files.find((f) => f.id === fileIds[0]);
if (file) {
const a = document.createElement('a');
a.href = fileDownloadUrl(file.id);
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
}
return;
}
const zipName = batchZipName();
try {
const res = await apiFetch('/api/batch/download', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
errorToast(e);
}
}
/** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() {
// Build an id → item index so the "already favorite" filter is
// O(1) per selection member instead of an O(N·M) scan. Reused
// after success to flip `is_favorite` in place on each row.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, FileItem | FolderItem>();
for (const it of orderedItems) byId.set(it.id, it);
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection();
return;
}
try {
const res = await apiFetch('/api/favorites/batch', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({
items: items.map((it) => ({ item_id: it.id, item_type: it.kind }))
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) {
const row = byId.get(it.id);
if (row) row.is_favorite = true;
}
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
errorToast(e);
}
}
function selectionTargets(): ActionTarget[] {
// One O(M) index build instead of an O(N·M) `find` per selected id.
// Folders win id collisions, matching the old folder-first probe.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, ActionTarget>();
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
return [...selected]
.map((id) => byId.get(id) ?? null)
.filter((x): x is ActionTarget => x !== null);
}
function batchMove() {
const items = selectionTargets();
if (items.length) {
moveItems = items;
moveMode = 'move';
moveOpen = true;
}
}
function batchCopy() {
const items = selectionTargets();
if (items.length) {
moveItems = items;
moveMode = 'copy';
moveOpen = true;
}
}
// Shared batch actions (favorite / download / delete / move / copy),
// extracted so this page and the /search results page share one
// implementation. `getItems` switches with the view mode: batch
// operations act on search hits while the filter is active, on the
// folder listing otherwise. (`orderedItems` rather than `rlItems` —
// hidden dotfiles can never be selected, and the raw array keeps the
// lone-file download name lookup working.)
const resActions = useResourceActions({
getItems: () => (searchActive ? searchItems : orderedItems),
getSelected: () => selected,
clearSelection,
onChanged: () => (searchActive ? runSearch(true) : reload()),
afterDelete: () => void session.refresh()
});
function onKeydown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Escape priority: selection first, then an active filter. The filter
// input handles its own Escape (clears the keyword, stopPropagation)
// so reaching here means the focus is outside the search bar.
if (e.key === 'Escape' && selected.size) {
clearSelection();
} else if (e.key === 'Escape' && searchActive) {
clearFilterState(filter);
} else if (e.key === 'Delete' && selected.size) {
// Delete only — Backspace was dropped: it triggered accidental deletes.
e.preventDefault();
void batchDelete();
void resActions.batchDelete();
}
// Ctrl+A "select all" moved to the list-header checkbox owned by
// ResourceList — the row-level selection UX now lives entirely
@@ -1309,33 +1270,6 @@
// gestures that reference the local `selected` mirror.
}
async function batchDelete() {
const ids = [...selected];
const ok = await confirmDialog({
title: t('files.batch_delete', 'Delete selected'),
message: t('files.confirm_batch_delete', { n: ids.length }, 'Move {{n}} items to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
// Bounded fan-out instead of a serial await per item: 100 deletes at
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
// windows. Failures toast individually and the rest still proceed,
// exactly like the old serial loop.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
await mapLimit(ids, 6, async (id) => {
try {
if (folderIdSet.has(id)) await deleteFolder(id);
else await deleteFile(id);
} catch (e) {
errorToast(e);
}
});
clearSelection();
await reload();
void session.refresh();
}
// ── Drag-to-move ─────────────────────────────────────────────────────────
const DRAG_TYPE = 'application/x-oxi-item';
let dropFolderId = $state<string | null>(null);
@@ -1380,7 +1314,7 @@
*/
function onItemDragStart(e: DragEvent, kind: ItemType, id: string, name: string) {
const items: ActionTarget[] =
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
selected.has(id) && selected.size > 1 ? resActions.selectionTargets() : [{ id, name, kind }];
e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
if (e.dataTransfer) {
// `copyMove` advertises both operations; the drop-target's
@@ -1575,7 +1509,7 @@
$effect(() => {
if (viewerOpen) void fileViewer.load();
if (wopiOpen) void wopiEditor.load();
if (moveOpen) void moveDialog.load();
if (resActions.moveDialog.open) void moveDialog.load();
if (shareOpen) void shareDialog.load();
});
// Editability of the current context-menu target file, resolved async.
@@ -1798,6 +1732,10 @@
// user has active. First-appearance bucketing in
// `buildResourceSections` keys off the item order in the input list.
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => {
// Search mode: the rows are the scoped search hits; the swimlane
// hoist never applies there (it's cleared on mode entry, see the
// filter effect) so the dotfile filter passes straight through.
if (searchActive) return filterDotfiles(searchItems, preferences.hideDotfiles);
const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
if (newlyAdded.size === 0) return filtered;
const hoisted: Array<FileItem | FolderItem> = [];
@@ -1809,6 +1747,23 @@
return [...hoisted, ...rest];
});
// ── Folder content stat ──────────────────────────────────────────────────
// Counts of what the listing actually renders (post-dotfile-filter, i.e.
// exactly the rows on screen), shown next to the breadcrumb. Listing is
// cursor-paginated, so while more pages exist (`pageCursor` defined) the
// numbers are partial — a "+" suffix says so instead of claiming exactness
// for a folder larger than one page.
const folderStat = $derived.by(() => {
const files = rlItems.filter(isFile).length;
// Search mode shows a flat result count instead of the folders/files
// split (the recursive result set isn't "this folder's content");
// partial/+ semantics carry over via the active mode's cursor.
if (searchActive) {
return { folders: 0, files: rlItems.length, partial: searchCursor !== undefined };
}
return { folders: rlItems.length - files, files, partial: pageCursor !== undefined };
});
// Group-by state (bound to <ResourceList>). Kept as a `string` prop
// value; the current `sortField` mirrors from the picked group's
// `orderBy` so a group-by change also drives the sort.
@@ -1965,10 +1920,37 @@
// you just added"; carrying it across folders would surface
// stale ids that don't belong to the new listing.
newlyAdded.clear();
// Always re-load the folder page: even in search mode `load()`
// resolves the canonical folder id + breadcrumbs that the
// scoped search below is anchored to.
void load(true);
});
});
// Scoped-search driver. Re-runs the search from page 1 whenever its
// inputs change: any filter dimension, the folder it's scoped to
// (`currentId`, resolved by `load()` above), or the sort dimension.
// Inactive (plain folder listing) is the no-op fast path. `filter` is
// a `$state` proxy — the field reads are what register the deps.
$effect(() => {
void filter.query;
void filter.type;
void filter.size;
void filter.date;
void filter.recursive;
void currentId;
void sortField;
void reversed;
const active = searchActive;
untrack(() => {
if (!active) return;
// Mode entry / re-run → drop the swimlane so its hoisting
// never fights the search ordering.
newlyAdded.clear();
void runSearch(true);
});
});
// The command palette's "Upload files" action navigates here then dispatches
// this event so the hidden file picker opens (the input lives on this page).
$effect(() => {
@@ -2001,6 +1983,14 @@
<ReadOnlyBanner driveName={currentDrive.name} />
{/if}
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
current folder (recursive toggle inside). While any dimension is
active the listing below switches from the folder page to the
search results; clearing it returns to the plain folder view. -->
<div class="files-filter-row">
<SearchFilterBar bind:value={filter} />
</div>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the
`actions` snippet can click() them without ResourceList's internal
@@ -2026,16 +2016,20 @@
<ResourceList
title={t('nav.files', 'Files')}
items={rlItems}
emptyText={hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
emptyHint={hiddenCount > 0
? t(
'files.empty_hidden_hint',
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
)
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
emptyText={searchActive
? t('search.no_results', 'No results found for this search')
: hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
emptyHint={searchActive
? t('search.prompt', 'Type a query in the search bar above.')
: hiddenCount > 0
? t(
'files.empty_hidden_hint',
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
)
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={searchActive ? 'search' : hiddenCount > 0 ? 'eye-slash' : undefined}
{loading}
error={error ?? undefined}
selectable
@@ -2052,7 +2046,7 @@
groupBys={rlGroupBys}
bind:groupBy
bind:reversed
hasMore={pageCursor !== undefined}
hasMore={searchActive ? searchCursor !== undefined : pageCursor !== undefined}
onloadmore={loadMore}
onreload={(orderBy) => {
sortField = orderBy as SortField;
@@ -2074,8 +2068,10 @@
<!-- Surfaces only when the folder isn't really empty — it's just
filtered because the user chose to hide dotfiles. Clicking
flips the app-wide `preferences.hideDotfiles` back off,
re-populating the list without a hunt through settings. -->
{#if hiddenCount > 0}
re-populating the list without a hunt through settings.
Suppressed in search mode: an empty result there is a real
"nothing matched", not a hidden-items artifact. -->
{#if !searchActive && hiddenCount > 0}
<button
class="btn btn-secondary"
onclick={() => preferences.setHideDotfiles(false)}
@@ -2106,6 +2102,29 @@
onDrop={(target, e) => onCrumbDrop(e, target)}
dragMime={DRAG_TYPE}
/>
{#if searchActive}
<!-- Filter mode: a flat result count replaces the folders/files
split — the recursive result set isn't "this folder's
content". "+" keeps the partial-pages meaning. -->
{#if rlItems.length > 0}
<span class="folder-stat" data-testid="files-folder-stat">
{t('filter.results_count', { n: rlItems.length }, '{{n}} results')}{folderStat.partial
? '+'
: ''}
</span>
{/if}
{:else if folderStat.folders + folderStat.files > 0}
<!-- Item count for the folder on screen. Sits in the same sticky
strip as the breadcrumb so it stays visible while scrolling.
"+" = more pages are still loading via infinite scroll. -->
<span class="folder-stat" data-testid="files-folder-stat">
{t(
'files.folder_stat',
{ folders: folderStat.folders, files: folderStat.files },
'{{folders}} folders · {{files}} files'
)}{folderStat.partial ? '+' : ''}
</span>
{/if}
{/snippet}
{#snippet actions()}
@@ -2155,6 +2174,21 @@
<Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span>
</button>
<!-- Manual reload of the current folder: resets pagination to page 1
and refetches (listing accumulator + folder stat + dotfile
filter all recompute). Disabled while a load is already in
flight — the button is a convenience, not a hammer. -->
<button
class="btn btn-secondary"
data-testid="files-refresh-btn"
title={t('common.refresh', 'Refresh')}
aria-label={t('common.refresh', 'Refresh')}
disabled={loading}
onclick={() => void load(true)}
>
<Icon name="repeat" class="icon-mr" />
<span>{t('common.refresh', 'Refresh')}</span>
</button>
{/snippet}
{#snippet batchActions(_sel)}
@@ -2162,7 +2196,7 @@
class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')}
data-testid="files-batch-favorite-btn"
onclick={() => void batchFavorites()}
onclick={() => void resActions.batchFavorites()}
>
<Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span>
@@ -2171,7 +2205,7 @@
class="batch-btn"
title={t('files.move', 'Move')}
data-testid="files-batch-move-btn"
onclick={batchMove}
onclick={resActions.batchMove}
>
<Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span>
@@ -2180,7 +2214,7 @@
class="batch-btn"
title={t('files.copy', 'Copy')}
data-testid="files-batch-copy-btn"
onclick={batchCopy}
onclick={resActions.batchCopy}
>
<Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span>
@@ -2189,7 +2223,7 @@
class="batch-btn"
title={t('common.download', 'Download')}
data-testid="files-batch-download-btn"
onclick={() => void batchDownload()}
onclick={() => void resActions.batchDownload()}
>
<Icon name="download" />
<span>{t('common.download', 'Download')}</span>
@@ -2198,7 +2232,7 @@
class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')}
data-testid="files-batch-delete-btn"
onclick={batchDelete}
onclick={() => void resActions.batchDelete()}
>
<Icon name="trash" />
<span>{t('common.delete', 'Delete')}</span>
@@ -2210,14 +2244,11 @@
{#if moveDialog.component}
{@const MoveDialog = moveDialog.component}
<MoveDialog
bind:open={moveOpen}
item={actionTarget}
items={moveItems}
mode={moveMode}
onmoved={() => {
clearSelection();
void reload();
}}
bind:open={resActions.moveDialog.open}
item={resActions.moveDialog.item}
items={resActions.moveDialog.items}
mode={resActions.moveDialog.mode}
onmoved={resActions.handleMoved}
/>
{/if}
{#if shareDialog.component}
@@ -2434,6 +2465,17 @@
z-index: 1000;
}
/* Item count next to the breadcrumb (same sticky strip; `.rl-breadcrumb`
is already a flex row, so the span just flows beside the crumbs).
`flex-shrink: 0` keeps long crumb trails from squeezing the digits
into a vertical stack on narrow viewports — the crumbs wrap instead. */
.folder-stat {
flex-shrink: 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
white-space: nowrap;
}
.ctx-menu {
position: fixed;
z-index: 1001;
@@ -2471,4 +2513,9 @@
rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt);
}
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style>
+85 -8
View File
@@ -27,14 +27,19 @@ vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
vi.mock('$lib/api/endpoints/deltaUpload', () => ({
instantUploadOwned: vi.fn(),
resolveOwnedHashes: vi.fn(),
tryDeltaUpload: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
addFavoritesBatch: vi.fn()
}));
vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: () => false,
getEditorUrlWithFallback: vi.fn()
@@ -77,7 +82,8 @@ vi.mock('$lib/api/endpoints/folders', () => ({
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile, uploadFileWithProgress } from '$lib/api/endpoints/files';
import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
import { apiFetch } from '$lib/api/client';
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
import { searchResources } from '$lib/api/endpoints/search';
import { files as filesStore } from '$lib/stores/files.svelte';
import FilesPage from './[...path]/+page.svelte';
@@ -196,6 +202,41 @@ it('loads the home folder listing on mount and renders its contents', async () =
await screen.findByTestId('files-new-folder-btn');
});
it('shows the folder item count next to the breadcrumb', async () => {
withListing(); // 1 folder + 1 file, `nextCursor` undefined → last page
render(FilesPage);
const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 folders · 1 files');
// No "+" suffix — the listing is complete, the count is exact.
expect(stat.textContent!.trim().endsWith('+')).toBe(false);
});
it('marks the folder count as partial while more pages exist', async () => {
const folder = folderItem('sub1', 'Sub');
const file = fileItem('f1', 'hello.txt');
m(fetchFolderPage).mockResolvedValue({
items: [folder, file],
folders: [folder],
files: [file],
nextCursor: 'page-2'
});
render(FilesPage);
const stat = await screen.findByTestId('files-folder-stat');
// Counts reflect the pages loaded so far; the trailing "+" says more
// are on the way via infinite scroll instead of claiming exactness.
expect(stat.textContent).toContain('1 folders · 1 files');
expect(stat.textContent!.trim().endsWith('+')).toBe(true);
});
it('reloads the listing when the refresh button is clicked', async () => {
withListing();
render(FilesPage);
await screen.findByTestId('files-refresh-btn');
// Initial mount load = 1 call; the click resets pagination and refetches.
await fireEvent.click(screen.getByTestId('files-refresh-btn'));
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledTimes(2));
});
it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage);
@@ -233,14 +274,50 @@ it('batch-deletes the whole selection after confirmation', async () => {
it('batch-favorites the selection via the favorites batch endpoint', async () => {
withListing();
m(apiFetch).mockResolvedValue({ ok: true });
m(addFavoritesBatch).mockResolvedValue(undefined);
render(FilesPage);
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/api/favorites/batch',
expect.objectContaining({ method: 'POST' })
)
expect(addFavoritesBatch).toHaveBeenCalledWith([
{ item_id: 'sub1', item_type: 'folder' },
{ item_id: 'f1', item_type: 'file' }
])
);
});
it('runs a scoped recursive search when the filter keyword is set', async () => {
withListing();
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
expect(searchResources).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ folderId: 'home', recursive: true })
);
});
it('shows the search result count while the filter is active', async () => {
withListing();
const hit = fileItem('s1', 'found.txt');
m(searchResources).mockResolvedValue({
items: [{ resource_type: 'file', resource: hit, meta: { score: 50 } }],
query_time_ms: 1
});
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'found' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 results');
});