diff --git a/AGENTS.md b/AGENTS.md index 10443559..507e8658 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -324,3 +324,66 @@ CI runs the same `npm run check` (plus Vitest) — commits that fail will not me - Leave debug `console.log` statements in code - Use raw color values in CSS — always use CSS custom properties - Commit without passing all linters (`npm run check` for the frontend; `cargo fmt` + `cargo clippy` for the backend) + +# 本地 fork 维护规则(Local fork rules) + +> 本节是仅本地追加的内容,不属于上游 OxiCloud。与上游合并时,若本节之外的部分发生冲突, +> 以上游为准;本节始终保留在文件末尾以减少冲突面。 + +## 背景 + +本仓库是开源项目 OxiCloud 的本地副本,上游会持续更新。本地修改必须 +**可追溯、可合并**:任何时候都要能知道"我们改了什么",以便与上游主分支合并。 + +## 规则 1:计划与进度必须记录在 `status.md` + +- 每次接到非琐碎任务,**开始前**先在 `status.md` 顶部("进行中"区域)写下计划。 +- `status.md` 条目格式(每条任务一个区块): + +```markdown +### [YYYY-MM-DD] 任务标题 +- **状态**: 进行中 / 已完成 / 已放弃(写明原因) +- **计划**: 要做什么、分几步 +- **改动文件**: 列出修改/新增的上游文件(相对路径)+ 一句话说明 +- **仅本地文件**: 新增的不属于上游的文件(合并时无需处理) +- **上游冲突风险**: 高 / 中 / 低,以及可能与上游哪些文件冲突 +``` + +- 状态只允许进行中/已完成/已放弃三种;完成的任务移入"已完成"区域,保留记录不删除。 + +## 规则 2:每次修改后立即更新 `status.md` + +- **不需要用户提醒**。任何一次代码/文档修改完成后,agent 必须同步更新 + `status.md` 中对应条目的状态、改动文件列表和冲突风险。 +- 即使任务中途被打断,也要把当前进度写清(做到哪一步、剩下什么),保证 + 任何 agent(或人)读了 `status.md` 就能接手。 + +## 规则 3:与上游主分支合并 + +- **小步提交**:一个任务一个 commit(或少量 commit),commit message 说清楚改了什么。 + 不要把多天的工作堆成一个巨型 commit,否则合并时无法选择性丢弃。 +- **少改上游文件**:能用新增文件解决的(新组件、新模块、新 endpoint)就不要改上游现有文件; + 必须改时尽量小而集中,并在 `status.md` 的"上游冲突风险"里注明。 + **`AGENTS.md` 本身也因此只允许在文件末尾追加内容,不得改动上游已有的章节。** +- **不改无关格式**:不要顺手重排上游代码、改无关 import 顺序——纯噪音,制造冲突。 +- 合并上游的流程: + +```bash +git remote add upstream <上游仓库地址> # 只需配置一次 +git fetch upstream +git merge upstream/main # 或 rebase,按团队习惯;首次建议 merge +# 解决冲突时:先读 status.md 的"改动文件"列表,逐个文件核对本地意图 +git status # 确认没有遗漏的冲突标记 +cargo fmt --all && cargo clippy --all-features --all-targets -- -D warnings +just test +``` + +- 合并完成后,在 `status.md` 新增一条"上游合并"记录:合并到的 upstream commit、 + 解决过的冲突文件、是否有本地修改被上游覆盖/废弃。 +- 若上游已用别的方式实现了某个本地功能(导致本地补丁不再需要),在 `status.md` + 把对应条目标为"已放弃(上游已实现)",并考虑回退本地补丁。 + +## 规则 4:其他 + +- `status.md` 属于仅本地文件,不向上游提 PR(除非团队明确决定); + `AGENTS.md` 中仅本节("本地 fork 维护规则")是本地内容,向上游提 PR 时应剔除。 diff --git a/docs/architecture/share-integration.md b/docs/architecture/share-integration.md index 0fa6c2bb..2831bebb 100644 --- a/docs/architecture/share-integration.md +++ b/docs/architecture/share-integration.md @@ -36,8 +36,43 @@ What used to live on the share row but is now resolved through ReBAC: | Method | Path | Description | | --- | --- | --- | -| `GET` | `/api/s/{token}` | Access a shared item | -| `POST` | `/api/s/{token}/verify` | Verify a password-protected share | +| `GET` | `/api/s/{token}` | Share landing metadata (see [landing enrichment](#share-landing-metadata-enrichment)) | +| `POST` | `/api/s/{token}/verify` | Verify a password-protected share (sets the unlock-JWT cookie) | +| `GET` | `/api/s/{token}/download` | Download a **file share** (Range / 206 / 304 / 416 aware) | +| `GET` | `/api/s/{token}/contents` | List a **folder share's** root (folders + files) | +| `GET` | `/api/s/{token}/contents/{folder_id}` | List a subfolder inside the shared subtree | +| `GET` | `/api/s/{token}/file/{file_id}` | Stream one file — the landing page's inline preview and per-file download path (Range aware) | +| `GET` | `/api/s/{token}/zip` | ZIP archive of a **folder share's** root | +| `GET` | `/api/s/{token}/zip/{folder_id}` | ZIP archive of a subfolder inside the shared subtree | + +#### File scoping on `/file/{file_id}` + +The AuthZ gate (`ShareBrowseService::assert_file_in_share`) branches on the +share's `item_type` — a single-file share and a folder share scope the +endpoint differently: + +- **File share** — only the shared item itself may be streamed + (`file_id == share.item_id`). This is what renders the public landing + page's inline media preview (video player / image) and it is also the + NextCloud-desktop-style per-file fetch path. +- **Folder share** — the file must live inside the shared subtree + (ltree `is_file_in_subtree` against the share's root folder). +- Anything else → **404**, the same shape as "file doesn't exist", so the + endpoint cannot be used to enumerate ids. + +Password and expiry checks happen inside `get_shared_link_with_unlock` +before the scope decision; a password-protected share answers 401 with +`requiresPassword: true` until the unlock cookie is presented. + +#### Share landing metadata enrichment + +`GET /api/s/{token}` resolves the shared **file's** `mime_type` + `size` +at read time so anonymous viewers get an inline media preview (video +player / image) instead of a bare download button. The enrichment is +display-only and never fails the response: a failed file lookup (transient +DB error, race with a delete) leaves the fields absent and the download +endpoints surface the real error — a read failure is never proof that the +data is absent. Folder shares pass through unenriched. ## Service Responsibilities diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index 55d0490a..eefd5be2 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -47,6 +47,19 @@ If you need to let someone make changes, share with their **email** instead. They'll receive an invitation, and from then on every change they make is recorded under their name. +## What recipients see + +Opening a **single-file** public link shows the file right on the +landing page — images display inline, and videos play in the browser +with a working seek bar (streamed, so no full download before +playback). A **Download** button always sits below the preview. For +files the browser can't display, recipients get the download button as +usual. + +Opening a **folder** public link shows a browsable listing — folders +and files as cards, with a grid/list toggle and a **Download ZIP** +button. Images and videos open in a lightbox viewer. + ## Expiration When you share, you can set an **expiration date**. After that date, diff --git a/frontend/src/lib/api/endpoints/batch.ts b/frontend/src/lib/api/endpoints/batch.ts index 9591815e..0886ef39 100644 --- a/frontend/src/lib/api/endpoints/batch.ts +++ b/frontend/src/lib/api/endpoints/batch.ts @@ -33,3 +33,22 @@ export function copyFolders(folderIds: string[], targetFolderId: string | null): target_folder_id: targetFolderId }); } + +/** + * Stream a multi-item selection as a server-built zip (`POST /api/batch/download` + * — folders included, unlike the legacy per-item loop). The caller names and + * saves the returned blob. + */ +export async function downloadBatch(fileIds: string[], folderIds: string[]): Promise { + const res = await apiFetch('/api/batch/download', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) + }); + if (!res.ok) { + const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; + throw new Error(e.error || e.message || `batch download failed: ${res.status}`); + } + return res.blob(); +} diff --git a/frontend/src/lib/api/endpoints/favorites.ts b/frontend/src/lib/api/endpoints/favorites.ts index f1f61cc4..adc3a5ff 100644 --- a/frontend/src/lib/api/endpoints/favorites.ts +++ b/frontend/src/lib/api/endpoints/favorites.ts @@ -139,3 +139,24 @@ export async function removeFavorite(type: ItemType, id: string): Promise }); if (!res.ok) throw new Error(`remove favorite failed: ${res.status}`); } + +/** One item for the batch favorites call. */ +export interface FavoriteBatchItem { + item_id: string; + item_type: ItemType; +} + +/** + * Batch-add favorites via `POST /api/favorites/batch` — a single round trip + * for the whole selection (used by the files page and search results batch bar). + */ +export async function addFavoritesBatch(items: FavoriteBatchItem[]): Promise { + if (items.length === 0) return; + const res = await apiFetch('/api/favorites/batch', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify({ items }) + }); + if (!res.ok) throw new Error(`batch favorites failed: ${res.status}`); +} diff --git a/frontend/src/lib/components/SearchFilterBar.svelte b/frontend/src/lib/components/SearchFilterBar.svelte new file mode 100644 index 00000000..c3f6fb2d --- /dev/null +++ b/frontend/src/lib/components/SearchFilterBar.svelte @@ -0,0 +1,354 @@ + + +
+
+
+ + + {#if keyword.length > 0} + + {/if} +
+ +
+ + {#if expanded} +
+ + + + {#if !hideRecursive} + + {/if} +
+ {/if} +
+ + diff --git a/frontend/src/lib/composables/useResourceActions.svelte.ts b/frontend/src/lib/composables/useResourceActions.svelte.ts new file mode 100644 index 00000000..37af252a --- /dev/null +++ b/frontend/src/lib/composables/useResourceActions.svelte.ts @@ -0,0 +1,241 @@ +// Shared batch-actions composable for resource list views (files page, +// search results, …). +// +// Extracted verbatim from the files page so every surface that can select +// items shares one implementation of the batch favorite / download / +// delete / move / copy flows. Surfaces differ in (a) the id→item index, +// (b) what "refresh" means (folder reload vs search re-run) and (c) how a +// favorites flip reaches the rows — those differences are injected via +// the callbacks in `ResourceActionsOptions`. +import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; +import { downloadBatch } from '$lib/api/endpoints/batch'; +import { deleteFile, fileDownloadUrl } from '$lib/api/endpoints/files'; +import { deleteFolder } from '$lib/api/endpoints/folders'; +import { addFavoritesBatch } from '$lib/api/endpoints/favorites'; +import { t } from '$lib/i18n/index.svelte'; +import { confirmDialog } from '$lib/stores/dialogs.svelte'; +import { ui } from '$lib/stores/ui.svelte'; +import { errorToast } from '$lib/utils/errors'; +import { mapLimit } from '$lib/utils/mapLimit'; + +/** A minimal actionable item reference (dialog props, favorites payload). */ +export interface ActionTarget { + id: string; + name: string; + kind: ItemType; +} + +export interface ResourceActionsOptions { + /** Current on-screen rows, read at action time (fresh, never stale). */ + getItems: () => ReadonlyArray; + /** Selected ids — the page's SvelteSet mirror of the list's selection. */ + getSelected: () => ReadonlySet; + clearSelection: () => void; + /** After a successful delete: reload the listing or re-run the search. */ + onChanged: () => void | Promise; + /** Extra bookkeeping after a delete (e.g. the session/quota refresh). */ + afterDelete?: () => void; + /** + * After favorites succeed, update rows in place (keeps scroll position on + * infinite-scroll pages). Defaults to flipping `is_favorite` on the items + * returned by `getItems()` — sufficient for plain DTO `$state` arrays. + */ + onFavoritesApplied?: (ids: ReadonlySet) => void; +} + +/** Name for a server-zipped multi-item archive (matches the legacy format). */ +export function batchZipName(): string { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- filename stamp, never read reactively + const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-'); + return `oxicloud ${stamp}.zip`; +} + +/** Trigger a browser download of `blob` as `name`. */ +function saveBlob(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export function useResourceActions(opts: ResourceActionsOptions) { + // Move/copy dialog state, owned here so both surfaces bind one dialog to + // `moveDialog.*` instead of re-implementing the open/mode/items triple. + const moveDialog = $state({ + open: false, + mode: 'move' as 'move' | 'copy', + item: null as ActionTarget | null, + items: null as ActionTarget[] | null + }); + + function openBatchDialog(mode: 'move' | 'copy', items: ActionTarget[]): void { + moveDialog.items = items; + moveDialog.item = null; + moveDialog.mode = mode; + moveDialog.open = true; + } + + /** Context-menu / single-row entry points. */ + function openMove(target: ActionTarget): void { + moveDialog.item = target; + moveDialog.items = null; + moveDialog.mode = 'move'; + moveDialog.open = true; + } + function openCopy(target: ActionTarget): void { + moveDialog.item = target; + moveDialog.items = null; + moveDialog.mode = 'copy'; + moveDialog.open = true; + } + + 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(); + for (const f of opts.getItems()) byId.set(f.id, { id: f.id, name: f.name, kind: kindOf(f) }); + return [...opts.getSelected()] + .map((id) => byId.get(id) ?? null) + .filter((x): x is ActionTarget => x !== null); + } + + /** + * 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. + */ + async function batchDownload(): Promise { + const targets = selectionTargets(); + if (targets.length === 0) return; + const fileTargets = targets.filter((it) => it.kind === 'file'); + const folderTargets = targets.filter((it) => it.kind === 'folder'); + + // Single file, no folders → direct download (preserves the real name). + if (fileTargets.length === 1 && folderTargets.length === 0) { + const file = opts.getItems().find((f) => f.id === fileTargets[0].id); + 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; + } + + try { + const blob = await downloadBatch( + fileTargets.map((it) => it.id), + folderTargets.map((it) => it.id) + ); + saveBlob(blob, batchZipName()); + } catch (e) { + errorToast(e); + } + } + + /** Batch add the selection to favorites — single /api/favorites/batch call. */ + async function batchFavorites(): Promise { + const items = opts.getItems(); + // 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(); + for (const it of items) byId.set(it.id, it); + const targets = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false)); + if (targets.length === 0) { + ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info'); + opts.clearSelection(); + return; + } + try { + await addFavoritesBatch(targets.map((it) => ({ item_id: it.id, item_type: it.kind }))); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read + const flipped = new Set(targets.map((it) => it.id)); + if (opts.onFavoritesApplied) opts.onFavoritesApplied(flipped); + else + for (const id of flipped) { + const row = byId.get(id); + if (row) row.is_favorite = true; + } + ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); + opts.clearSelection(); + } catch (e) { + errorToast(e); + } + } + + async function batchDelete(): Promise { + const selected = opts.getSelected(); + 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. + const items = opts.getItems(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read + const folderIdSet = new Set(items.filter((it) => !isFileItem(it)).map((it) => it.id)); + await mapLimit(ids, 6, async (id) => { + try { + if (folderIdSet.has(id)) await deleteFolder(id); + else await deleteFile(id); + } catch (e) { + errorToast(e); + } + }); + opts.clearSelection(); + await opts.onChanged(); + opts.afterDelete?.(); + } + + function batchMove(): void { + const items = selectionTargets(); + if (items.length) openBatchDialog('move', items); + } + + function batchCopy(): void { + const items = selectionTargets(); + if (items.length) openBatchDialog('copy', items); + } + + /** Pass as the MoveDialog `onmoved` handler. */ + async function handleMoved(): Promise { + opts.clearSelection(); + await opts.onChanged(); + } + + return { + selectionTargets, + batchFavorites, + batchDownload, + batchDelete, + batchMove, + batchCopy, + openMove, + openCopy, + handleMoved, + moveDialog + }; +} + +function kindOf(item: FileItem | FolderItem): ItemType { + return isFileItem(item) ? 'file' : 'folder'; +} + +/** `FileItem | FolderItem` uses duck typing (`mime_type`) rather than a tag field. */ +export function isFileItem(item: FileItem | FolderItem): item is FileItem { + return 'mime_type' in item; +} diff --git a/frontend/src/lib/composables/useResourceActions.test.ts b/frontend/src/lib/composables/useResourceActions.test.ts new file mode 100644 index 00000000..6424bd5c --- /dev/null +++ b/frontend/src/lib/composables/useResourceActions.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { confirmDialog, ui } = vi.hoisted(() => ({ + confirmDialog: vi.fn(), + ui: { + notify: vi.fn(), + startProgress: vi.fn(() => 1), + updateProgress: vi.fn(), + finishProgress: vi.fn() + } +})); +vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog: vi.fn() })); +vi.mock('$lib/stores/ui.svelte', () => ({ ui })); +vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); +vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) })); +vi.mock('$lib/api/endpoints/batch', () => ({ + downloadBatch: vi.fn(), + copyFiles: vi.fn(), + copyFolders: vi.fn() +})); +vi.mock('$lib/api/endpoints/files', () => ({ deleteFile: vi.fn(), fileDownloadUrl: () => '/dl' })); +vi.mock('$lib/api/endpoints/folders', () => ({ deleteFolder: vi.fn() })); +vi.mock('$lib/api/endpoints/favorites', () => ({ addFavoritesBatch: vi.fn() })); + +import { downloadBatch } from '$lib/api/endpoints/batch'; +import { deleteFile } from '$lib/api/endpoints/files'; +import { deleteFolder } from '$lib/api/endpoints/folders'; +import { addFavoritesBatch } from '$lib/api/endpoints/favorites'; +import { useResourceActions, type ActionTarget } from './useResourceActions.svelte'; +import type { FileItem, FolderItem } from '$lib/api/types'; + +const m = (fn: unknown) => fn as ReturnType; + +// The composable only reads `id` / `name` / `mime_type` / `is_favorite`; the +// full DTO shapes are satisfied via casts to keep the fixtures minimal. +function fileItem(id: string, overrides: Record = {}): FileItem { + return { + id, + name: `${id}.txt`, + mime_type: 'text/plain', + is_favorite: false, + ...overrides + } as unknown as FileItem; +} +function folderItem(id: string, overrides: Record = {}): FolderItem { + return { + id, + name: id, + is_favorite: false, + ...overrides + } as unknown as FolderItem; +} + +function harness(items: Array, selected: string[]) { + const selection = new Set(selected); + const actions = useResourceActions({ + getItems: () => items, + getSelected: () => selection, + clearSelection: () => selection.clear(), + onChanged: vi.fn(), + afterDelete: vi.fn() + }); + return { actions, selection }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('selectionTargets', () => { + it('maps selected ids to targets with kind', () => { + const { actions } = harness([folderItem('d1'), fileItem('f1')], ['f1']); + expect(actions.selectionTargets()).toEqual([ + { id: 'f1', name: 'f1.txt', kind: 'file' } + ]); + }); + + it('lets folders win id collisions', () => { + const { actions } = harness([fileItem('x'), folderItem('x')], ['x']); + expect(actions.selectionTargets()[0].kind).toBe('folder'); + }); + + it('drops ids that are no longer on screen', () => { + const { actions } = harness([fileItem('f1')], ['gone']); + expect(actions.selectionTargets()).toEqual([]); + }); +}); + +describe('batchFavorites', () => { + it('skips items that are already favorites and flips the rest in place', async () => { + const folder = folderItem('d1'); + const file = fileItem('f1', { is_favorite: true }); + const { actions, selection } = harness([folder, file], ['d1', 'f1']); + m(addFavoritesBatch).mockResolvedValue(undefined); + await actions.batchFavorites(); + expect(addFavoritesBatch).toHaveBeenCalledWith([{ item_id: 'd1', item_type: 'folder' }]); + expect(folder.is_favorite).toBe(true); + // already-favorite rows keep their state + expect(file.is_favorite).toBe(true); + expect(selection.size).toBe(0); + expect(ui.notify).toHaveBeenCalledWith(expect.anything(), 'success'); + }); + + it('notifies when every selected item is already a favorite', async () => { + const file = fileItem('f1', { is_favorite: true }); + const { actions, selection } = harness([file], ['f1']); + await actions.batchFavorites(); + expect(addFavoritesBatch).not.toHaveBeenCalled(); + expect(ui.notify).toHaveBeenCalledWith(expect.anything(), 'info'); + expect(selection.size).toBe(0); + }); +}); + +describe('batchDownload', () => { + it('uses the batch zip endpoint for a mixed selection', async () => { + const { actions } = harness( + [fileItem('f1'), fileItem('f2'), folderItem('d1')], + ['f1', 'f2', 'd1'] + ); + m(downloadBatch).mockResolvedValue(new Blob(['zip'])); + await actions.batchDownload(); + expect(downloadBatch).toHaveBeenCalledWith(['f1', 'f2'], ['d1']); + }); + + it('streams a lone file directly, without the zip endpoint', async () => { + const { actions } = harness([fileItem('f1')], ['f1']); + await actions.batchDownload(); + expect(downloadBatch).not.toHaveBeenCalled(); + }); +}); + +describe('batchDelete', () => { + it('fans out per item after confirmation and calls onChanged + afterDelete', async () => { + const selection = new Set(['d1', 'f1']); + const onChanged = vi.fn(); + const afterDelete = vi.fn(); + const actions = useResourceActions({ + getItems: () => [folderItem('d1'), fileItem('f1')], + getSelected: () => selection, + clearSelection: () => selection.clear(), + onChanged, + afterDelete + }); + confirmDialog.mockResolvedValue(true); + await actions.batchDelete(); + expect(deleteFolder).toHaveBeenCalledWith('d1'); + expect(deleteFile).toHaveBeenCalledWith('f1'); + expect(onChanged).toHaveBeenCalled(); + expect(afterDelete).toHaveBeenCalled(); + expect(selection.size).toBe(0); + }); + + it('does nothing when the confirm dialog is dismissed', async () => { + const { actions } = harness([fileItem('f1')], ['f1']); + confirmDialog.mockResolvedValue(false); + await actions.batchDelete(); + expect(deleteFile).not.toHaveBeenCalled(); + }); +}); + +describe('move/copy dialogs', () => { + it('batch move opens the dialog with the selection', () => { + const { actions } = harness([fileItem('f1')], ['f1']); + actions.batchMove(); + expect(actions.moveDialog.open).toBe(true); + expect(actions.moveDialog.mode).toBe('move'); + expect(actions.moveDialog.items).toEqual([ + { id: 'f1', name: 'f1.txt', kind: 'file' } + ]); + expect(actions.moveDialog.item).toBeNull(); + }); + + it('batch copy opens the dialog in copy mode', () => { + const { actions } = harness([fileItem('f1')], ['f1']); + actions.batchCopy(); + expect(actions.moveDialog.open).toBe(true); + expect(actions.moveDialog.mode).toBe('copy'); + }); + + it('openMove sets a single item and handleMoved clears selection', async () => { + const selection = new Set(['f1']); + const onChanged = vi.fn(); + const actions = useResourceActions({ + getItems: () => [fileItem('f1')], + getSelected: () => selection, + clearSelection: () => selection.clear(), + onChanged + }); + actions.openMove({ id: 'f1', name: 'f1.txt', kind: 'file' }); + expect(actions.moveDialog.open).toBe(true); + expect(actions.moveDialog.item).toEqual({ id: 'f1', name: 'f1.txt', kind: 'file' }); + await actions.handleMoved(); + expect(selection.size).toBe(0); + expect(onChanged).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/mapLimit.test.ts b/frontend/src/lib/utils/mapLimit.test.ts new file mode 100644 index 00000000..59d805d0 --- /dev/null +++ b/frontend/src/lib/utils/mapLimit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mapLimit } from './mapLimit'; + +describe('mapLimit', () => { + it('preserves result order regardless of completion order', async () => { + const result = await mapLimit([200, 50, 100, 10], 4, async (ms) => { + await new Promise((r) => setTimeout(r, ms)); + return ms; + }); + expect(result).toEqual([200, 50, 100, 10]); + }); + + it('never exceeds the concurrency cap', async () => { + let active = 0; + let peak = 0; + await mapLimit([1, 2, 3, 4, 5, 6, 7, 8], 3, async () => { + active++; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, 1)); + active--; + return null; + }); + expect(peak).toBe(3); + }); + + it('propagates rejections', async () => { + await expect( + mapLimit([1, 2, 3], 2, async (n) => { + if (n === 2) throw new Error('boom'); + return n; + }) + ).rejects.toThrow('boom'); + }); + + it('handles empty input', async () => { + const fn = vi.fn(async (n: number) => n); + await expect(mapLimit([], 4, fn)).resolves.toEqual([]); + expect(fn).not.toHaveBeenCalled(); + }); + + it('runs items sequentially when limit is 1', async () => { + const calls: number[] = []; + await mapLimit([1, 2, 3], 1, async (n) => { + calls.push(n); + return n; + }); + expect(calls).toEqual([1, 2, 3]); + }); +}); diff --git a/frontend/src/lib/utils/mapLimit.ts b/frontend/src/lib/utils/mapLimit.ts new file mode 100644 index 00000000..19e22f50 --- /dev/null +++ b/frontend/src/lib/utils/mapLimit.ts @@ -0,0 +1,24 @@ +/** + * Map `fn` over `items` with at most `limit` concurrent calls, preserving + * result order regardless of completion order. + * + * Extracted from the files page so batch fan-out paths (delete, drag-move, + * upload probing) and the shared resource-actions composable all use the + * same bounded-concurrency primitive. + */ +export async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const out = new Array(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.max(0, Math.min(limit, items.length)) }, worker)); + return out; +} diff --git a/frontend/src/lib/utils/searchFilters.test.ts b/frontend/src/lib/utils/searchFilters.test.ts new file mode 100644 index 00000000..82f70dca --- /dev/null +++ b/frontend/src/lib/utils/searchFilters.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + clearFilterState, + dateBound, + defaultFilterState, + filterToSearchOptions, + isFilterActive, + sizeBounds, + TYPE_EXT +} from './searchFilters'; + +const MB = 1024 * 1024; + +describe('TYPE_EXT', () => { + it('covers the five non-all type keys', () => { + expect(Object.keys(TYPE_EXT).sort()).toEqual( + ['archive', 'audio', 'document', 'image', 'video'].sort() + ); + }); + + it('uses bare lowercase extensions', () => { + for (const exts of Object.values(TYPE_EXT)) { + for (const ext of exts) expect(ext).toBe(ext.toLowerCase()); + } + }); +}); + +describe('sizeBounds', () => { + it('maps the presets to byte ranges', () => { + expect(sizeBounds('all')).toEqual({}); + expect(sizeBounds('small')).toEqual({ maxSize: MB }); + expect(sizeBounds('medium')).toEqual({ minSize: MB, maxSize: 100 * MB }); + expect(sizeBounds('large')).toEqual({ minSize: 100 * MB }); + }); +}); + +describe('dateBound', () => { + it('maps day to 24h ago', () => { + expect(dateBound('day')).toBe(Math.floor(Date.now() / 1000) - 86400); + }); + + it('returns undefined for all', () => { + expect(dateBound('all')).toBeUndefined(); + }); +}); + +describe('isFilterActive', () => { + it('is false for the default state', () => { + expect(isFilterActive(defaultFilterState())).toBe(false); + }); + + it('is true for a non-empty keyword (even whitespace-only counts as empty)', () => { + expect(isFilterActive({ ...defaultFilterState(), query: 'x' })).toBe(true); + expect(isFilterActive({ ...defaultFilterState(), query: ' ' })).toBe(false); + }); + + it('is true when any preset differs from all', () => { + expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true); + expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true); + expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true); + }); + + it('ignores the recursive toggle', () => { + expect(isFilterActive({ ...defaultFilterState(), recursive: false })).toBe(false); + }); +}); + +describe('clearFilterState', () => { + it('resets every field in place', () => { + const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const }; + clearFilterState(f); + expect(f).toEqual(defaultFilterState()); + }); +}); + +describe('filterToSearchOptions', () => { + it('omits everything for the default state', () => { + expect(filterToSearchOptions(defaultFilterState())).toEqual({ + fileTypes: undefined, + minSize: undefined, + maxSize: undefined, + modifiedAfter: undefined, + recursive: true + }); + }); + + it('maps each active dimension onto the wire options', () => { + const opts = filterToSearchOptions({ + query: 'report', + recursive: false, + type: 'archive', + size: 'medium', + date: 'month' + }); + expect(opts.fileTypes).toEqual(TYPE_EXT.archive); + expect(opts.minSize).toBe(MB); + expect(opts.maxSize).toBe(100 * MB); + expect(opts.modifiedAfter).toBe(dateBound('month')); + expect(opts.recursive).toBe(false); + }); +}); diff --git a/frontend/src/lib/utils/searchFilters.ts b/frontend/src/lib/utils/searchFilters.ts new file mode 100644 index 00000000..5a4f7b0b --- /dev/null +++ b/frontend/src/lib/utils/searchFilters.ts @@ -0,0 +1,96 @@ +// Shared resource-filter model for search-backed list views. +// +// Extracted from the /search page so the files page's filter bar and +// /search's filter selects share one source of truth for the preset +// vocabularies (type / size / date) and their mapping onto +// `SearchOptions`. Pure functions only — no runes here, so the module is +// unit-testable without component scaffolding. +import type { SearchOptions } from '$lib/api/endpoints/search'; + +export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; +export type SizeKey = 'all' | 'small' | 'medium' | 'large'; +export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; + +/** Full filter state for a search-backed resource list. */ +export interface ResourceFilterState { + query: string; + /** Search subfolders too (backend default is true; exposed explicitly on /files). */ + recursive: boolean; + type: TypeKey; + size: SizeKey; + date: DateKey; +} + +export function defaultFilterState(): ResourceFilterState { + return { query: '', recursive: true, type: 'all', size: 'all', date: 'all' }; +} + +export const TYPE_EXT: Record, string[]> = { + image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'], + video: ['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'wmv', 'flv'], + document: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'odt', 'rtf', 'csv'], + audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus'], + archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'] +}; + +const MB = 1024 * 1024; +export function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } { + switch (k) { + case 'small': + return { maxSize: MB }; + case 'medium': + return { minSize: MB, maxSize: 100 * MB }; + case 'large': + return { minSize: 100 * MB }; + default: + return {}; + } +} + +export function dateBound(k: DateKey): number | undefined { + const day = 86400; + const now = Math.floor(Date.now() / 1000); + switch (k) { + case 'day': + return now - day; + case 'week': + return now - 7 * day; + case 'month': + return now - 30 * day; + case 'year': + return now - 365 * day; + default: + return undefined; + } +} + +/** True when any filter dimension would change the result set. */ +export function isFilterActive(f: ResourceFilterState): boolean { + return f.query.trim() !== '' || f.type !== 'all' || f.size !== 'all' || f.date !== 'all'; +} + +/** Reset every dimension in place (runes-friendly — mutates the $state proxy). */ +export function clearFilterState(f: ResourceFilterState): void { + f.query = ''; + f.recursive = true; + f.type = 'all'; + f.size = 'all'; + f.date = 'all'; +} + +/** + * Map the filter state onto the search-wire options. Scope (folderId) and + * sorting stay the caller's concern — they differ per surface. The date + * preset maps to `modifiedAfter` (its labels read "Past N", which matches + * modified-time semantics); created-time bounds are a possible follow-up. + */ +export function filterToSearchOptions( + f: ResourceFilterState +): Pick { + return { + fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type], + ...sizeBounds(f.size), + modifiedAfter: dateBound(f.date), + recursive: f.recursive + }; +} diff --git a/frontend/src/lib/utils/time.test.ts b/frontend/src/lib/utils/time.test.ts index fbaef7a2..042c2903 100644 --- a/frontend/src/lib/utils/time.test.ts +++ b/frontend/src/lib/utils/time.test.ts @@ -1,7 +1,22 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; import { relativeTimeAgo } from './time'; describe('relativeTimeAgo', () => { + // The formatter resolves the runtime default locale (`undefined`), so on a + // non-English dev machine (e.g. zh-CN Windows) the output is localized and + // these English-unit regexes fail. Pin English for the tests; vitest's + // per-file isolation keeps the module-level formatter cache from leaking. + const RealRelativeTimeFormat = Intl.RelativeTimeFormat; + beforeAll(() => { + // A regular function, not an arrow: time.ts calls the mock via `new`. + vi.spyOn(Intl, 'RelativeTimeFormat').mockImplementation(function ( + locales?: string | string[], + options?: Intl.RelativeTimeFormatOptions + ) { + return new RealRelativeTimeFormat('en', options); + } as unknown as typeof Intl.RelativeTimeFormat); + }); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index c68e1750..f98f5ad4 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -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(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(null); - let moveItems = $state(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`), 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(defaultFilterState()); + const searchActive = $derived(isFilterActive(filter)); + let searchItems = $state>([]); + let searchCursor = $state(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( - items: T[], - limit: number, - fn: (item: T) => Promise - ): Promise { - const out = new Array(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( 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(); - 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(); - 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(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>(() => { + // 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 = []; @@ -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 ). 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 @@ {/if} + +
+ +
+ - {#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} + + {/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()} > {t('files.add_favorites', 'Add to favorites')} @@ -2171,7 +2205,7 @@ class="batch-btn" title={t('files.move', 'Move')} data-testid="files-batch-move-btn" - onclick={batchMove} + onclick={resActions.batchMove} > {t('files.move', 'Move')} @@ -2180,7 +2214,7 @@ class="batch-btn" title={t('files.copy', 'Copy')} data-testid="files-batch-copy-btn" - onclick={batchCopy} + onclick={resActions.batchCopy} > {t('files.copy', 'Copy')} @@ -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()} > {t('common.download', 'Download')} @@ -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()} > {t('common.delete', 'Delete')} @@ -2210,14 +2244,11 @@ {#if moveDialog.component} {@const MoveDialog = moveDialog.component} { - 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); + } diff --git a/frontend/src/routes/files/page.test.ts b/frontend/src/routes/files/page.test.ts index 059dc70d..3fe5645f 100644 --- a/frontend/src/routes/files/page.test.ts +++ b/frontend/src/routes/files/page.test.ts @@ -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'); +}); diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index a1c0a4cf..010607c0 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -22,10 +22,18 @@ import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; + import { TYPE_EXT, dateBound, sizeBounds } from '$lib/utils/searchFilters'; + import { SvelteSet } from 'svelte/reactivity'; + import { replaceSet } from '$lib/utils/sets'; + import { + useResourceActions, + type ActionTarget + } from '$lib/composables/useResourceActions.svelte'; import Icon from '$lib/icons/Icon.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; + import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; const query = $derived(page.url.searchParams.get('q') ?? ''); @@ -145,7 +153,9 @@ void goto(target, { replaceState: true, keepFocus: true, noScroll: true }); } - // Filters + // Filters — the preset vocabularies and their SearchOptions mapping live + // in the shared `searchFilters` util (also consumed by the files page's + // filter bar); only the i18n label lists stay local since they need `t()`. type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; type SizeKey = 'all' | 'small' | 'medium' | 'large'; type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; @@ -153,26 +163,6 @@ let sizeFilter = $state('all'); let dateFilter = $state('all'); - const TYPE_EXT: Record, string[]> = { - image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'], - video: ['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'wmv', 'flv'], - document: [ - 'pdf', - 'doc', - 'docx', - 'xls', - 'xlsx', - 'ppt', - 'pptx', - 'txt', - 'md', - 'odt', - 'rtf', - 'csv' - ], - audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus'], - archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'] - }; const TYPES: { v: TypeKey; l: string }[] = [ { v: 'all', l: t('search.type.all', 'All types') }, { v: 'image', l: t('search.type.image', 'Images') }, @@ -195,36 +185,6 @@ { v: 'year', l: t('search.date.year', 'Past year') } ]; - const MB = 1024 * 1024; - function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } { - switch (k) { - case 'small': - return { maxSize: MB }; - case 'medium': - return { minSize: MB, maxSize: 100 * MB }; - case 'large': - return { minSize: 100 * MB }; - default: - return {}; - } - } - function dateBound(k: DateKey): number | undefined { - const day = 86400; - const now = Math.floor(Date.now() / 1000); - switch (k) { - case 'day': - return now - day; - case 'week': - return now - 7 * day; - case 'month': - return now - 30 * day; - case 'year': - return now - 365 * day; - default: - return undefined; - } - } - const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all'); function clearFilters() { typeFilter = 'all'; @@ -294,6 +254,8 @@ scope === 'folder' && filesStore.section !== 'trash' ? (effectiveFolder ?? undefined) : undefined; + // TYPE_EXT / sizeBounds / dateBound come from `$lib/utils/searchFilters` + // (shared with the files-page filter bar). return { recursive: true, sortBy: orderByForGroup() as SortBy, @@ -383,16 +345,46 @@ // all reuse the same lazy dialogs. let viewerOpen = $state(false); let viewerFile = $state(null); - let moveOpen = $state(false); - let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null); let shareOpen = $state(false); let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null); const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte')); const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte')); const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.svelte')); + + // ── Multi-select + batch actions ───────────────────────────────────── + // Same wiring as the files page: ResourceList owns the row-level + // selection UX and mirrors it out via `onselectionchange`; the shared + // composable owns the batch favorite/download/delete/move/copy flows + // and the MoveDialog state. After any mutation the search re-runs — + // a move/delete can shift rows in or out of the current scope and + // filter set, so patching in place would go stale. + const selected = new SvelteSet(); + function clearSelection() { + selected.clear(); + } + const resActions = useResourceActions({ + getItems: () => items, + getSelected: () => selected, + clearSelection, + onChanged: () => run(query), + afterDelete: () => void session.refresh() + }); + + function onKeydown(e: KeyboardEvent) { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.key === 'Escape' && selected.size) { + clearSelection(); + } else if (e.key === 'Delete' && selected.size) { + // Delete only — Backspace was dropped: it triggered accidental deletes. + e.preventDefault(); + void resActions.batchDelete(); + } + } + $effect(() => { if (viewerOpen) void fileViewer.load(); - if (moveOpen) void moveDialog.load(); + if (resActions.moveDialog.open) void moveDialog.load(); if (shareOpen) void shareDialog.load(); }); @@ -446,8 +438,8 @@ } function openMoveDialog(item: FileItem | FolderItem) { - moveTarget = { id: item.id, name: item.name, kind: kindOf(item) }; - moveOpen = true; + const target: ActionTarget = { id: item.id, name: item.name, kind: kindOf(item) }; + resActions.openMove(target); } function downloadItem(item: FileItem | FolderItem) { @@ -615,7 +607,7 @@ {t('search.title', 'Search')} · OxiCloud - + {#if !query} @@ -629,6 +621,9 @@ emptyText={t('search.no_results', 'No results found for this search')} hasMore={!!cursor} onloadmore={loadMore} + selectable + shiftRangeSelect + onselectionchange={(ids) => replaceSet(selected, ids)} showPath showViewToggle onopen={open} @@ -750,6 +745,56 @@ {/if} {/snippet} + {#snippet batchActions(_sel)} + + + + + + + {/snippet} {#snippet itemActions(item)}