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
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:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user