Compare commits

8 Commits

Author SHA1 Message Date
cjw deb6dc831b docs(status): record filter-merge task completion (29d0c335)
CI / Frontend end-to-end tests (via Playwright) (push) Blocked by required conditions
CI / changes (push) Waiting to run
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Blocked by required conditions
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Blocked by required conditions
CI / Migration ordering (new migrations postdate target branch) (push) Blocked by required conditions
CI / Rustfmt (push) Blocked by required conditions
CI / Clippy (push) Blocked by required conditions
CI / Wasm — fmt + clippy (push) Blocked by required conditions
CI / Wasm — release tests (push) Blocked by required conditions
CI / Plugins — fixtures + runtime tests (push) Blocked by required conditions
CI / Server Unit and Functionnal Tests (push) Blocked by required conditions
CI / Security Audit (push) Blocked by required conditions
CI / Build (push) Waiting to run
CI / API, WebDAV & OIDC tests (push) Blocked by required conditions
CI / Bundled-assets binary — embed + SPA-serve integration (push) Blocked by required conditions
CI / WebDAV RFC 4918 — litmus (59/59) (push) Blocked by required conditions
CI / CalDAV + CardDAV — python-caldav (push) Blocked by required conditions
Docker Build and Test / Build and Test Docker Image (push) Waiting to run
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Waiting to run
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Blocked by required conditions
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 20:00:03 +08:00
cjw 29d0c33558 feat(frontend): merge top-bar search with the filter bar, expose kind/created filters
Unify the upstream top-bar search box and the local SearchFilterBar into
one filtering capability sharing a single state store, with the URL as
its projection:

- lib/stores/filter.svelte.ts: module-level $state store, the single
  source of truth; every surface mutates the same proxy in place
- lib/composables/useFilterUrlSync.svelte.ts: one $effect serves both
  directions (URL -> store adoption, store -> URL replaceState write)
  with echo suppression so the loop settles in one round-trip
- FilterFields.svelte: the five selects (type/size/modified/created/
  kind) + recursive switch; word lists and i18n labels live in one
  place; SearchFilterBar reuses it instead of its own copies
- TopBarFilterPanel.svelte: dropdown panel anchored inside the top-bar
  search input (in-field toggle button with active-count badge);
  Escape/outside-click close, no overlay stacking with suggestions
- FilterChips.svelte: dismissible chips on /search, which is now
  URL-driven and loses its three inline <select>s
- New dimensions the backend already supported: kind (file/folder,
  resource_types) and created (created_after); URL keys q/type/size/
  date/created/kind/recursive, non-defaults only, unknown values
  degrade to defaults
- /files keeps in-place filtering (no navigation); folderTarget
  carries filter params through folder entry; ?file= and in/scope
  survive filter writes
- topbar.css: input right padding reserves the three in-field controls

Backend unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 15:40:20 +08:00
cjw b7640e9be4 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
2026-09-20 00:28:52 +08:00
cjw d33d1932b6 perf(thumbnail): cache attached-blob lookups on the request path
Every thumbnail request paid an uncached storage.file_attached_blobs
point query before it could answer — including 304 revalidations and
RAM thumbnail hits, where the ETag path (thumbnail_content_id) probes
the row every time and tier 2b probes it again with the same key. A
photos grid revalidating 60 thumbnails per visit meant 60+ point
queries per browse, repeated on every visit.

find_attached_blob now reads through a process-local moka cache in
DedupService, keyed by the row's (file_id, kind, variant) PK, holding
positive and negative entries (most files have no attached preview, so
the negative side carries the win). Two rules keep it honest:

- DB faults are surfaced as Err and never cached — a transient outage
  cannot freeze "no attached blob" into a negative entry (a read
  failure is never proof that data is absent). The public signature is
  unchanged; the SQL body moved to find_attached_blob_uncached.
- Writes invalidate eagerly: store_attached_blob and the Inserted arm
  of store_attached_blob_if_absent on success, and deletions via
  ThumbnailRefreshHook::on_file_deleted, which all three production
  delete paths (single file, folder cascade, trash clear) fire after
  the DELETE commits. The 60s TTL bounds only what the process cannot
  see (bare SQL, copy_file_satellites races).

The Nextcloud preview endpoint rides the same lookup and benefits
identically. Five in-memory contract tests pin the cache behaviour,
including the fault-not-cached rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 00:28:00 +08:00
cjw 68e21f4bef fix(share): stream single-file shares through /api/s/{token}/file/{id}
CI / changes (push) Has been cancelled
CI / 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
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
The public landing page's inline media preview (added in 6ee26e46)
requests /api/s/{token}/file/{item_id}, but assert_file_in_share went
through resolve_folder_share, which hard-rejects non-folder shares —
so for a single-file share the video src got a 400 and the player
rendered empty: the preview box appeared but nothing would play.

The AuthZ gate now branches on item_type instead: a file share only
accepts file_id == share.item_id, a folder share still requires the
file to live in the shared subtree, and anything else is NotFound
(same shape as "file doesn't exist", preserving anti-enumeration).
Password/expiry checks still happen inside
get_shared_link_with_unlock, unchanged.

Also extend public_shares.hurl section 8b: the file-share token must
stream its own item (200 + inline disposition) and reject an outsider
file id with 404.

NOTE: fmt/clippy/api-test could not run on the authoring machine (no
Rust toolchain or Docker) — run `just check` + `just api-test` before
pushing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 00:53:34 +08:00
cjw d677f92b7f fix(main): consume reuse_port param on Windows builds
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (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 / Security Audit (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Server Unit and Functionnal Tests (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
SO_REUSEPORT is Unix-only; the parameter is only read inside the
#[cfg(not(windows))] block, so -D warnings fails the build with an
unused-variable error on Windows hosts while Linux CI stays green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:02:03 +08:00
cjw 5f9f91ee2f fix(blob): fsync blob files via a write handle so Windows works
The sync sweep (and the EXDEV copy fallback) opened blob files with
File::open — a read-only handle — before calling sync_all. POSIX fsync
accepts read-only fds, so Linux never noticed, but Windows
FlushFileBuffers requires a GENERIC_WRITE handle and fails with
ACCESS_DENIED (os error 5) on every call. On Windows deployments the
strict sweep therefore failed every deferred sync, and the post-copy
fsync silently never happened.

Files now open via OpenOptions::write(true); the best-effort directory
fsyncs keep the read-only POSIX dirent idiom unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:02:02 +08:00
cjw 6ee26e46e6 feat(share): inline media preview on single-file share landing
The public share page only rendered media previews for FOLDER shares —
a single-file share got a bare icon + download button, even for images
and videos the browser can play natively.

Backend: resolve the shared file's mime_type + size at read time and
expose them on ShareDto (meta + password-verify endpoints, one shared
enrichment helper). Display-only enrichment: a failed file lookup
leaves the fields None instead of failing the response — the download
endpoint still surfaces the real error.

Frontend: the 'file' view now reuses the folder grid's lazyVideo
(poster-seek + retry) for video and imageRetry for images, with
Range-aware streaming already provided by /api/s/{token}/file/{id}.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:00:30 +08:00
55 changed files with 3903 additions and 456 deletions
+63
View File
@@ -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 - Leave debug `console.log` statements in code
- Use raw color values in CSS — always use CSS custom properties - 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) - 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 时应剔除。
+25
View File
@@ -16,8 +16,33 @@ The two layers are orthogonal — the moka caches shave query round-trips regard
| Thumbnail cache | configurable | 1 000 | Generated WebP/AVIF thumbnails | | Thumbnail cache | configurable | 1 000 | Generated WebP/AVIF thumbnails |
| Image transcode | configurable | 500 | On-the-fly image transcoding results | | Image transcode | configurable | 500 | On-the-fly image transcoding results |
| Blob hash | 30 s TTI | 5 000 | BLAKE3 hashes for dedup lookups | | Blob hash | 30 s TTI | 5 000 | BLAKE3 hashes for dedup lookups |
| Attached blob | 60 s TTL | 50 000 | `file_attached_blobs` row lookups on the thumbnail hot path (ETag + tier-2b, also the Nextcloud preview endpoint) |
| Audio metadata | — | 2 000 | ID3 tags and duration | | Audio metadata | — | 2 000 | ID3 tags and duration |
### The attached-blob cache
Every thumbnail request pays a `storage.file_attached_blobs` point query
before it can even answer "304 Not Modified" — the ETag names the attached
blob's hash. A photos grid revalidating 60 thumbnails per visit means
60+ point queries per browse. The cache sits in `DedupService` in front of
that lookup (`find_attached_blob`), keyed by the row's `(file_id, kind,
variant)` primary key, and caches **both directions**: `Some(row)` and
`None` (most files have no attached preview, so the negative side is where
most of the win is).
Two rules keep it honest:
- **DB faults are never cached.** The uncached lookup surfaces errors as
`Err`; only a genuine `Ok(None)` fills a negative entry. A transient
outage must not freeze "no attached blob" into place for a full TTL —
a read failure is never proof that data is absent.
- **TTL is the bound, not the invalidation strategy.** Writes invalidate
eagerly — `store_attached_blob` / `store_attached_blob_if_absent` on
success, deletions via `ThumbnailRefreshHook::on_file_deleted` (which
all three production delete paths fire). The 60 s TTL only bounds what
the process cannot see: bare SQL, the `copy_file_satellites` race
window, a hypothetical second instance.
### How it works ### How it works
1. **Read path:** check cache → if hit, return immediately (sub-ms); if miss, query PostgreSQL, populate cache, return 1. **Read path:** check cache → if hit, return immediately (sub-ms); if miss, query PostgreSQL, populate cache, return
@@ -248,6 +248,19 @@ on `DELETE`. The trigger fires on DELETE only; replacing a preview
updates `blob_hash` in place and the Rust path handles that reference updates `blob_hash` in place and the Rust path handles that reference
swap. swap.
**Reads are cached; the cache never outlives the truth by design.**
`DedupService::find_attached_blob` — the lookup the thumbnail ETag path
pays on *every* request, 304 or not — reads through an in-process moka
cache keyed by the row's PK, positive and negative entries alike. Two
properties make that safe rather than merely fast: a DB fault is
surfaced as an error and never fills a negative entry (a failed lookup
is not a missing row), and every write path that can change an answer
invalidates first — the two `store_attached_blob*` variants on success,
deletes via `ThumbnailRefreshHook::on_file_deleted` after the CASCADE
committed. The 60 s TTL exists for the residual cases the process
cannot observe (bare SQL, `copy_file_satellites` racing a concurrent
new file), not as the primary coherence mechanism.
**Writing a derived row requires its source to exist.** **Writing a derived row requires its source to exist.**
`store_derived_blob` guards the insert with an `EXISTS` on `store_derived_blob` guards the insert with an `EXISTS` on
`chunk_manifests`/`blobs`. Without it, a row written just after its `chunk_manifests`/`blobs`. Without it, a row written just after its
+37 -2
View File
@@ -36,8 +36,43 @@ What used to live on the share row but is now resolved through ReBAC:
| Method | Path | Description | | Method | Path | Description |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/s/{token}` | Access a shared item | | `GET` | `/api/s/{token}` | Share landing metadata (see [landing enrichment](#share-landing-metadata-enrichment)) |
| `POST` | `/api/s/{token}/verify` | Verify a password-protected share | | `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 ## Service Responsibilities
+13
View File
@@ -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 instead. They'll receive an invitation, and from then on every change
they make is recorded under their name. 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 ## Expiration
When you share, you can set an **expiration date**. After that date, When you share, you can set an **expiration date**. After that date,
+19
View File
@@ -33,3 +33,22 @@ export function copyFolders(folderIds: string[], targetFolderId: string | null):
target_folder_id: targetFolderId 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<Blob> {
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();
}
@@ -139,3 +139,24 @@ export async function removeFavorite(type: ItemType, id: string): Promise<void>
}); });
if (!res.ok) throw new Error(`remove favorite failed: ${res.status}`); 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<void> {
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}`);
}
+10
View File
@@ -9,6 +9,16 @@ import type { ItemType } from '$lib/api/types';
export interface ShareMeta { export interface ShareMeta {
item_type: ItemType; item_type: ItemType;
item_name: string; item_name: string;
/** The shared item's id — file shares use it to build the preview src. */
item_id: string;
/**
* File shares only: resolved by the server at read time so the landing
* page can inline a media preview (video player / image) instead of a
* bare download button. Absent for folder shares.
*/
mime_type?: string;
/** File shares only: the shared file's size in bytes. */
size?: number;
} }
export interface ShareFolderEntry { export interface ShareFolderEntry {
+131 -14
View File
@@ -13,15 +13,22 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte'; import DrivePicker from '$lib/components/DrivePicker.svelte';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import TopBarFilterPanel from '$lib/components/TopBarFilterPanel.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display'; import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import {
activeFilterCount,
filterParamsToString,
filterToSearchOptions
} from '$lib/utils/searchFilters';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { serverConfig } from '$lib/stores/serverConfig.svelte';
import { serverStatus } from '$lib/stores/serverStatus.svelte'; import { serverStatus } from '$lib/stores/serverStatus.svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte'; import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { preferences } from '$lib/stores/preferences.svelte'; import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte';
@@ -309,7 +316,9 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let searchQuery = $state(''); let searchQuery = $state('');
/** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */ /** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */
let searchActive = $state(false); let mobileSearchOpen = $state(false);
/** Top-bar filter dropdown (opens from the button inside the search input). */
let filterPanelOpen = $state(false);
let langOpen = $state(false); let langOpen = $state(false);
let aboutOpen = $state(false); let aboutOpen = $state(false);
let appVersion = $state(''); let appVersion = $state('');
@@ -348,15 +357,26 @@
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread)); const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
function openMobileSearch() { function openMobileSearch() {
searchActive = true; mobileSearchOpen = true;
requestAnimationFrame(() => searchInputEl?.focus()); requestAnimationFrame(() => searchInputEl?.focus());
} }
function closeMobileSearch() { function closeMobileSearch() {
searchActive = false; mobileSearchOpen = false;
filterPanelOpen = false;
clearSearch(); clearSearch();
} }
function toggleFilterPanel() {
filterPanelOpen = !filterPanelOpen;
if (filterPanelOpen) {
// The two overlays would stack (both anchor to .search-container's
// bottom) — the panel wins while it's open; suggestions resume on
// the next input event after it closes.
suggestOpen = false;
}
}
async function openAbout() { async function openAbout() {
menuOpen = false; menuOpen = false;
aboutOpen = true; aboutOpen = true;
@@ -378,6 +398,10 @@
let suggestions = $state<Suggestion[]>([]); let suggestions = $state<Suggestion[]>([]);
let suggestOpen = $state(false); let suggestOpen = $state(false);
let suggestBusy = $state(false); let suggestBusy = $state(false);
// Live count of active filter presets — drives the toggle button's badge
// and its "filters applied" highlight. The shared store is also what the
// panel (below) and the files page's filter bar mutate.
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
let suggestTimer: ReturnType<typeof setTimeout> | null = null; let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Stale-response guard (same family as the search page): the debounce // Stale-response guard (same family as the search page): the debounce
// spaces requests out but doesn't stop a SLOW earlier response from // spaces requests out but doesn't stop a SLOW earlier response from
@@ -389,16 +413,21 @@
const q = searchQuery.trim(); const q = searchQuery.trim();
if (!q) return; if (!q) return;
suggestOpen = false; suggestOpen = false;
searchActive = false; filterPanelOpen = false;
mobileSearchOpen = false;
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; these params don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
// Active filter presets ride along (`type=image&kind=file&…`) — the
// shared store is what the top-bar panel and the files page's bar
// mutate, so submitting from here carries exactly what the user sees.
const filterQs = filterParamsToString(resourceFilters);
if (filterQs) parts.push(filterQs);
// Carry the currently-open folder into the search URL as `?in=<uuid>` // Carry the currently-open folder into the search URL as `?in=<uuid>`
// so a hard refresh, a shared link, or a bookmark all restore the // so a hard refresh, a shared link, or a bookmark all restore the
// "This folder" scope. Trash section is always global — skip. See // "This folder" scope. Trash section is always global — skip. See
// `/search/+page.svelte` for the receiver side. // `/search/+page.svelte` for the receiver side.
//
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; the two params here don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
if (filesStore.currentFolder && filesStore.section !== 'trash') { if (filesStore.currentFolder && filesStore.section !== 'trash') {
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`); parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
} }
@@ -411,6 +440,8 @@
} }
function onSearchInput() { function onSearchInput() {
// The filter panel owns the dropdown area while open — don't fight it.
if (filterPanelOpen) return;
if (suggestTimer) clearTimeout(suggestTimer); if (suggestTimer) clearTimeout(suggestTimer);
const q = searchQuery.trim(); const q = searchQuery.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -428,7 +459,13 @@
suggestInflight = ctl; suggestInflight = ctl;
suggestBusy = true; suggestBusy = true;
try { try {
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal }); // Suggestions honor the active filter presets, mirroring what
// "See all results" will show on /search.
const r = await searchResources(q, {
...filterToSearchOptions(resourceFilters),
limit: 9,
signal: ctl.signal
});
if (seq !== suggestSeq) return; // superseded while awaiting if (seq !== suggestSeq) return; // superseded while awaiting
// The wire is ordered — folders first, then files — but slice // The wire is ordered — folders first, then files — but slice
// per kind explicitly so the header preview stays a folder-heavy // per kind explicitly so the header preview stays a folder-heavy
@@ -491,6 +528,22 @@
langOpen = false; langOpen = false;
} }
/**
* Window-level click: the existing close-outside-click for the menus,
* plus the same treatment for the top-bar filter panel. Anything inside
* the search container — the input, the toggle, the panel itself — keeps
* the panel open (same `closest` guard as DisplayModeControls).
*/
function onGlobalClick(e: MouseEvent) {
closeMenus();
if (
filterPanelOpen &&
!(e.target instanceof Element && e.target.closest('.search-container'))
) {
filterPanelOpen = false;
}
}
/** /**
* True when the shortcut target is a text-input surface — <input>, * True when the shortcut target is a text-input surface — <input>,
* <textarea>, or any `contenteditable` element. Used by the * <textarea>, or any `contenteditable` element. Used by the
@@ -563,7 +616,7 @@
</script> </script>
<svelte:window <svelte:window
onclick={closeMenus} onclick={onGlobalClick}
onkeydown={(e) => { onkeydown={(e) => {
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted, // First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
// the palette's own handler takes over toggling/closing. // the palette's own handler takes over toggling/closing.
@@ -597,7 +650,8 @@
} }
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (aboutOpen) aboutOpen = false; if (aboutOpen) aboutOpen = false;
else if (searchActive) closeMobileSearch(); else if (filterPanelOpen) filterPanelOpen = false;
else if (mobileSearchOpen) closeMobileSearch();
else closeMenus(); else closeMenus();
}} }}
/> />
@@ -702,7 +756,7 @@
</div> </div>
<div class="main-content"> <div class="main-content">
<div class="top-bar" class:top-bar--search-active={searchActive}> <div class="top-bar" class:top-bar--search-active={mobileSearchOpen}>
<button <button
class="sidebar-toggle" class="sidebar-toggle"
aria-label={t('nav.toggle', 'Toggle navigation menu')} aria-label={t('nav.toggle', 'Toggle navigation menu')}
@@ -760,6 +814,24 @@
<Icon name="times" /> <Icon name="times" />
</button> </button>
{/if} {/if}
<!-- Filter toggle sits inside the input's right edge (between the
clear × and the submit button) and opens the shared filter
panel — the same state the files page's filter bar edits. -->
<button
class="search-filter-btn"
class:search-filter-btn--active={filterPanelOpen || filterActiveCount > 0}
type="button"
aria-expanded={filterPanelOpen}
title={t('filter.advanced', 'Filters')}
aria-label={t('filter.advanced', 'Filters')}
data-testid="appshell-filter-toggle-btn"
onclick={toggleFilterPanel}
>
<Icon name="sliders-h" />
{#if filterActiveCount > 0}
<span class="search-filter-btn__badge">{filterActiveCount}</span>
{/if}
</button>
<button <button
class="search-button" class="search-button"
type="submit" type="submit"
@@ -770,6 +842,10 @@
<Icon name="search" /> <Icon name="search" />
</button> </button>
{#if filterPanelOpen}
<TopBarFilterPanel value={resourceFilters} onclose={() => (filterPanelOpen = false)} />
{/if}
{#if suggestOpen} {#if suggestOpen}
<ul class="suggest"> <ul class="suggest">
{#each suggestions as s (s.kind + s.item.id)} {#each suggestions as s (s.kind + s.item.id)}
@@ -1223,7 +1299,7 @@
/* Clear (×) button sits left of the submit button inside the search field. */ /* Clear (×) button sits left of the submit button inside the search field. */
.search-clear { .search-clear {
position: absolute; position: absolute;
right: 44px; right: 72px;
display: grid; display: grid;
place-items: center; place-items: center;
width: 28px; width: 28px;
@@ -1240,6 +1316,47 @@
color: var(--color-text); color: var(--color-text);
} }
/* Filter toggle inside the input's right edge: submit button at 6px,
clear × at 72px, this one between them at 44px. The badge counts the
active presets, mirroring the files page's filter-bar toggle. */
.search-filter-btn {
position: absolute;
right: 44px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.search-filter-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.search-filter-btn--active {
color: var(--color-accent);
}
.search-filter-btn__badge {
position: absolute;
top: -2px;
right: -3px;
min-width: 13px;
height: 13px;
padding: 0 2px;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 9px;
line-height: 13px;
text-align: center;
}
.suggest { .suggest {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
@@ -88,3 +88,49 @@ it('submits a search and routes to /search', async () => {
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn')); await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report')); await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
}); });
it('opens the filter panel, and its presets ride along on submit', async () => {
render(AppShell, { props: { children } });
// Panel opens from the toggle inside the search input…
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
// …and picking a preset lights the toggle's badge (shared store).
await fireEvent.change(screen.getByTestId('filter-type-select'), {
target: { value: 'image' }
});
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent).toContain('1');
// Submitting carries the preset into the search URL.
const input = screen.getByTestId('appshell-search-input');
await fireEvent.input(input, { target: { value: 'report' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report&type=image'));
// Submit closes the panel; reopen it and "Clear filters" resets the
// presets — the badge drops and the next submit is param-free.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent!.includes('1')).toBe(false);
await fireEvent.input(input, { target: { value: 'again' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=again'));
// Leave the shared store clean for the other tests in this file.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
});
it('closes the filter panel on Escape and on outside click', async () => {
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('appshell-filter-done-btn'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Outside click (window-level handler) reopens-then-closes too.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('shell-child'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Escape closes it as well.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
});
@@ -0,0 +1,130 @@
<!--
The active filter presets as dismissible chips, for surfaces whose filter
editing lives elsewhere (the top bar's panel) — /search after its in-page
selects were retired. Without these the page would show filtered results
with no visible way to see or undo why.
`value` is the shared state proxy, mutated in place — deliberately a plain
prop, not `$bindable`: there is exactly one state object (the store) and
nobody reassigns it, so a two-way binding contract would be noise.
-->
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
activeFilters,
clearFilterDimension,
filterDimensionLabelKey,
filterValueLabelKey,
type ResourceFilterState
} from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
}
let { value }: Props = $props();
const chips = $derived(activeFilters(value));
function clearAll() {
for (const { key } of activeFilters(value)) clearFilterDimension(value, key);
// A lingering recursive=0 would survive a "clear everything" otherwise.
value.recursive = true;
}
</script>
{#if chips.length > 0}
<div
class="fchips"
data-testid="filter-chips"
role="list"
aria-label={t('filter.advanced', 'Filters')}
>
{#each chips as chip (chip.key)}
<span class="fchips__chip" role="listitem" data-testid={`filter-chip-${chip.key}`}>
<span class="fchips__dim">{t(filterDimensionLabelKey(chip.key), chip.key)}</span>
<span class="fchips__val">{t(filterValueLabelKey(chip.key, chip.value), chip.value)}</span>
<button
class="fchips__dismiss"
type="button"
aria-label={t('common.clear', 'Clear')}
data-testid={`filter-chip-${chip.key}-dismiss-btn`}
onclick={() => clearFilterDimension(value, chip.key)}
>
<Icon name="times" />
</button>
</span>
{/each}
<button
class="fchips__clear-all"
type="button"
data-testid="filter-clear-all-btn"
onclick={clearAll}
>
{t('search.clear_filters', 'Clear filters')}
</button>
</div>
{/if}
<style>
.fchips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.fchips__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.3rem 0.15rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl, 999px);
background: var(--color-bg-surface);
font-size: var(--text-sm);
}
.fchips__dim {
color: var(--color-text-secondary);
}
.fchips__val {
color: var(--color-text);
font-weight: 500;
}
.fchips__dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.fchips__dismiss:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.fchips__clear-all {
padding: 0.15rem 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-accent);
font-size: var(--text-sm);
cursor: pointer;
}
.fchips__clear-all:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,178 @@
<!--
The filter dimensions themselves (type / size / modified / created / kind /
recursive), bound to the shared `ResourceFilterState`.
Extracted so the files page's filter bar and the top bar's filter panel
render the same fields off the same vocabulary — the option lists used to
live in two copies (SearchFilterBar and the /search page). The vocabularies
(extensions, byte bounds, date presets) stay in `$lib/utils/searchFilters`;
only the i18n labels live here.
-->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
import {
type DateKey,
type KindKey,
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters';
interface Props {
/** Bindable filter state — the caller owns it (usually the shared store). */
value: ResourceFilterState;
/** `inline` wraps in a row (files page); `panel` uses a grid (top bar). */
layout?: 'inline' | 'panel';
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let { value = $bindable(), layout = 'inline', hideRecursive = false }: Props = $props();
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const KINDS: { v: KindKey; l: string }[] = [
{ v: 'all', l: t('filter.kind.all', 'Files and folders') },
{ v: 'file', l: t('filter.kind.file', 'Files only') },
{ v: 'folder', l: t('filter.kind.folder', 'Folders only') }
];
</script>
<div
class="filter-fields"
class:filter-fields--panel={layout === 'panel'}
data-testid="filter-fields"
>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.type_label', 'Type')}</span>
<select data-testid="filter-type-select" bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.kind_label', 'Items')}</span>
<select data-testid="filter-kind-select" bind:value={value.kind}>
{#each KINDS as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.size_label', 'Size')}</span>
<select data-testid="filter-size-select" bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.modified_label', 'Modified')}</span>
<select data-testid="filter-date-select" bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.created_label', 'Created')}</span>
<select data-testid="filter-created-select" bind:value={value.created}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="filter-fields__check">
<input
type="checkbox"
data-testid="filter-recursive-checkbox"
bind:checked={value.recursive}
/>
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</div>
<style>
.filter-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
/* Panel shape: two even columns, so five selects plus the toggle do not
run off the width of the top bar's search slot. */
.filter-fields--panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.filter-fields__field {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.filter-fields__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.filter-fields__field select {
flex: 1;
min-width: 0;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
max-width: 9rem;
}
.filter-fields__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
@media (width <= 768px) {
.filter-fields--panel {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,266 @@
<script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
activeFilterCount,
defaultFilterState,
type ResourceFilterState
} from '$lib/utils/searchFilters';
interface Props {
/**
* Full filter state (keyword + toggles + presets), mutated in place —
* in practice the shared store proxy from `$lib/stores/filter.svelte`.
* Deliberately NOT `$bindable`: there is exactly one state object and
* nobody reassigns it, so a two-way binding contract would be noise.
*/
value?: ResourceFilterState;
/** Advanced section (presets + recursive) expanded. */
expanded?: boolean;
placeholder?: string;
/** Debounce for the keyword input (ms). */
debounceMs?: number;
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let {
value = defaultFilterState(),
expanded = $bindable(false),
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
debounceMs = 300,
hideRecursive = false
}: Props = $props();
// Keyword buffer: typing updates the buffer immediately (responsive input)
// and pushes into `value.query` debounced, so a keystroke doesn't fire a
// backend search per character. `lastPushed` disambiguates our own pushes
// from external writes (e.g. the page's clear-filter Escape path, or a
// URL hydration of a deep link like /files/…?q=abc), which flow back into
// the buffer via the sync effect below — including on first mount, which
// is why both start empty instead of capturing `value.query` here.
let keyword = $state('');
let lastPushed = '';
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// External `value.query` change → adopt it into the input buffer.
const external = value.query;
if (external !== lastPushed) {
if (timer) {
clearTimeout(timer);
timer = null;
}
keyword = external;
lastPushed = external;
}
});
$effect(() => {
// No reactive deps — teardown-only, clearing a pending debounce on destroy.
return () => {
if (timer) clearTimeout(timer);
};
});
function pushKeyword(v: string) {
if (timer) {
clearTimeout(timer);
timer = null;
}
value.query = v;
lastPushed = v;
}
function handleInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
keyword = v;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
pushKeyword(v);
}, debounceMs);
}
function onInputKeydown(e: KeyboardEvent) {
// Escape clears the keyword locally and never reaches the page-level
// handler (which would otherwise also clear the selection / filters).
if (e.key === 'Escape') {
e.stopPropagation();
clearKeyword();
} else if (e.key === 'Enter') {
// Enter flushes the debounce for an immediate search.
e.preventDefault();
pushKeyword(keyword);
}
}
function clearKeyword() {
keyword = '';
pushKeyword('');
}
const activeCount = $derived(activeFilterCount(value));
</script>
<div class="sfb" data-testid="search-filter-bar">
<div class="sfb__row">
<div class="sfb__input-wrap">
<span class="sfb__magnifier"><Icon name="search" /></span>
<input
class="sfb__input"
type="search"
{placeholder}
aria-label={t('filter.keyword', 'Keyword')}
data-testid="filter-keyword-input"
value={keyword}
oninput={handleInput}
onkeydown={onInputKeydown}
/>
{#if keyword.length > 0}
<button
class="sfb__clear"
type="button"
aria-label={t('filter.clear_keyword', 'Clear search')}
data-testid="filter-clear-keyword-btn"
onclick={clearKeyword}
>
<Icon name="times" />
</button>
{/if}
</div>
<button
class="sfb__toggle"
class:sfb__toggle--active={activeCount > 0}
type="button"
aria-expanded={expanded}
aria-label={t('filter.advanced', 'Filters')}
title={t('filter.advanced', 'Filters')}
data-testid="filter-advanced-toggle-btn"
onclick={() => (expanded = !expanded)}
>
<Icon name="sliders-h" />
{#if activeCount > 0}
<span class="sfb__badge">{activeCount}</span>
{/if}
</button>
</div>
{#if expanded}
<div data-testid="filter-advanced-row">
<FilterFields bind:value {hideRecursive} />
</div>
{/if}
</div>
<style>
.sfb {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 100%;
}
.sfb__row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.sfb__input-wrap {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.sfb__magnifier {
position: absolute;
left: 0.6rem;
color: var(--color-text-secondary);
pointer-events: none;
}
.sfb__input {
width: 100%;
padding: 0.45rem 2rem 0.45rem 2.1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.sfb__input:focus {
outline: none;
border-color: var(--color-accent);
}
.sfb__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.sfb__clear {
position: absolute;
right: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.sfb__clear:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.sfb__toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
flex-shrink: 0;
}
.sfb__toggle:hover {
background: var(--color-bg-hover);
}
.sfb__toggle--active {
border-color: var(--color-accent);
color: var(--color-accent);
}
.sfb__badge {
position: absolute;
top: -0.4rem;
right: -0.4rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: var(--text-xs, 0.7rem);
line-height: 1rem;
text-align: center;
}
</style>
@@ -0,0 +1,122 @@
<!--
The top bar's filter dropdown — the panel that opens from the button inside
the search input. A shell around the shared `FilterFields` plus a
clear-presets footer; positioning assumes it is rendered inside
`.search-container` (position: relative), same stacking trick as the
suggestions list.
Mutates the shared state proxy in place — plain prop, not `$bindable` (see
FilterChips for the rationale).
-->
<script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { activeFilterCount, type ResourceFilterState } from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
/** Dismisses the panel (the clear button keeps it open on the files page). */
onclose: () => void;
}
let { value, onclose }: Props = $props();
const activeCount = $derived(activeFilterCount(value));
function clearPresets() {
for (const dim of ['type', 'size', 'date', 'created', 'kind'] as const) {
value[dim] = 'all';
}
value.recursive = true;
}
</script>
<div
class="tfp"
data-testid="appshell-filter-panel"
role="group"
aria-label={t('filter.advanced', 'Filters')}
>
<FilterFields layout="panel" {value} />
<div class="tfp__footer">
<button
class="tfp__clear"
type="button"
data-testid="appshell-filter-clear-btn"
disabled={activeCount === 0 && value.recursive}
onclick={clearPresets}
>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
<button
class="tfp__done"
type="button"
data-testid="appshell-filter-done-btn"
onclick={onclose}
>
{t('common.done', 'Done')}
</button>
</div>
</div>
<style>
.tfp {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, var(--radius-md));
background: var(--color-bg-surface);
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
}
.tfp__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.tfp__clear,
.tfp__done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: none;
border-radius: var(--radius-sm);
background: none;
font-size: var(--text-sm);
cursor: pointer;
}
.tfp__clear {
color: var(--color-accent);
}
.tfp__clear:disabled {
color: var(--color-text-secondary);
cursor: default;
}
.tfp__clear:not(:disabled):hover {
background: var(--color-bg-hover);
}
.tfp__done {
background: var(--color-accent);
color: var(--color-on-accent);
}
.tfp__done:hover {
filter: brightness(1.05);
}
</style>
@@ -0,0 +1,60 @@
/**
* Two-way sync between the shared filter store and the current page's URL.
*
* The store is the single source of truth; the URL is its projection. One
* `$effect` serves both directions, disambiguated by tracking the last URL
* search string we processed:
*
* - URL changed (navigation, Back/Forward, a shared link) → the store adopts
* the URL's filter params via `hydrateFilters`.
* - Store changed (a select moved, keyword debounced in) → the new state is
* written back with `goto(..., { replaceState: true })` so refresh,
* bookmarks and Back all see it — without adding a history entry per edit.
*
* Convergence: after a write, `page.url` catches up to what we wrote, the
* `lastSeenSearch` guard recognizes it as our own echo and the store is left
* alone — the loop settles in one round-trip instead of ping-ponging.
*
* `includeQuery` decides who owns `?q=`: true on the files page (the filter
* bar's keyword belongs in the URL there), false on /search (the top-bar
* search box writes `?q=` itself; a second writer would fight it).
*/
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { hydrateFilters, resourceFilters } from '$lib/stores/filter.svelte';
import { filterSearchOver, filtersFromParams } from '$lib/utils/searchFilters';
export function useFilterUrlSync(includeQuery: boolean): void {
let lastSeenSearch: string | null = null;
$effect(() => {
const current = page.url;
if (current.search !== lastSeenSearch) {
// External URL change → URL wins. `untrack` because `hydrateFilters`
// mutates the store we read below; the mutation schedules this
// effect's next run, where `lastSeenSearch` already matches and the
// adoption is skipped.
lastSeenSearch = current.search;
untrack(() => hydrateFilters(filtersFromParams(current.searchParams)));
}
// Store → URL: overwrite only the filter params so the surface's own
// params (`?file=`, `in`, `scope`) survive untouched. The desired
// search string is composed in `searchFilters` — a throwaway `new URL`
// copy here would trip svelte/prefer-svelte-reactivity.
const desired = filterSearchOver(current.searchParams, resourceFilters, includeQuery);
if (desired !== current.search) {
lastSeenSearch = desired;
// Same-origin path/search/hash built from page.url; resolve() only
// accepts a route string, so it can't type a dynamic URL.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(`${current.pathname}${desired}${current.hash}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
});
}
@@ -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<FileItem | FolderItem>;
/** Selected ids — the page's SvelteSet mirror of the list's selection. */
getSelected: () => ReadonlySet<string>;
clearSelection: () => void;
/** After a successful delete: reload the listing or re-run the search. */
onChanged: () => void | Promise<void>;
/** 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<string>) => 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<string, ActionTarget>();
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<void> {
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<void> {
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<string, FileItem | FolderItem>();
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<void> {
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<void> {
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;
}
@@ -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<typeof vi.fn>;
// 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<string, unknown> = {}): FileItem {
return {
id,
name: `${id}.txt`,
mime_type: 'text/plain',
is_favorite: false,
...overrides
} as unknown as FileItem;
}
function folderItem(id: string, overrides: Record<string, unknown> = {}): FolderItem {
return {
id,
name: id,
is_favorite: false,
...overrides
} as unknown as FolderItem;
}
function harness(items: Array<FileItem | FolderItem>, 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<ActionTarget[]>([
{ 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<ActionTarget[]>([
{ 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();
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared resource-filter state for the search-backed surfaces.
*
* One module-level `$state` object is THE filter state: the files page's
* `SearchFilterBar`, the top bar's filter panel, and the /search chips all
* read and mutate this same proxy, and each surface projects it into its URL
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
* never reassign, which is also what keeps every holder looking at the same
* proxy.
*/
import {
clearFilterState,
defaultFilterState,
type ResourceFilterState
} from '$lib/utils/searchFilters';
export const resourceFilters: ResourceFilterState = $state(defaultFilterState());
/**
* Adopt `next` field-by-field (never reassign — the proxy identity is the
* contract every surface binds to).
*/
export function hydrateFilters(next: ResourceFilterState): void {
resourceFilters.query = next.query;
resourceFilters.recursive = next.recursive;
resourceFilters.type = next.type;
resourceFilters.size = next.size;
resourceFilters.date = next.date;
resourceFilters.created = next.created;
resourceFilters.kind = next.kind;
}
/** Reset every dimension (keyword included) in place. */
export function resetFilters(): void {
clearFilterState(resourceFilters);
}
+3 -1
View File
@@ -112,7 +112,9 @@
.search-container input { .search-container input {
width: 100%; width: 100%;
padding: var(--space-3) 50px var(--space-3) var(--space-11); /* Right padding reserves the three in-field controls: submit (6px),
filter toggle (44px), clear × (72px). */
padding: var(--space-3) 104px var(--space-3) var(--space-11);
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
background-color: var(--color-bg-input); background-color: var(--color-bg-input);
+49
View File
@@ -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]);
});
});
+24
View File
@@ -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<T, R>(
items: readonly 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.max(0, Math.min(limit, items.length)) }, worker));
return out;
}
@@ -0,0 +1,297 @@
import { describe, expect, it } from 'vitest';
import {
activeFilterCount,
activeFilters,
applyFilterParams,
clearFilterDimension,
clearFilterState,
dateBound,
defaultFilterState,
filterParamEntries,
filterParamsToString,
filterSearchOver,
filtersFromParams,
filterToSearchOptions,
filterValueLabelKey,
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);
expect(isFilterActive({ ...defaultFilterState(), created: 'week' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), kind: 'folder' })).toBe(true);
});
it('ignores the recursive toggle', () => {
expect(isFilterActive({ ...defaultFilterState(), recursive: false })).toBe(false);
});
});
describe('activeFilters', () => {
it('is empty for the default state', () => {
expect(activeFilters(defaultFilterState())).toEqual([]);
expect(activeFilterCount(defaultFilterState())).toBe(0);
});
it('lists live dimensions in URL-key order', () => {
const f = {
...defaultFilterState(),
kind: 'file' as const,
type: 'image' as const,
date: 'week' as const
};
expect(activeFilters(f)).toEqual([
{ key: 'type', value: 'image' },
{ key: 'date', value: 'week' },
{ key: 'kind', value: 'file' }
]);
expect(activeFilterCount(f)).toBe(3);
});
it('does not count the keyword', () => {
expect(activeFilterCount({ ...defaultFilterState(), query: 'report' })).toBe(0);
});
});
describe('clearFilterDimension', () => {
it('resets only the named dimension', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'folder' as const };
clearFilterDimension(f, 'kind');
expect(f.kind).toBe('all');
expect(f.type).toBe('image');
});
});
describe('filterValueLabelKey', () => {
it('names each dimension from its own i18n family', () => {
expect(filterValueLabelKey('type', 'image')).toBe('search.type.image');
expect(filterValueLabelKey('size', 'large')).toBe('search.size.large');
expect(filterValueLabelKey('date', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('created', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('kind', 'folder')).toBe('filter.kind.folder');
});
});
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,
createdAfter: undefined,
resourceTypes: 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',
created: 'day',
kind: 'folder'
});
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.createdAfter).toBe(dateBound('day'));
expect(opts.resourceTypes).toEqual(['folder']);
expect(opts.recursive).toBe(false);
});
});
describe('filtersFromParams', () => {
it('returns the defaults for an empty query string', () => {
expect(filtersFromParams(new URLSearchParams(''))).toEqual(defaultFilterState());
});
it('reads every dimension', () => {
const f = filtersFromParams(
new URLSearchParams('q=report&type=image&size=large&date=week&created=day&kind=folder')
);
expect(f).toEqual({
query: 'report',
recursive: true,
type: 'image',
size: 'large',
date: 'week',
created: 'day',
kind: 'folder'
});
});
it('falls back to the default for unknown values instead of erroring', () => {
const f = filtersFromParams(new URLSearchParams('type=pdf&size=huge&kind=folderz'));
expect(f.type).toBe('all');
expect(f.size).toBe('all');
expect(f.kind).toBe('all');
});
it('treats recursive=0 as off and anything else as on', () => {
expect(filtersFromParams(new URLSearchParams('recursive=0')).recursive).toBe(false);
expect(filtersFromParams(new URLSearchParams('recursive=1')).recursive).toBe(true);
});
it('ignores params that belong to the surface', () => {
const f = filtersFromParams(new URLSearchParams('in=abc&scope=all&file=xyz'));
expect(f).toEqual(defaultFilterState());
});
});
describe('applyFilterParams', () => {
it('writes only the non-default dimensions', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'file' as const };
const p = new URLSearchParams('');
applyFilterParams(p, f);
expect(p.toString()).toBe('type=image&kind=file');
});
it('keeps the surface-owned params around it', () => {
const f = { ...defaultFilterState(), size: 'small' as const, recursive: false };
const p = new URLSearchParams('in=abc&scope=all');
applyFilterParams(p, f);
expect(p.get('in')).toBe('abc');
expect(p.get('scope')).toBe('all');
expect(p.get('size')).toBe('small');
expect(p.get('recursive')).toBe('0');
});
it('drops a dimension that went back to its default', () => {
const p = new URLSearchParams('type=image&kind=folder');
applyFilterParams(p, defaultFilterState());
expect(p.toString()).toBe('');
});
it('leaves the keyword alone unless asked', () => {
const p = new URLSearchParams('q=report');
applyFilterParams(p, defaultFilterState());
expect(p.get('q')).toBe('report');
const p2 = new URLSearchParams('q=report');
applyFilterParams(p2, { ...defaultFilterState(), query: 'plan' }, true);
expect(p2.get('q')).toBe('plan');
});
it('round-trips through filtersFromParams', () => {
const f = {
query: 'report',
recursive: false,
type: 'video' as const,
size: 'medium' as const,
date: 'month' as const,
created: 'year' as const,
kind: 'file' as const
};
const p = new URLSearchParams('');
applyFilterParams(p, f, true);
expect(filtersFromParams(p)).toEqual(f);
});
});
describe('filterParamsToString', () => {
it('omits the keyword by default and encodes values', () => {
expect(filterParamsToString({ ...defaultFilterState(), query: 'a b' })).toBe('');
expect(
filterParamsToString({ ...defaultFilterState(), query: 'a b', type: 'image' }, true)
).toBe('q=a%20b&type=image');
});
});
describe('filterParamEntries', () => {
it('lists every live dimension as a param pair', () => {
const f = {
...defaultFilterState(),
type: 'image' as const,
size: 'large' as const,
date: 'week' as const,
created: 'month' as const,
kind: 'folder' as const,
recursive: false
};
expect(filterParamEntries(f)).toEqual([
['type', 'image'],
['size', 'large'],
['date', 'week'],
['created', 'month'],
['kind', 'folder'],
['recursive', '0']
]);
});
});
describe('filterSearchOver', () => {
it('overwrites filter params onto a base and keeps foreign params', () => {
const base = new URLSearchParams('?file=abc&in=dir1&type=video');
expect(filterSearchOver(base, { ...defaultFilterState(), type: 'image' })).toBe(
'?file=abc&in=dir1&type=image'
);
});
it('collapses an all-defaults result to an empty search string', () => {
const base = new URLSearchParams('?type=video&q=x');
// includeQuery=true — with false, `?q=` belongs to the search box and
// survives the overwrite by design.
expect(filterSearchOver(base, defaultFilterState(), true)).toBe('');
});
});
+277
View File
@@ -0,0 +1,277 @@
// Shared resource-filter model for search-backed list views.
//
// One source of truth for the preset vocabularies (type / size / date /
// created / kind), their mapping onto `SearchOptions`, and their
// serialization in and out of the URL. The files page's filter bar, the
// top bar's filter panel and the /search page all bind the same state
// object, and the URL is its projection — see `filtersFromParams` /
// `filterParamsInto`. 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';
/** Files, folders, or both — maps to the backend's `resource_types`. */
export type KindKey = 'all' | 'file' | 'folder';
/** The filter dimensions carried in the URL, in URL-key order. */
export type FilterDimension = 'type' | 'size' | 'date' | 'created' | 'kind';
/** 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;
/** Created-time preset, independent of the modified-time one. */
created: DateKey;
kind: KindKey;
}
export function defaultFilterState(): ResourceFilterState {
return {
query: '',
recursive: true,
type: 'all',
size: 'all',
date: 'all',
created: 'all',
kind: 'all'
};
}
export const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, 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() !== '' || activeFilterCount(f) > 0;
}
/**
* Number of active preset dimensions (the keyword is counted separately —
* it has its own clear affordance). Drives the filter button's badge.
*/
export function activeFilterCount(f: ResourceFilterState): number {
return activeFilters(f).length;
}
/** The dimensions currently differing from their default, URL-key ordered. */
export function activeFilters(f: ResourceFilterState): { key: FilterDimension; value: string }[] {
const out: { key: FilterDimension; value: string }[] = [];
if (f.type !== 'all') out.push({ key: 'type', value: f.type });
if (f.size !== 'all') out.push({ key: 'size', value: f.size });
if (f.date !== 'all') out.push({ key: 'date', value: f.date });
if (f.created !== 'all') out.push({ key: 'created', value: f.created });
if (f.kind !== 'all') out.push({ key: 'kind', value: f.kind });
return out;
}
/**
* i18n key naming a dimension's current value, so chips and labels read
* from one vocabulary. `created` reuses the modified-time preset labels
* ("Past week" reads the same for either timestamp) and `kind` gets its
* own family.
*/
export function filterValueLabelKey(key: FilterDimension, value: string): string {
if (key === 'created') return `search.date.${value}`;
if (key === 'kind') return `filter.kind.${value}`;
return `search.${key}.${value}`;
}
/** i18n key naming a dimension itself (the chip's "Type:" prefix). */
export function filterDimensionLabelKey(key: FilterDimension): string {
if (key === 'created') return 'filter.created_label';
if (key === 'kind') return 'filter.kind_label';
if (key === 'date') return 'filter.modified_label';
return `search.${key}_label`;
}
/** Reset one dimension to its default (the chip's dismiss action). */
export function clearFilterDimension(f: ResourceFilterState, key: FilterDimension): void {
if (key === 'kind') f.kind = 'all';
else f[key] = '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';
f.created = 'all';
f.kind = 'all';
}
/**
* Map the filter state onto the search-wire options. Scope (folderId) and
* sorting stay the caller's concern — they differ per surface.
*/
export function filterToSearchOptions(
f: ResourceFilterState
): Pick<
SearchOptions,
| 'fileTypes'
| 'minSize'
| 'maxSize'
| 'modifiedAfter'
| 'createdAfter'
| 'resourceTypes'
| 'recursive'
> {
return {
fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type],
...sizeBounds(f.size),
modifiedAfter: dateBound(f.date),
createdAfter: dateBound(f.created),
resourceTypes: f.kind === 'all' ? undefined : [f.kind],
recursive: f.recursive
};
}
// ---------------------------------------------------------------------------
// URL projection
//
// The URL carries the UI preset keys (`type=image`), never the expanded wire
// values (extension lists, byte ranges) — the URL stays readable and keeps
// working when a vocabulary grows. Only non-default dimensions are written,
// and an unrecognized value degrades to the default rather than erroring.
// ---------------------------------------------------------------------------
const TYPE_KEYS: readonly TypeKey[] = ['all', 'image', 'video', 'document', 'audio', 'archive'];
const SIZE_KEYS: readonly SizeKey[] = ['all', 'small', 'medium', 'large'];
const DATE_KEYS: readonly DateKey[] = ['all', 'day', 'week', 'month', 'year'];
const KIND_KEYS: readonly KindKey[] = ['all', 'file', 'folder'];
/** Every param this module owns, so a write can clear the stale ones first. */
const FILTER_PARAM_KEYS = ['q', 'type', 'size', 'date', 'created', 'kind', 'recursive'] as const;
function pickOne<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
return raw !== null && (allowed as readonly string[]).includes(raw) ? (raw as T) : fallback;
}
/**
* Read the filter dimensions out of URL params. Every other param (`in`,
* `scope`, `file`, …) belongs to the surface and is left untouched; missing
* or unknown values fall back to the default for that dimension.
*/
export function filtersFromParams(params: URLSearchParams): ResourceFilterState {
return {
query: params.get('q')?.trim() ?? '',
recursive: params.get('recursive') !== '0',
type: pickOne(params.get('type'), TYPE_KEYS, 'all'),
size: pickOne(params.get('size'), SIZE_KEYS, 'all'),
date: pickOne(params.get('date'), DATE_KEYS, 'all'),
created: pickOne(params.get('created'), DATE_KEYS, 'all'),
kind: pickOne(params.get('kind'), KIND_KEYS, 'all')
};
}
/**
* The query-string entries for a filter state, defaults omitted.
*
* `q` is opt-in: on the files page the keyword belongs to the filter bar, but
* on /search the search box owns `?q=` and already writes it — emitting it
* from the state there would put two writers on one param.
*/
export function filterParamEntries(
f: ResourceFilterState,
includeQuery = false
): [string, string][] {
const out: [string, string][] = [];
const q = f.query.trim();
if (includeQuery && q) out.push(['q', q]);
if (f.type !== 'all') out.push(['type', f.type]);
if (f.size !== 'all') out.push(['size', f.size]);
if (f.date !== 'all') out.push(['date', f.date]);
if (f.created !== 'all') out.push(['created', f.created]);
if (f.kind !== 'all') out.push(['kind', f.kind]);
// `recursive` defaults to true, so only the override is worth carrying.
if (!f.recursive) out.push(['recursive', '0']);
return out;
}
/** The entries as a query string (no leading `?`), for hand-built targets. */
export function filterParamsToString(f: ResourceFilterState, includeQuery = false): string {
return filterParamEntries(f, includeQuery)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
}
/**
* Overwrite the filter params of `params` **in place** — the shape the pages
* need, since they write into a copy of `page.url` so the surface's own
* params (`?file=`, `in`, `scope`) survive untouched.
*/
export function applyFilterParams(
params: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): void {
for (const key of FILTER_PARAM_KEYS) {
// `?q=` is the search box's when the state does not own it — deleting a
// param we do not write would wipe the query the page is showing.
if (key === 'q' && !includeQuery) continue;
params.delete(key);
}
for (const [k, v] of filterParamEntries(f, includeQuery)) params.set(k, v);
}
/**
* The `url.search` string `base` carries once the filter params of `f` are
* overwritten onto it — everything else (`?file=`, `in`, `scope`) survives.
* An all-defaults result collapses to `''` so callers can compare the value
* directly against `page.url.search`. Composed here rather than at the call
* site so reactive modules can build the string without holding a mutable
* `URL` copy (which `svelte/prefer-svelte-reactivity` treats as state).
*/
export function filterSearchOver(
base: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): string {
const params = new URLSearchParams(base);
applyFilterParams(params, f, includeQuery);
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
+16 -1
View File
@@ -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'; import { relativeTimeAgo } from './time';
describe('relativeTimeAgo', () => { 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(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
+303 -224
View File
@@ -40,11 +40,9 @@
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch'; 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 { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter';
import { preferences } from '$lib/stores/preferences.svelte'; 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 ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte'; import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, { import ResourceList, {
@@ -62,6 +60,22 @@
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte'; import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte';
import { replaceSet } from '$lib/utils/sets'; import { replaceSet } from '$lib/utils/sets';
import { mapLimit } from '$lib/utils/mapLimit';
import {
clearFilterState,
filterParamsToString,
isFilterActive,
filterToSearchOptions
} from '$lib/utils/searchFilters';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
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 // Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug') // oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -180,16 +194,11 @@
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false); let uploading = $state(false);
interface ActionTarget { // Move/copy dialog state lives in the shared `useResourceActions`
id: string; // composable (`resActions.moveDialog.*`); this page only keeps the share
name: string; // dialog target, which the composable doesn't own.
kind: ItemType;
}
let moveOpen = $state(false);
let moveMode = $state<'move' | 'copy'>('move');
let shareOpen = $state(false); let shareOpen = $state(false);
let actionTarget = $state<ActionTarget | null>(null); let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite / shared state now lives inline on every `FileItem` / // Favorite / shared state now lives inline on every `FileItem` /
// `FolderItem` DTO (`is_favorite`, `is_shared` — see // `FolderItem` DTO (`is_favorite`, `is_shared` — see
@@ -200,16 +209,10 @@
// inside `orderedItems`. No more `SvelteSet` shadowing. // inside `orderedItems`. No more `SvelteSet` shadowing.
function openMove(kind: ItemType, id: string, name: string) { function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind }; resActions.openMove({ id, name, kind });
moveItems = null;
moveMode = 'move';
moveOpen = true;
} }
function openCopy(kind: ItemType, id: string, name: string) { function openCopy(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind }; resActions.openCopy({ id, name, kind });
moveItems = null;
moveMode = 'copy';
moveOpen = true;
} }
function openShare(kind: ItemType, id: string, name: string) { function openShare(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind }; actionTarget = { id, name, kind };
@@ -276,7 +279,11 @@
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
const target = last ?? home; const target = last ?? home;
if (target) { if (target) {
await goto(resolve(`/files/${target}`), { replaceState: true }); // `folderTarget` keeps any deep-linked filter params
// (`/files?type=image`) alive through the canonicalization.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(folderTarget(target), { replaceState: true });
return; return;
} }
} }
@@ -389,9 +396,15 @@
* Fetch and append the next page. Invoked by ResourceList's * Fetch and append the next page. Invoked by ResourceList's
* IntersectionObserver when the bottom sentinel enters the viewport. * IntersectionObserver when the bottom sentinel enters the viewport.
* The `loadingMore` guard collapses a double-fire (the observer can * 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() { async function loadMore() {
if (searchActive) {
await loadMoreSearch();
return;
}
if (loadingMore || pageCursor === undefined) return; if (loadingMore || pageCursor === undefined) return;
loadingMore = true; loadingMore = true;
try { try {
@@ -437,6 +450,91 @@
} }
} }
// ── 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.
//
// `filter` is the module-level shared store (`$lib/stores/filter.svelte`)
// — the same object the top bar's filter panel mutates, so both surfaces
// always agree. `useFilterUrlSync(true)` mirrors it to/from this page's
// URL (`?q=…&type=…&recursive=0`), which is what makes a filtered view
// refresh-proof and Back-button-restoreable. Local alias kept so the
// search-mode code below reads unchanged.
const filter = resourceFilters;
useFilterUrlSync(true);
const searchActive = $derived(isFilterActive(filter));
/** Folder-entry URL that carries the active filter params along (the
* filter used to live in component state and survived folder navigation;
* URL state needs the params forwarded explicitly). */
function folderTarget(id: string): string {
const base = resolve(`/files/${id}`);
const qs = filterParamsToString(filter, true);
return qs ? `${base}?${qs}` : base;
}
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) ──────────────────────────── // ── Live folder updates (message bus) ────────────────────────────
// Subscribe to `folder:{currentId}` and refresh when THIS session's // Subscribe to `folder:{currentId}` and refresh when THIS session's
// tabs, another tab of the same user, or another user with a share // tabs, another tab of the same user, or another user with a share
@@ -465,7 +563,11 @@
// event upload without feeling laggy. // event upload without feeling laggy.
setTimeout(() => { setTimeout(() => {
reloadScheduled = false; 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); }, 100);
} }
useFolderTopic(() => currentId, { useFolderTopic(() => currentId, {
@@ -526,8 +628,11 @@
function openFolder(folder: FolderItem) { function openFolder(folder: FolderItem) {
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves // Canonical single-id URL. Legacy `/files/A/B/C` still resolves
// (canonicalize-on-load rewrites it inside `load()`), but new // (canonicalize-on-load rewrites it inside `load()`), but new
// navigation lands directly on `/files/{id}`. // navigation lands directly on `/files/{id}`. Active filter params
goto(resolve(`/files/${folder.id}`)); // ride along (`folderTarget`) so filtering survives folder entry.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(folder.id));
} }
async function onNewFolder() { async function onNewFolder() {
@@ -670,24 +775,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. */ /** Split items into the readable ones and the unreadable (FIFO/socket/…) ones. */
async function partitionReadable<T>( async function partitionReadable<T>(
items: T[], items: T[],
@@ -1117,7 +1204,11 @@
// viewer-state changes, so a user-initiated close can't be re-opened here. // viewer-state changes, so a user-initiated close can't be re-opened here.
$effect(() => { $effect(() => {
const fileId = page.url.searchParams.get('file'); 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(() => { untrack(() => {
if (!fileId) { if (!fileId) {
if (viewerOpen) viewerOpen = false; if (viewerOpen) viewerOpen = false;
@@ -1167,140 +1258,35 @@
selected.clear(); selected.clear();
} }
/** // Shared batch actions (favorite / download / delete / move / copy),
* Download the whole selection as a single zip via POST /api/batch/download — // extracted so this page and the /search results page share one
* folders are included (the old per-item loop silently skipped them). A lone // implementation. `getItems` switches with the view mode: batch
* file still streams directly so it keeps its original name/extension. // operations act on search hits while the filter is active, on the
*/ // folder listing otherwise. (`orderedItems` rather than `rlItems` —
/** Name for a server-zipped multi-item archive (matches the legacy format). */ // hidden dotfiles can never be selected, and the raw array keeps the
function batchZipName(): string { // lone-file download name lookup working.)
const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-'); const resActions = useResourceActions({
return `oxicloud ${stamp}.zip`; getItems: () => (searchActive ? searchItems : orderedItems),
} getSelected: () => selected,
clearSelection,
async function batchDownload() { onChanged: () => (searchActive ? runSearch(true) : reload()),
const fileIds: string[] = []; afterDelete: () => void session.refresh()
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;
}
}
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; 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) { if (e.key === 'Escape' && selected.size) {
clearSelection(); clearSelection();
} else if (e.key === 'Escape' && searchActive) {
clearFilterState(filter);
} else if (e.key === 'Delete' && selected.size) { } else if (e.key === 'Delete' && selected.size) {
// Delete only — Backspace was dropped: it triggered accidental deletes. // Delete only — Backspace was dropped: it triggered accidental deletes.
e.preventDefault(); e.preventDefault();
void batchDelete(); void resActions.batchDelete();
} }
// Ctrl+A "select all" moved to the list-header checkbox owned by // Ctrl+A "select all" moved to the list-header checkbox owned by
// ResourceList — the row-level selection UX now lives entirely // ResourceList — the row-level selection UX now lives entirely
@@ -1309,33 +1295,6 @@
// gestures that reference the local `selected` mirror. // 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 ───────────────────────────────────────────────────────── // ── Drag-to-move ─────────────────────────────────────────────────────────
const DRAG_TYPE = 'application/x-oxi-item'; const DRAG_TYPE = 'application/x-oxi-item';
let dropFolderId = $state<string | null>(null); let dropFolderId = $state<string | null>(null);
@@ -1380,7 +1339,7 @@
*/ */
function onItemDragStart(e: DragEvent, kind: ItemType, id: string, name: string) { function onItemDragStart(e: DragEvent, kind: ItemType, id: string, name: string) {
const items: ActionTarget[] = 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)); e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
if (e.dataTransfer) { if (e.dataTransfer) {
// `copyMove` advertises both operations; the drop-target's // `copyMove` advertises both operations; the drop-target's
@@ -1575,7 +1534,7 @@
$effect(() => { $effect(() => {
if (viewerOpen) void fileViewer.load(); if (viewerOpen) void fileViewer.load();
if (wopiOpen) void wopiEditor.load(); if (wopiOpen) void wopiEditor.load();
if (moveOpen) void moveDialog.load(); if (resActions.moveDialog.open) void moveDialog.load();
if (shareOpen) void shareDialog.load(); if (shareOpen) void shareDialog.load();
}); });
// Editability of the current context-menu target file, resolved async. // Editability of the current context-menu target file, resolved async.
@@ -1798,6 +1757,10 @@
// user has active. First-appearance bucketing in // user has active. First-appearance bucketing in
// `buildResourceSections` keys off the item order in the input list. // `buildResourceSections` keys off the item order in the input list.
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => { 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); const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
if (newlyAdded.size === 0) return filtered; if (newlyAdded.size === 0) return filtered;
const hoisted: Array<FileItem | FolderItem> = []; const hoisted: Array<FileItem | FolderItem> = [];
@@ -1809,6 +1772,23 @@
return [...hoisted, ...rest]; 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 // Group-by state (bound to <ResourceList>). Kept as a `string` prop
// value; the current `sortField` mirrors from the picked group's // value; the current `sortField` mirrors from the picked group's
// `orderBy` so a group-by change also drives the sort. // `orderBy` so a group-by change also drives the sort.
@@ -1965,10 +1945,41 @@
// you just added"; carrying it across folders would surface // you just added"; carrying it across folders would surface
// stale ids that don't belong to the new listing. // stale ids that don't belong to the new listing.
newlyAdded.clear(); 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); 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
// the shared store's `$state` proxy — the field reads are what register
// the deps (and `useFilterUrlSync` above runs first in the same flush,
// so URL hydration lands before this fires).
$effect(() => {
void filter.query;
void filter.type;
void filter.size;
void filter.date;
void filter.created;
void filter.kind;
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 // 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). // this event so the hidden file picker opens (the input lives on this page).
$effect(() => { $effect(() => {
@@ -2001,6 +2012,16 @@
<ReadOnlyBanner driveName={currentDrive.name} /> <ReadOnlyBanner driveName={currentDrive.name} />
{/if} {/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. The
bar mutates the shared store proxy in place — no bind, the state
lives in `$lib/stores/filter.svelte`. -->
<div class="files-filter-row">
<SearchFilterBar value={filter} />
</div>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. <!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the Kept OUTSIDE ResourceList so the split-button dropdown in the
`actions` snippet can click() them without ResourceList's internal `actions` snippet can click() them without ResourceList's internal
@@ -2026,16 +2047,20 @@
<ResourceList <ResourceList
title={t('nav.files', 'Files')} title={t('nav.files', 'Files')}
items={rlItems} items={rlItems}
emptyText={hiddenCount > 0 emptyText={searchActive
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder') ? t('search.no_results', 'No results found for this search')
: t('files.empty_title', 'This folder is empty')} : hiddenCount > 0
emptyHint={hiddenCount > 0 ? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
? t( : t('files.empty_title', 'This folder is empty')}
'files.empty_hidden_hint', emptyHint={searchActive
"Files whose name starts with '.' are hidden. Toggle the setting to see them." ? t('search.prompt', 'Type a query in the search bar above.')
) : hiddenCount > 0
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')} ? t(
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined} '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} {loading}
error={error ?? undefined} error={error ?? undefined}
selectable selectable
@@ -2052,7 +2077,7 @@
groupBys={rlGroupBys} groupBys={rlGroupBys}
bind:groupBy bind:groupBy
bind:reversed bind:reversed
hasMore={pageCursor !== undefined} hasMore={searchActive ? searchCursor !== undefined : pageCursor !== undefined}
onloadmore={loadMore} onloadmore={loadMore}
onreload={(orderBy) => { onreload={(orderBy) => {
sortField = orderBy as SortField; sortField = orderBy as SortField;
@@ -2074,8 +2099,10 @@
<!-- Surfaces only when the folder isn't really empty — it's just <!-- Surfaces only when the folder isn't really empty — it's just
filtered because the user chose to hide dotfiles. Clicking filtered because the user chose to hide dotfiles. Clicking
flips the app-wide `preferences.hideDotfiles` back off, flips the app-wide `preferences.hideDotfiles` back off,
re-populating the list without a hunt through settings. --> re-populating the list without a hunt through settings.
{#if hiddenCount > 0} Suppressed in search mode: an empty result there is a real
"nothing matched", not a hidden-items artifact. -->
{#if !searchActive && hiddenCount > 0}
<button <button
class="btn btn-secondary" class="btn btn-secondary"
onclick={() => preferences.setHideDotfiles(false)} onclick={() => preferences.setHideDotfiles(false)}
@@ -2106,6 +2133,29 @@
onDrop={(target, e) => onCrumbDrop(e, target)} onDrop={(target, e) => onCrumbDrop(e, target)}
dragMime={DRAG_TYPE} 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}
{#snippet actions()} {#snippet actions()}
@@ -2155,6 +2205,21 @@
<Icon name="folder-plus" class="icon-mr" /> <Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span> <span>{t('actions.new_folder', 'New folder')}</span>
</button> </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}
{#snippet batchActions(_sel)} {#snippet batchActions(_sel)}
@@ -2162,7 +2227,7 @@
class="batch-btn" class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')} title={t('files.add_favorites', 'Add to favorites')}
data-testid="files-batch-favorite-btn" data-testid="files-batch-favorite-btn"
onclick={() => void batchFavorites()} onclick={() => void resActions.batchFavorites()}
> >
<Icon name="star" /> <Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span> <span>{t('files.add_favorites', 'Add to favorites')}</span>
@@ -2171,7 +2236,7 @@
class="batch-btn" class="batch-btn"
title={t('files.move', 'Move')} title={t('files.move', 'Move')}
data-testid="files-batch-move-btn" data-testid="files-batch-move-btn"
onclick={batchMove} onclick={resActions.batchMove}
> >
<Icon name="arrows-alt" /> <Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span> <span>{t('files.move', 'Move')}</span>
@@ -2180,7 +2245,7 @@
class="batch-btn" class="batch-btn"
title={t('files.copy', 'Copy')} title={t('files.copy', 'Copy')}
data-testid="files-batch-copy-btn" data-testid="files-batch-copy-btn"
onclick={batchCopy} onclick={resActions.batchCopy}
> >
<Icon name="copy" /> <Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span> <span>{t('files.copy', 'Copy')}</span>
@@ -2189,7 +2254,7 @@
class="batch-btn" class="batch-btn"
title={t('common.download', 'Download')} title={t('common.download', 'Download')}
data-testid="files-batch-download-btn" data-testid="files-batch-download-btn"
onclick={() => void batchDownload()} onclick={() => void resActions.batchDownload()}
> >
<Icon name="download" /> <Icon name="download" />
<span>{t('common.download', 'Download')}</span> <span>{t('common.download', 'Download')}</span>
@@ -2198,7 +2263,7 @@
class="batch-btn batch-btn-danger" class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')} title={t('common.delete', 'Delete')}
data-testid="files-batch-delete-btn" data-testid="files-batch-delete-btn"
onclick={batchDelete} onclick={() => void resActions.batchDelete()}
> >
<Icon name="trash" /> <Icon name="trash" />
<span>{t('common.delete', 'Delete')}</span> <span>{t('common.delete', 'Delete')}</span>
@@ -2210,14 +2275,11 @@
{#if moveDialog.component} {#if moveDialog.component}
{@const MoveDialog = moveDialog.component} {@const MoveDialog = moveDialog.component}
<MoveDialog <MoveDialog
bind:open={moveOpen} bind:open={resActions.moveDialog.open}
item={actionTarget} item={resActions.moveDialog.item}
items={moveItems} items={resActions.moveDialog.items}
mode={moveMode} mode={resActions.moveDialog.mode}
onmoved={() => { onmoved={resActions.handleMoved}
clearSelection();
void reload();
}}
/> />
{/if} {/if}
{#if shareDialog.component} {#if shareDialog.component}
@@ -2271,7 +2333,8 @@
const id = ctxTarget!.id; const id = ctxTarget!.id;
closeContext(); closeContext();
// Canonical single-id URL — see `openFolder` above. // Canonical single-id URL — see `openFolder` above.
goto(resolve(`/files/${id}`)); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(id));
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button }}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
> >
<button <button
@@ -2434,6 +2497,17 @@
z-index: 1000; 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 { .ctx-menu {
position: fixed; position: fixed;
z-index: 1001; z-index: 1001;
@@ -2471,4 +2545,9 @@
rendered near-invisible here). Mirrors the user-menu logout red. */ rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt); color: var(--color-danger-alt);
} }
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style> </style>
+123 -8
View File
@@ -27,14 +27,19 @@ vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog })); 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/csrf', () => ({ getCsrfHeaders: () => ({}) }));
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
vi.mock('$lib/api/endpoints/deltaUpload', () => ({ vi.mock('$lib/api/endpoints/deltaUpload', () => ({
instantUploadOwned: vi.fn(), instantUploadOwned: vi.fn(),
resolveOwnedHashes: vi.fn(), resolveOwnedHashes: vi.fn(),
tryDeltaUpload: 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', () => ({ vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: () => false, canEditWithWopi: () => false,
getEditorUrlWithFallback: vi.fn() getEditorUrlWithFallback: vi.fn()
@@ -77,7 +82,8 @@ vi.mock('$lib/api/endpoints/folders', () => ({
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders'; import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile, uploadFileWithProgress } from '$lib/api/endpoints/files'; import { deleteFile, uploadFileWithProgress } from '$lib/api/endpoints/files';
import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload'; 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 { files as filesStore } from '$lib/stores/files.svelte';
import FilesPage from './[...path]/+page.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'); 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 () => { it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 })); m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage); render(FilesPage);
@@ -233,14 +274,88 @@ it('batch-deletes the whole selection after confirmation', async () => {
it('batch-favorites the selection via the favorites batch endpoint', async () => { it('batch-favorites the selection via the favorites batch endpoint', async () => {
withListing(); withListing();
m(apiFetch).mockResolvedValue({ ok: true }); m(addFavoritesBatch).mockResolvedValue(undefined);
render(FilesPage); render(FilesPage);
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox')); await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn')); await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() => await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith( expect(addFavoritesBatch).toHaveBeenCalledWith([
'/api/favorites/batch', { item_id: 'sub1', item_type: 'folder' },
expect.objectContaining({ method: 'POST' }) { 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');
});
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
withListing();
pageState.url = new URL('http://localhost/files/home?type=image&kind=folder');
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
folderId: 'home',
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
resourceTypes: ['folder']
});
// The bar's own select reflects the hydrated state (expand the row first).
await fireEvent.click(screen.getByTestId('filter-advanced-toggle-btn'));
expect((screen.getByTestId('filter-type-select') as HTMLSelectElement).value).toBe('image');
});
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
withListing();
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
m(goto).mockClear();
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
// One replaceState write for the debounced keyword — and nothing else:
// the effect must recognize its own write and settle, not ping-pong.
expect(goto).toHaveBeenCalledTimes(1);
// The sync composable passes a path/search/hash string (a URL instance
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
const written = m(goto).mock.calls[0][0] as string;
expect(written).toContain('q=hello');
});
+30 -1
View File
@@ -287,7 +287,29 @@
</form> </form>
{:else if view === 'file'} {:else if view === 'file'}
<div class="share__center"> <div class="share__center">
<Icon name="file" class="share__big-icon" /> {#if meta && mediaKind(meta.mime_type) === 'video'}
<!-- Inline player: Range-aware endpoint streams the video, so the
timeline seeks without downloading the whole file first.
lazyVideo defers the load, seeks a few frames in for a poster
and retries once on error (same behaviour as the folder grid). -->
<video
class="share__media"
data-testid="public-share-file-video"
use:lazyVideo={shareFileUrl(token, meta.item_id)}
controls
playsinline
></video>
{:else if meta && mediaKind(meta.mime_type) === 'image'}
<img
class="share__media"
data-testid="public-share-file-image"
src={shareFileUrl(token, meta.item_id)}
alt={meta.item_name}
use:imageRetry
/>
{:else}
<Icon name="file" class="share__big-icon" />
{/if}
<h1>{meta?.item_name}</h1> <h1>{meta?.item_name}</h1>
<a <a
class="share__btn" class="share__btn"
@@ -486,6 +508,13 @@
text-align: center; text-align: center;
} }
.share__media {
max-width: 100%;
max-height: min(70vh, 40rem);
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border);
}
:global(.share__big-icon) { :global(.share__big-icon) {
font-size: 3rem; font-size: 3rem;
color: var(--color-text-muted); color: var(--color-text-muted);
+149 -183
View File
@@ -22,10 +22,21 @@
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types'; import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import { filterToSearchOptions } from '$lib/utils/searchFilters';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { replaceSet } from '$lib/utils/sets';
import {
useResourceActions,
type ActionTarget
} from '$lib/composables/useResourceActions.svelte';
import FilterChips from '$lib/components/FilterChips.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
const query = $derived(page.url.searchParams.get('q') ?? ''); const query = $derived(page.url.searchParams.get('q') ?? '');
@@ -117,120 +128,48 @@
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all' scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
); );
function setScope(next: 'all' | 'folder') { function setScope(next: 'all' | 'folder') {
// Build the query string by hand — Svelte's lint flags mutating a // Copy the current URL and flip only the scope params — `q=` and the
// stdlib `URLSearchParams`, and we don't need reactivity here. // filter presets already in it survive untouched (they used to be
// dropped here when the query string was rebuilt by hand).
// //
// Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved // Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved
// even when switching to "Everywhere" so "This folder" stays // even when switching to "Everywhere" so "This folder" stays
// clickable and remembers WHICH folder. The active-scope flip // clickable and remembers WHICH folder. The active-scope flip
// rides on `scope=all` instead. // rides on `scope=all` instead.
const parts: string[] = []; const url = new URL(page.url);
if (query) parts.push(`q=${encodeURIComponent(query)}`);
// Sticky `in=`: keep whatever's already in the URL, or seed it // Sticky `in=`: keep whatever's already in the URL, or seed it
// from filesStore when the user first pins "This folder" from a // from filesStore when the user first pins "This folder" from a
// fresh /search visit. // fresh /search visit.
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null); const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
if (stickyFolder) { if (stickyFolder) {
parts.push(`in=${encodeURIComponent(stickyFolder)}`); url.searchParams.set('in', stickyFolder);
} else {
url.searchParams.delete('in');
} }
if (next === 'all' && stickyFolder) { if (next === 'all' && stickyFolder) {
// Only meaningful when there's a folder to override — otherwise // Only meaningful when there's a folder to override — otherwise
// the URL is "everywhere by default" and the flag would be noise. // the URL is "everywhere by default" and the flag would be noise.
parts.push('scope=all'); url.searchParams.set('scope', 'all');
} else {
url.searchParams.delete('scope');
} }
const target = resolve(parts.length ? `/search?${parts.join('&')}` : '/search');
// `replaceState: true` keeps the browser back-button meaningful — // `replaceState: true` keeps the browser back-button meaningful —
// scope changes are UI state, not navigation. `keepFocus: true` // scope changes are UI state, not navigation. `keepFocus: true`
// keeps focus on whatever button the user just clicked. // keeps focus on whatever button the user just clicked.
void goto(target, { replaceState: true, keepFocus: true, noScroll: true }); // Same-origin URL object (see useFilterUrlSync); resolve() can't type it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
} }
// Filters // Filters live in the shared store (`$lib/stores/filter.svelte`) and are
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; // mirrored to/from this page's URL — the top bar's filter panel and the
type SizeKey = 'all' | 'small' | 'medium' | 'large'; // files page's filter bar mutate the same object, so a filter set in
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; // either place is live here, and every dimension is URL-persisted
let typeFilter = $state<TypeKey>('all'); // (`?type=…&size=…&created=…&kind=…&recursive=0`) for refresh/bookmark/
let sizeFilter = $state<SizeKey>('all'); // Back. The active presets render as dismissible chips in the header
let dateFilter = $state<DateKey>('all'); // (`<FilterChips>` in the actions snippet) — the old in-page selects
// duplicated the vocabularies and lost state on every refresh.
const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = { useFilterUrlSync(false);
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') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ 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';
sizeFilter = 'all';
dateFilter = 'all';
}
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ── // ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
// Ed's 2026-07-26 spec: 4 options total — // Ed's 2026-07-26 spec: 4 options total —
@@ -294,14 +233,14 @@
scope === 'folder' && filesStore.section !== 'trash' scope === 'folder' && filesStore.section !== 'trash'
? (effectiveFolder ?? undefined) ? (effectiveFolder ?? undefined)
: undefined; : undefined;
// Filter presets come from the shared store (hydrated from this
// page's URL by `useFilterUrlSync`), mapped onto the wire by the
// util shared with the files page's filter bar.
return { return {
recursive: true, ...filterToSearchOptions(resourceFilters),
sortBy: orderByForGroup() as SortBy, sortBy: orderByForGroup() as SortBy,
reverse: reversed, reverse: reversed,
folderId, folderId
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
}; };
} }
@@ -383,16 +322,46 @@
// all reuse the same lazy dialogs. // all reuse the same lazy dialogs.
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null); let viewerFile = $state<FileItem | null>(null);
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
let shareOpen = $state(false); let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null); let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte')); const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte')); const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte'));
const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.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<string>();
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(() => { $effect(() => {
if (viewerOpen) void fileViewer.load(); if (viewerOpen) void fileViewer.load();
if (moveOpen) void moveDialog.load(); if (resActions.moveDialog.open) void moveDialog.load();
if (shareOpen) void shareDialog.load(); if (shareOpen) void shareDialog.load();
}); });
@@ -446,8 +415,8 @@
} }
function openMoveDialog(item: FileItem | FolderItem) { function openMoveDialog(item: FileItem | FolderItem) {
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) }; const target: ActionTarget = { id: item.id, name: item.name, kind: kindOf(item) };
moveOpen = true; resActions.openMove(target);
} }
function downloadItem(item: FileItem | FolderItem) { function downloadItem(item: FileItem | FolderItem) {
@@ -554,13 +523,19 @@
]; ];
$effect(() => { $effect(() => {
// re-run when query, sort/direction, scope, or any filter changes // re-run when query, sort/direction, scope, or any filter changes.
// The filter store is hydrated from the URL by `useFilterUrlSync`
// (registered earlier, so it runs first in the same flush) — these
// field reads register the deps.
void groupBy; void groupBy;
void reversed; void reversed;
void scope; void scope;
void typeFilter; void resourceFilters.type;
void sizeFilter; void resourceFilters.size;
void dateFilter; void resourceFilters.date;
void resourceFilters.created;
void resourceFilters.kind;
void resourceFilters.recursive;
void run(query); void run(query);
}); });
@@ -615,7 +590,7 @@
<svelte:head><title>{t('search.title', 'Search')} · OxiCloud</title></svelte:head> <svelte:head><title>{t('search.title', 'Search')} · OxiCloud</title></svelte:head>
<svelte:window ondragover={onWindowDragOver} ondrop={onWindowDrop} /> <svelte:window ondragover={onWindowDragOver} ondrop={onWindowDrop} onkeydown={onKeydown} />
{#if !query} {#if !query}
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} /> <EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
@@ -629,6 +604,9 @@
emptyText={t('search.no_results', 'No results found for this search')} emptyText={t('search.no_results', 'No results found for this search')}
hasMore={!!cursor} hasMore={!!cursor}
onloadmore={loadMore} onloadmore={loadMore}
selectable
shiftRangeSelect
onselectionchange={(ids) => replaceSet(selected, ids)}
showPath showPath
showViewToggle showViewToggle
onopen={open} onopen={open}
@@ -691,47 +669,14 @@
{t('search.this_folder', 'This folder')} {t('search.this_folder', 'This folder')}
</button> </button>
</div> </div>
<select
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
data-testid="search-type-filter-select"
>
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
data-testid="search-size-filter-select"
>
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
data-testid="search-date-filter-select"
>
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select>
<!-- <!--
NOTE: sort dimension + asc/desc live in ResourceList's Filter editing lives in the top bar's panel (the unified
built-in DisplayModeControls now (fed by `groupBys` + entry); this page only SHOWS the active presets, as
`bind:groupBy` + `bind:reversed` below), matching dismissible chips. NOTE: sort dimension + asc/desc live in
/favorites / /recent / /trash. The old ResourceList's built-in DisplayModeControls (fed by
`<select bind:value={sortBy}>` was removed with the `groupBys` + `bind:groupBy` + `bind:reversed` below).
`SORTS` array.
--> -->
{#if hasFilters} <FilterChips value={resourceFilters} />
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
{/snippet} {/snippet}
{#snippet breadcrumb()} {#snippet breadcrumb()}
<!-- <!--
@@ -750,6 +695,56 @@
<FolderBreadcrumb folderId={scopeFolderId} /> <FolderBreadcrumb folderId={scopeFolderId} />
{/if} {/if}
{/snippet} {/snippet}
{#snippet batchActions(_sel)}
<!-- Same five batch buttons as the files page (shared
`useResourceActions` composable); testids are search-prefixed
so the two pages' tests stay unambiguous. -->
<button
class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')}
data-testid="search-batch-favorite-btn"
onclick={() => void resActions.batchFavorites()}
>
<Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span>
</button>
<button
class="batch-btn"
title={t('files.move', 'Move')}
data-testid="search-batch-move-btn"
onclick={resActions.batchMove}
>
<Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span>
</button>
<button
class="batch-btn"
title={t('files.copy', 'Copy')}
data-testid="search-batch-copy-btn"
onclick={resActions.batchCopy}
>
<Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span>
</button>
<button
class="batch-btn"
title={t('common.download', 'Download')}
data-testid="search-batch-download-btn"
onclick={() => void resActions.batchDownload()}
>
<Icon name="download" />
<span>{t('common.download', 'Download')}</span>
</button>
<button
class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')}
data-testid="search-batch-delete-btn"
onclick={() => void resActions.batchDelete()}
>
<Icon name="trash" />
<span>{t('common.delete', 'Delete')}</span>
</button>
{/snippet}
{#snippet itemActions(item)} {#snippet itemActions(item)}
<!-- <!--
Per-row "Open parent folder" quick-action — search results Per-row "Open parent folder" quick-action — search results
@@ -787,16 +782,11 @@
{#if moveDialog.component} {#if moveDialog.component}
{@const MoveDialog = moveDialog.component} {@const MoveDialog = moveDialog.component}
<MoveDialog <MoveDialog
bind:open={moveOpen} bind:open={resActions.moveDialog.open}
item={moveTarget} item={resActions.moveDialog.item}
onmoved={() => { items={resActions.moveDialog.items}
// A move can shift the row out of the current scope (`?in=<uuid>`) mode={resActions.moveDialog.mode}
// or into it, and the SQL name-match count may change. Reload onmoved={resActions.handleMoved}
// page 1 rather than trying to patch state in place — search
// state is already reactive on query/scope so a fresh `run()`
// is cheap and correct.
void run(query);
}}
/> />
{/if} {/if}
{#if shareDialog.component} {#if shareDialog.component}
@@ -805,20 +795,12 @@
{/if} {/if}
<style> <style>
/* Filter cluster lives inside ResourceList's action-bar snippet now, /* Scope segment lives inside ResourceList's action-bar snippet now,
but the actual DOM is scoped to THIS component's \3c style> block — but the actual DOM is scoped to THIS component's \3c style> block —
Svelte's scoped selectors still apply because these are declared Svelte's scoped selectors still apply because these are declared
with the elements they style below. with the elements they style below.
Every color/border here uses tokens; no raw values (Stylelint gate). */ Every color/border here uses tokens; no raw values (Stylelint gate). */
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.seg { .seg {
display: flex; display: flex;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -843,20 +825,4 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.clear-filters {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.clear-filters:hover {
background: var(--color-bg-hover);
}
</style> </style>
+133 -4
View File
@@ -1,24 +1,108 @@
import { it, expect, vi, beforeEach } from 'vitest'; import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/svelte'; import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
const { goto, pageState } = vi.hoisted(() => ({ const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
goto: vi.fn(), goto: vi.fn(),
pageState: { url: new URL('http://localhost/search?q=report') } pageState: { url: new URL('http://localhost/search?q=report') },
session: {
user: { id: 'me', username: 'admin', is_external: false },
isExternalUser: false,
loadHomeFolder: vi.fn(async () => 'home'),
refresh: vi.fn(async () => {})
},
ui: {
notify: vi.fn(),
startProgress: vi.fn(() => 1),
updateProgress: vi.fn(),
finishProgress: vi.fn()
},
confirmDialog: vi.fn(),
promptDialog: vi.fn()
})); }));
vi.mock('$app/navigation', () => ({ goto })); vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState })); vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() })); vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' })); vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
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/endpoints/files', () => ({
fileInlineUrl: () => '/in',
fileDownloadUrl: () => '/dl',
fileThumbnailUrl: () => '/thumb',
thumbSizeForView: () => 'preview' as const,
deleteFile: vi.fn(),
moveFile: vi.fn(),
renameFile: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({
deleteFolder: vi.fn(),
moveFolder: vi.fn(),
renameFolder: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
addFavoritesBatch: vi.fn(),
dateBucket: () => 'bucket',
sizeBucket: () => 'bucket'
}));
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { deleteFile } from '$lib/api/endpoints/files';
import { deleteFolder } from '$lib/api/endpoints/folders';
import { files as filesStore } from '$lib/stores/files.svelte';
import SearchPage from './+page.svelte'; import SearchPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>; const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const searchHit = () => ({
items: [
{
resource_type: 'file',
resource: {
id: 'f1',
name: 'a-report.txt',
mime_type: 'text/plain',
folder_id: 'p',
category: 'Document',
created_at: 0,
modified_at: 0,
size: 4,
created_by: 'me',
updated_by: 'me',
path: '/a-report.txt'
},
meta: { score: 50 }
},
{
resource_type: 'folder',
resource: {
id: 'd1',
name: 'reports',
parent_id: 'p',
category: 'Folder',
created_at: 0,
modified_at: 0,
is_root: false,
created_by: 'me',
updated_by: 'me',
path: '/reports'
},
meta: { score: 50 }
}
],
query_time_ms: 1,
total: 2
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
pageState.url = new URL('http://localhost/search?q=report'); pageState.url = new URL('http://localhost/search?q=report');
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 0, total: 0 }); m(searchResources).mockResolvedValue({ items: [], query_time_ms: 0, total: 0 });
// List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list';
}); });
it('runs a search from the q query parameter on mount', async () => { it('runs a search from the q query parameter on mount', async () => {
@@ -41,3 +125,48 @@ it('surfaces a search error', async () => {
await waitFor(() => expect(searchResources).toHaveBeenCalled()); await waitFor(() => expect(searchResources).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy()); await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
}); });
it('hydrates filter presets from the URL onto the wire options', async () => {
pageState.url = new URL(
'http://localhost/search?q=report&type=image&size=small&created=week&kind=file&recursive=0'
);
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
maxSize: 1024 * 1024,
createdAfter: expect.any(Number),
resourceTypes: ['file'],
recursive: false
});
// The live presets are visible — and dismissible — as chips.
expect(screen.getByTestId('filter-chips')).toBeTruthy();
expect(screen.getByTestId('filter-chip-type')).toBeTruthy();
expect(screen.getByTestId('filter-chip-kind')).toBeTruthy();
expect(screen.getByTestId('filter-chip-size')).toBeTruthy();
expect(screen.getByTestId('filter-chip-created')).toBeTruthy();
});
it('an unknown filter value in the URL degrades to the default', async () => {
pageState.url = new URL('http://localhost/search?q=report&type=pdf&kind=aliens');
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: undefined,
resourceTypes: undefined
});
// No chips for dimensions that fell back to their defaults.
expect(screen.queryByTestId('filter-chip-type')).toBeNull();
});
it('batch-deletes the selected search results after confirmation', async () => {
m(searchResources).mockResolvedValue(searchHit());
confirmDialog.mockResolvedValue(true);
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('search-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('d1'));
await waitFor(() => expect(session.refresh).toHaveBeenCalled());
});
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "تم إرسال الإشعار بنجاح", "shared_notificationSent": "تم إرسال الإشعار بنجاح",
"shared_notificationFailed": "فشل إرسال الإشعار" "shared_notificationFailed": "فشل إرسال الإشعار"
}, },
"filter": {
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
"keyword": "الكلمة المفتاحية",
"advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "الاسم", "name": "الاسم",
"type": "النوع", "type": "النوع",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Benachrichtigung erfolgreich gesendet", "shared_notificationSent": "Benachrichtigung erfolgreich gesendet",
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden" "shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
}, },
"filter": {
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
"keyword": "Suchbegriff",
"advanced": "Filter",
"recursive": "Unterordner einbeziehen",
"results_count": "{{n}} Ergebnisse",
"clear_keyword": "Suche löschen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Name", "name": "Name",
"type": "Typ", "type": "Typ",
+18
View File
@@ -451,11 +451,28 @@
"shared_notificationSent": "Notification sent successfully", "shared_notificationSent": "Notification sent successfully",
"shared_notificationFailed": "Failed to send notification" "shared_notificationFailed": "Failed to send notification"
}, },
"filter": {
"placeholder": "Search this folder and subfolders…",
"keyword": "Keyword",
"advanced": "Filters",
"recursive": "Include subfolders",
"results_count": "{{n}} results",
"clear_keyword": "Clear search",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Name", "name": "Name",
"type": "Type", "type": "Type",
"size": "Size", "size": "Size",
"modified": "Modified", "modified": "Modified",
"folder_stat": "{{folders}} folders · {{files}} files",
"no_files": "No files in this folder", "no_files": "No files in this folder",
"empty_hint": "Upload files or create folders to get started", "empty_hint": "Upload files or create folders to get started",
"drop_to_upload": "Drop files here to upload", "drop_to_upload": "Drop files here to upload",
@@ -1645,6 +1662,7 @@
"toggle_theme": "Toggle theme" "toggle_theme": "Toggle theme"
}, },
"common": { "common": {
"refresh": "Refresh",
"add": "Add", "add": "Add",
"cancel": "Cancel", "cancel": "Cancel",
"clear": "Clear", "clear": "Clear",
+16
View File
@@ -416,6 +416,22 @@
"mit_license": "Licencia MIT", "mit_license": "Licencia MIT",
"title": "Menú de usuario" "title": "Menú de usuario"
}, },
"filter": {
"placeholder": "Buscar en esta carpeta y subcarpetas…",
"keyword": "Palabra clave",
"advanced": "Filtros",
"recursive": "Incluir subcarpetas",
"results_count": "{{n}} resultados",
"clear_keyword": "Borrar búsqueda",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Nombre", "name": "Nombre",
"type": "Tipo", "type": "Tipo",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "آگاه‌سازی با موفقیت ارسال شد", "shared_notificationSent": "آگاه‌سازی با موفقیت ارسال شد",
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود" "shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
}, },
"filter": {
"placeholder": "جستجو در این پوشه و زیرپوشه‌ها…",
"keyword": "کلیدواژه",
"advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "نام", "name": "نام",
"type": "نوع", "type": "نوع",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Notification envoyée avec succès", "shared_notificationSent": "Notification envoyée avec succès",
"shared_notificationFailed": "Erreur lors de l'envoi de la notification" "shared_notificationFailed": "Erreur lors de l'envoi de la notification"
}, },
"filter": {
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
"keyword": "Mot-clé",
"advanced": "Filtres",
"recursive": "Inclure les sous-dossiers",
"results_count": "{{n}} résultats",
"clear_keyword": "Effacer la recherche",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Nom", "name": "Nom",
"type": "Type", "type": "Type",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "सूचना सफलतापूर्वक भेजी गई", "shared_notificationSent": "सूचना सफलतापूर्वक भेजी गई",
"shared_notificationFailed": "सूचना भेजने में विफल" "shared_notificationFailed": "सूचना भेजने में विफल"
}, },
"filter": {
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
"keyword": "कीवर्ड",
"advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "नाम", "name": "नाम",
"type": "प्रकार", "type": "प्रकार",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Notifica inviata con successo", "shared_notificationSent": "Notifica inviata con successo",
"shared_notificationFailed": "Impossibile inviare la notifica" "shared_notificationFailed": "Impossibile inviare la notifica"
}, },
"filter": {
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
"keyword": "Parola chiave",
"advanced": "Filtri",
"recursive": "Includi sottocartelle",
"results_count": "{{n}} risultati",
"clear_keyword": "Cancella ricerca",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Nome", "name": "Nome",
"type": "Tipo", "type": "Tipo",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "通知が正常に送信されました", "shared_notificationSent": "通知が正常に送信されました",
"shared_notificationFailed": "通知の送信に失敗しました" "shared_notificationFailed": "通知の送信に失敗しました"
}, },
"filter": {
"placeholder": "このフォルダとサブフォルダを検索…",
"keyword": "キーワード",
"advanced": "フィルター",
"recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "名前", "name": "名前",
"type": "種類", "type": "種類",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "알림이 성공적으로 전송되었습니다", "shared_notificationSent": "알림이 성공적으로 전송되었습니다",
"shared_notificationFailed": "알림 전송에 실패했습니다" "shared_notificationFailed": "알림 전송에 실패했습니다"
}, },
"filter": {
"placeholder": "이 폴더와 하위 폴더 검색…",
"keyword": "키워드",
"advanced": "필터",
"recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "이름", "name": "이름",
"type": "유형", "type": "유형",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Notificatie verzonden", "shared_notificationSent": "Notificatie verzonden",
"shared_notificationFailed": "Notificatie verzenden mislukt" "shared_notificationFailed": "Notificatie verzenden mislukt"
}, },
"filter": {
"placeholder": "Deze map en submappen doorzoeken…",
"keyword": "Trefwoord",
"advanced": "Filters",
"recursive": "Submappen opnemen",
"results_count": "{{n}} resultaten",
"clear_keyword": "Zoekopdracht wissen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Naam", "name": "Naam",
"type": "Type", "type": "Type",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Powiadomienie wysłane pomyślnie", "shared_notificationSent": "Powiadomienie wysłane pomyślnie",
"shared_notificationFailed": "Nie udało się wysłać powiadomienia" "shared_notificationFailed": "Nie udało się wysłać powiadomienia"
}, },
"filter": {
"placeholder": "Szukaj w tym folderze i podfolderach…",
"keyword": "Słowo kluczowe",
"advanced": "Filtry",
"recursive": "Uwzględnij podfoldery",
"results_count": "Wyniki: {{n}}",
"clear_keyword": "Wyczyść wyszukiwanie",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Nazwa", "name": "Nazwa",
"type": "Typ", "type": "Typ",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Notificação enviada com sucesso", "shared_notificationSent": "Notificação enviada com sucesso",
"shared_notificationFailed": "Falha ao enviar a notificação" "shared_notificationFailed": "Falha ao enviar a notificação"
}, },
"filter": {
"placeholder": "Pesquisar nesta pasta e subpastas…",
"keyword": "Palavra-chave",
"advanced": "Filtros",
"recursive": "Incluir subpastas",
"results_count": "{{n}} resultados",
"clear_keyword": "Limpar pesquisa",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Nome", "name": "Nome",
"type": "Tipo", "type": "Tipo",
+16
View File
@@ -416,6 +416,22 @@
"shared_notificationSent": "Уведомление успешно отправлено", "shared_notificationSent": "Уведомление успешно отправлено",
"shared_notificationFailed": "Не удалось отправить уведомление" "shared_notificationFailed": "Не удалось отправить уведомление"
}, },
"filter": {
"placeholder": "Поиск в этой папке и подпапках…",
"keyword": "Ключевое слово",
"advanced": "Фильтры",
"recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
},
"files": { "files": {
"name": "Имя", "name": "Имя",
"type": "Тип", "type": "Тип",
+18
View File
@@ -416,11 +416,28 @@
"shared_typeFile": "檔案", "shared_typeFile": "檔案",
"shared_typeFolder": "資料夾" "shared_typeFolder": "資料夾"
}, },
"filter": {
"placeholder": "搜尋此資料夾及子資料夾…",
"keyword": "關鍵字",
"advanced": "篩選",
"recursive": "包含子資料夾",
"results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋",
"modified_label": "修改時間",
"created_label": "建立時間",
"kind_label": "項目",
"kind": {
"all": "檔案與資料夾",
"file": "僅檔案",
"folder": "僅資料夾"
}
},
"files": { "files": {
"name": "名稱", "name": "名稱",
"type": "型別", "type": "型別",
"size": "大小", "size": "大小",
"modified": "修改日期", "modified": "修改日期",
"folder_stat": "{{folders}} 個資料夾 · {{files}} 個檔案",
"no_files": "此資料夾中沒有檔案", "no_files": "此資料夾中沒有檔案",
"empty_hint": "上傳檔案或建立資料夾以開始使用", "empty_hint": "上傳檔案或建立資料夾以開始使用",
"drop_to_upload": "將檔案拖放到此處上傳", "drop_to_upload": "將檔案拖放到此處上傳",
@@ -1598,6 +1615,7 @@
"videos": "影片" "videos": "影片"
}, },
"common": { "common": {
"refresh": "重新整理",
"add": "新增", "add": "新增",
"cancel": "取消", "cancel": "取消",
"clear": "清除", "clear": "清除",
+18
View File
@@ -416,11 +416,28 @@
"shared_typeFile": "文件", "shared_typeFile": "文件",
"shared_typeFolder": "文件夹" "shared_typeFolder": "文件夹"
}, },
"filter": {
"placeholder": "搜索此文件夹及子文件夹…",
"keyword": "关键词",
"advanced": "筛选",
"recursive": "包含子文件夹",
"results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索",
"modified_label": "修改时间",
"created_label": "创建时间",
"kind_label": "条目",
"kind": {
"all": "文件和文件夹",
"file": "仅文件",
"folder": "仅文件夹"
}
},
"files": { "files": {
"name": "名称", "name": "名称",
"type": "类型", "type": "类型",
"size": "大小", "size": "大小",
"modified": "修改日期", "modified": "修改日期",
"folder_stat": "{{folders}} 个文件夹 · {{files}} 个文件",
"no_files": "此文件夹中没有文件", "no_files": "此文件夹中没有文件",
"empty_hint": "上传文件或创建文件夹以开始使用", "empty_hint": "上传文件或创建文件夹以开始使用",
"drop_to_upload": "将文件拖放到此处上传", "drop_to_upload": "将文件拖放到此处上传",
@@ -1598,6 +1615,7 @@
"videos": "视频" "videos": "视频"
}, },
"common": { "common": {
"refresh": "刷新",
"add": "添加", "add": "添加",
"cancel": "取消", "cancel": "取消",
"clear": "清除", "clear": "清除",
+11
View File
@@ -16,6 +16,15 @@ pub struct ShareDto {
pub created_at: u64, pub created_at: u64,
pub created_by: String, pub created_by: String,
pub access_count: u64, pub access_count: u64,
/// File shares only: the shared file's MIME type, resolved at read time
/// so anonymous viewers can render an inline media preview (video player
/// / image) instead of a bare download button. Absent for folder shares
/// and whenever the file lookup fails (display-only enrichment).
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// File shares only: the shared file's size in bytes (see `mime_type`).
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -49,6 +58,8 @@ impl ShareDto {
created_at: share.created_at(), created_at: share.created_at(),
created_by: share.created_by().to_string(), created_by: share.created_by().to_string(),
access_count: share.access_count(), access_count: share.access_count(),
mime_type: None,
size: None,
} }
} }
} }
@@ -116,19 +116,35 @@ impl ShareBrowseService {
self.list_inner(folder_id, resolved.owner_id).await self.list_inner(folder_id, resolved.owner_id).await
} }
/// AuthZ gate for `/api/s/{token}/file/{file_id}`: the requested file
/// must either BE the shared item (single-file share — the public landing
/// page's inline media preview streams through here) or live inside the
/// shared folder's subtree (folder share). Anything else is NotFound —
/// the same shape as "file doesn't exist", so the endpoint can't be used
/// to enumerate file ids.
pub async fn assert_file_in_share( pub async fn assert_file_in_share(
&self, &self,
token: &str, token: &str,
file_id: &str, file_id: &str,
unlock_jwt: Option<&str>, unlock_jwt: Option<&str>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let resolved = self.resolve_folder_share(token, unlock_jwt).await?; let share = self
.share_service
.get_shared_link_with_unlock(token, unlock_jwt)
.await?;
if !self let in_scope = match share.item_type.as_str() {
.folder_repo // Single-file share: only the shared item itself may be streamed.
.is_file_in_subtree(file_id, &resolved.root_folder_id) "file" => file_id == share.item_id,
.await? // Folder share: the file must live in the shared subtree.
{ "folder" => {
self.folder_repo
.is_file_in_subtree(file_id, &share.item_id)
.await?
}
_ => false,
};
if !in_scope {
return Err(DomainError::not_found("File", file_id)); return Err(DomainError::not_found("File", file_id));
} }
Ok(()) Ok(())
+122 -1
View File
@@ -249,6 +249,18 @@ impl ShareService {
}; };
self.fetch_share_resolved(token, unlocked).await self.fetch_share_resolved(token, unlocked).await
} }
/// Public share landing payload: share metadata enriched with the shared
/// file's `mime_type` + `size` so anonymous viewers get an inline media
/// preview (video player / image) instead of a bare download button.
pub async fn get_shared_link_meta_with_unlock(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let dto = self.get_shared_link_with_unlock(token, unlock_jwt).await?;
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
}
} }
impl ShareUseCase for ShareService { impl ShareUseCase for ShareService {
@@ -538,7 +550,8 @@ impl ShareUseCase for ShareService {
} }
// Password verified (or not required) — return full share metadata // Password verified (or not required) — return full share metadata
Ok(ShareDto::from_entity(&share, &self.base_url)) let dto = ShareDto::from_entity(&share, &self.base_url);
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
} }
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> { async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
@@ -558,6 +571,31 @@ impl ShareUseCase for ShareService {
} }
} }
/// Fill `mime_type`/`size` on file-share DTOs so anonymous viewers get an
/// inline media preview (video player / image) on the public share page
/// instead of a bare download button.
///
/// Display-only enrichment that never fails the response: a failed file
/// lookup (transient DB error, race with a delete) leaves the fields `None`
/// and the download endpoint surfaces the real error — a read failure is
/// never proof the data is absent. Folder shares pass through untouched.
///
/// A free function (not a method) so the `integration_tests` mirror of the
/// service exercises the exact same logic instead of re-implementing it.
async fn enrich_share_dto_with_file_info<FR: FileReadPort>(
mut dto: ShareDto,
file_repository: &FR,
) -> ShareDto {
if dto.item_type != "file" {
return dto;
}
if let Ok(file) = file_repository.get_file(&dto.item_id).await {
dto.mime_type = Some(file.mime_type().to_string());
dto.size = Some(file.size());
}
dto
}
#[cfg(feature = "integration_tests")] #[cfg(feature = "integration_tests")]
#[allow(dead_code)] #[allow(dead_code)]
mod tests { mod tests {
@@ -642,6 +680,18 @@ mod tests {
})?; })?;
self.password_hasher.hash_password(password).await self.password_hasher.hash_password(password).await
} }
/// Mirror of `ShareService::get_shared_link_meta_with_unlock` —
/// fetch by token (the mirror has no unlock-JWT machinery) plus the
/// shared file-info enrichment.
async fn get_shared_link_meta_with_unlock(
&self,
token: &str,
_unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let dto = self.get_shared_link_by_token(token).await?;
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
}
} }
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH> impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
@@ -1261,4 +1311,75 @@ mod tests {
assert!(share_dto.has_password); assert!(share_dto.has_password);
assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/")); assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/"));
} }
/// The share-landing meta endpoint enriches file shares with the shared
/// file's mime type + size so anonymous viewers can render an inline
/// preview (video player / image) instead of a bare download button.
#[tokio::test]
async fn test_get_shared_link_meta_enriches_file_shares() {
let config = Arc::new(AppConfig::default());
let service = ShareServiceForTest::new(
config,
Arc::new(MockShareRepository::new()),
Arc::new(MockFileRepository),
Arc::new(MockFolderRepository),
Arc::new(MockPasswordHasher),
);
let share = service
.create_shared_link(
Uuid::new_v4(),
CreateShareDto {
item_id: "test_file_id".to_string(),
item_name: Some("movie.mp4".to_string()),
item_type: "file".to_string(),
password: None,
expires_at: None,
},
)
.await
.unwrap();
let meta = service
.get_shared_link_meta_with_unlock(&share.token, None)
.await
.unwrap();
assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
assert_eq!(meta.size, Some(123));
}
/// Folder shares must NOT gain a bogus mime type — the enrichment is a
/// file-share-only passthrough for them.
#[tokio::test]
async fn test_get_shared_link_meta_leaves_folder_shares_unenriched() {
let config = Arc::new(AppConfig::default());
let service = ShareServiceForTest::new(
config,
Arc::new(MockShareRepository::new()),
Arc::new(MockFileRepository),
Arc::new(MockFolderRepository),
Arc::new(MockPasswordHasher),
);
let share = service
.create_shared_link(
Uuid::new_v4(),
CreateShareDto {
item_id: "test_folder_id".to_string(),
item_name: Some("pictures".to_string()),
item_type: "folder".to_string(),
password: None,
expires_at: None,
},
)
.await
.unwrap();
let meta = service
.get_shared_link_meta_with_unlock(&share.token, None)
.await
.unwrap();
assert_eq!(meta.mime_type, None);
assert_eq!(meta.size, None);
}
} }
+296 -7
View File
@@ -58,7 +58,7 @@ use crate::application::ports::blob_lifecycle::BlobLifecycleHook;
use crate::application::ports::blob_reference_ports::{BlobReferenceRegistry, RefLevel}; use crate::application::ports::blob_reference_ports::{BlobReferenceRegistry, RefLevel};
use crate::application::ports::blob_storage_ports::BlobStorageBackend; use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::application::ports::dedup_ports::{ use crate::application::ports::dedup_ports::{
BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto, BlobMetadataDto, DedupPort, DedupResultDto, DedupStatsDto, DerivedBlobRef,
}; };
use crate::application::services::blob_lifecycle_service::BlobLifecycleService; use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
use crate::domain::errors::{DomainError, ErrorKind}; use crate::domain::errors::{DomainError, ErrorKind};
@@ -646,6 +646,49 @@ fn manifest_reap_sql(registry: &BlobReferenceRegistry) -> String {
) )
} }
// ── Attached-blob lookup cache ───────────────────────────────────────────────
/// Cache size cap for [`DedupService::attached_blob_cache`] — plain entry
/// count (no weigher): an entry is three short strings + two short strings,
/// tens of bytes; 50k entries ≈ a few MB, noise next to the manifest cache.
pub(crate) const ATTACHED_BLOB_CACHE_MAX_ENTRIES: u64 = 50_000;
/// Hard staleness bound for [`DedupService::attached_blob_cache`].
///
/// Deliberately [`moka::future::Cache::builder().time_to_live`] and NOT
/// `time_to_idle`: a hot negative entry under TTI never expires, and TTL must
/// be the last-resort bound for writes this process never saw (bare SQL, a
/// future second instance, the `copy_file_satellites` race window).
pub(crate) const ATTACHED_BLOB_CACHE_TTL_SECS: u64 = 60;
/// Cache key for [`DedupService::attached_blob_cache`] — the
/// `storage.file_attached_blobs` primary key. A struct, not a
/// `(String, String, String)` tuple: three same-typed fields read by position
/// would force every construction site (and the `invalidate_for_file` scan)
/// to guess semantics; self-documenting beats positional here.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
struct AttachedBlobKey {
file_id: String,
kind: String,
variant: String,
}
impl AttachedBlobKey {
fn new(file_id: &str, kind: &str, variant: &str) -> Self {
Self {
file_id: file_id.to_string(),
kind: kind.to_string(),
variant: variant.to_string(),
}
}
}
/// Loader-error sentinel for the `try_get_with` cache wrapper on
/// [`Self::find_attached_blob`]. The SQL lookup treats a DB fault the same as
/// "no row" only at the very last moment — the cache must never see it, or a
/// transient outage would freeze "no attached blob" into place for a full
/// TTL while rows exist (a read failure is never proof that data is absent).
struct AttachedLookupFault;
pub struct DedupService { pub struct DedupService {
/// Pluggable blob storage backend (local FS, S3, …). /// Pluggable blob storage backend (local FS, S3, …).
backend: Arc<dyn BlobStorageBackend>, backend: Arc<dyn BlobStorageBackend>,
@@ -664,6 +707,28 @@ pub struct DedupService {
/// seen immediately), weight-bounded (a manifest is ~72 B per chunk), /// seen immediately), weight-bounded (a manifest is ~72 B per chunk),
/// short TTL so GC'd manifests age out fast (benches/MANIFEST-CACHE.md). /// short TTL so GC'd manifests age out fast (benches/MANIFEST-CACHE.md).
manifest_cache: moka::future::Cache<String, Arc<ChunkManifest>>, manifest_cache: moka::future::Cache<String, Arc<ChunkManifest>>,
/// `file_id → attached blob` lookup cache (`storage.file_attached_blobs`
/// rows) for the thumbnail hot path — `ThumbnailService::
/// thumbnail_content_id` hits it on EVERY request (including 304
/// revalidations and RAM thumbnail hits, `thumbnail_service.rs` ~:744)
/// and `get_cached_thumbnail` tier 2b hits it again with the same key
/// (~:851); the Nextcloud preview endpoint rides the same lookup.
///
/// Positive AND negative (`Option<DerivedBlobRef>` — most files have no
/// attached preview row, so the negative side is where the win is). The
/// loader NEVER caches a DB error: `find_attached_blob_uncached` returns
/// `Err` and `try_get_with` drops it, so a transient outage cannot freeze
/// "no attached blob" into the cache for a full TTL (a read failure is
/// never proof that data is absent).
///
/// Writes invalidate through the same type: `store_attached_blob` /
/// `store_attached_blob_if_absent` on success, file deletions via the
/// `ThumbnailRefreshHook::on_file_deleted` piggyback. The TTL above
/// remains the bound for anything this process cannot see (bare SQL,
/// `copy_file_satellites` races); invalidate-vs-inflight-REFILL races are
/// narrowed by `try_get_with` but not eliminated, and the residual window
/// is ≤ one TTL.
attached_blob_cache: moka::future::Cache<AttachedBlobKey, Option<DerivedBlobRef>>,
/// Every table that holds blob references, so GC agrees with the /// Every table that holds blob references, so GC agrees with the
/// consistency jobs on what "referenced" means. Defaults to the two /// consistency jobs on what "referenced" means. Defaults to the two
/// built-in sources; DI replaces it once more tables exist. Never /// built-in sources; DI replaces it once more tables exist. Never
@@ -698,6 +763,7 @@ impl DedupService {
maintenance_pool, maintenance_pool,
blob_lifecycle: None, blob_lifecycle: None,
manifest_cache: Self::build_manifest_cache(), manifest_cache: Self::build_manifest_cache(),
attached_blob_cache: Self::build_attached_blob_cache(),
reference_registry: registry.clone(), reference_registry: registry.clone(),
manifest_reap_sql: manifest_reap_sql(&registry), manifest_reap_sql: manifest_reap_sql(&registry),
blob_reap_sql: blob_reap_sql(&registry), blob_reap_sql: blob_reap_sql(&registry),
@@ -729,6 +795,19 @@ impl DedupService {
.build() .build()
} }
/// See the `attached_blob_cache` field docs. Plain entry-count cap (no
/// weigher — an entry is a handful of short strings), TTL as the hard
/// staleness bound; same hard-coded-const treatment as the manifest
/// cache rather than config: an internal accelerator with strict
/// write-side invalidation, where a misconfiguration costs performance,
/// never correctness.
fn build_attached_blob_cache() -> moka::future::Cache<AttachedBlobKey, Option<DerivedBlobRef>> {
moka::future::Cache::builder()
.max_capacity(ATTACHED_BLOB_CACHE_MAX_ENTRIES)
.time_to_live(std::time::Duration::from_secs(ATTACHED_BLOB_CACHE_TTL_SECS))
.build()
}
/// Registers the blob-reference registry used by the manifest reap /// Registers the blob-reference registry used by the manifest reap
/// predicate. Without it `garbage_collect` skips manifest collection /// predicate. Without it `garbage_collect` skips manifest collection
/// entirely — see `docs/plan/derived-blobs.md`. /// entirely — see `docs/plan/derived-blobs.md`.
@@ -831,6 +910,14 @@ impl DedupService {
.await .await
.map_err(|e| DomainError::internal_error("Dedup", format!("record attached blob: {e}")))?; .map_err(|e| DomainError::internal_error("Dedup", format!("record attached blob: {e}")))?;
// The row is replaced — drop any cached (possibly negative) entry so
// the next lookup refills from the new truth. Only on the success
// path: if the execute had failed, the row is unchanged and the
// cache is still accurate, so invalidating would just cost a refill.
self.attached_blob_cache
.invalidate(&AttachedBlobKey::new(file_id, kind, variant))
.await;
// Two shapes to balance depending on whether the UPSERT was a // Two shapes to balance depending on whether the UPSERT was a
// real content replacement or a same-content re-store: // real content replacement or a same-content re-store:
// //
@@ -960,12 +1047,27 @@ impl DedupService {
// if the row gets updated in it, the sidecar delete // if the row gets updated in it, the sidecar delete
// path fails its verify and keeps the sidecar — the // path fails its verify and keeps the sidecar — the
// conservative fallback. // conservative fallback.
//
// This readback now flows through the `attached_blob_cache`.
// Safe in-process: any write this process made already
// invalidated the key. The only degraded case is a negative
// entry cached before some OTHER process inserted the row —
// nonexistent in a single-instance deployment, and even then
// the consequence is `existing_hash: ""` → the import keeps
// its sidecar, the documented conservative fallback.
let existing = self.find_attached_blob(file_id, kind, variant).await; let existing = self.find_attached_blob(file_id, kind, variant).await;
return Ok(AttachedBlobInsertOutcome::AlreadyPresent { return Ok(AttachedBlobInsertOutcome::AlreadyPresent {
existing_hash: existing.map(|r| r.blob_hash).unwrap_or_default(), existing_hash: existing.map(|r| r.blob_hash).unwrap_or_default(),
}); });
} }
// We wrote a row for a key the cache may hold a negative entry for
// (the common "import backfill" case) — drop it so the new row is
// immediately visible to the thumbnail path.
self.attached_blob_cache
.invalidate(&AttachedBlobKey::new(file_id, kind, variant))
.await;
Ok(AttachedBlobInsertOutcome::Inserted { Ok(AttachedBlobInsertOutcome::Inserted {
hash: attached_hash, hash: attached_hash,
}) })
@@ -973,12 +1075,51 @@ impl DedupService {
/// Look up bytes attached to a file. File-keyed counterpart of /// Look up bytes attached to a file. File-keyed counterpart of
/// [`Self::find_derived_blob`]. /// [`Self::find_derived_blob`].
///
/// Cached read-through of [`Self::attached_blob_cache`] (positive AND
/// negative); see the field docs for why. The public signature is
/// unchanged — including the historical "DB fault reads as no row"
/// behaviour — but the fault now dies BEFORE the cache instead of being
/// indistinguishable from an absent row.
pub async fn find_attached_blob( pub async fn find_attached_blob(
&self, &self,
file_id: &str, file_id: &str,
kind: &str, kind: &str,
variant: &str, variant: &str,
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> { ) -> Option<DerivedBlobRef> {
match self
.attached_blob_cache
.try_get_with(AttachedBlobKey::new(file_id, kind, variant), async {
self.find_attached_blob_uncached(file_id, kind, variant)
.await
.map_err(|_| AttachedLookupFault) // Err ⇒ never cached
})
.await
{
Ok(attached) => attached,
Err(_) => {
tracing::debug!(
target: "oxicloud::dedup",
"attached-blob lookup failed (not cached): file={} kind={} variant={}",
file_id,
kind,
variant
);
None
}
}
}
/// The uncached lookup — one indexed point query on the
/// `file_attached_blobs` primary key. Unlike the historical inlined
/// body, a DB fault surfaces as `Err` so the cache wrapper can refuse to
/// store it; only a genuine `Ok(None)` means "no row".
async fn find_attached_blob_uncached(
&self,
file_id: &str,
kind: &str,
variant: &str,
) -> sqlx::Result<Option<DerivedBlobRef>> {
sqlx::query_as::<_, (String, String)>( sqlx::query_as::<_, (String, String)>(
"SELECT blob_hash, content_type FROM storage.file_attached_blobs "SELECT blob_hash, content_type FROM storage.file_attached_blobs
WHERE file_id = $1::uuid AND kind = $2 AND variant = $3", WHERE file_id = $1::uuid AND kind = $2 AND variant = $3",
@@ -988,16 +1129,31 @@ impl DedupService {
.bind(variant) .bind(variant)
.fetch_optional(self.pool.as_ref()) .fetch_optional(self.pool.as_ref())
.await .await
.ok() .map(|row| {
.flatten() row.map(|(blob_hash, content_type)| DerivedBlobRef {
.map(|(blob_hash, content_type)| {
crate::application::ports::dedup_ports::DerivedBlobRef {
blob_hash, blob_hash,
content_type, content_type,
} })
}) })
} }
/// Invalidate every `(kind, variant)` entry cached for one file.
///
/// Fired from `ThumbnailRefreshHook::on_file_deleted` so all three
/// production delete paths (single file, folder cascade, trash clear)
/// drop their cached rows after the DELETE commits. A linear scan over
/// the keys is fine here: deletions are rare and the cache is capped at
/// [`ATTACHED_BLOB_CACHE_MAX_ENTRIES`].
pub async fn invalidate_attached_blobs_for_file(&self, file_id: &str) {
// moka's `Iter` yields `(Arc<K>, V)` synchronously — the await lives
// in `invalidate`, not in the scan itself.
for (key, _) in self.attached_blob_cache.iter() {
if key.file_id == file_id {
self.attached_blob_cache.invalidate(&*key).await;
}
}
}
pub async fn store_derived_blob( pub async fn store_derived_blob(
&self, &self,
source_hash: &str, source_hash: &str,
@@ -1307,6 +1463,7 @@ impl DedupService {
maintenance_pool: stub_pool.clone(), maintenance_pool: stub_pool.clone(),
blob_lifecycle: None, blob_lifecycle: None,
manifest_cache: Self::build_manifest_cache(), manifest_cache: Self::build_manifest_cache(),
attached_blob_cache: Self::build_attached_blob_cache(),
reference_registry: stub_registry.clone(), reference_registry: stub_registry.clone(),
manifest_reap_sql: manifest_reap_sql(&stub_registry), manifest_reap_sql: manifest_reap_sql(&stub_registry),
blob_reap_sql: blob_reap_sql(&stub_registry), blob_reap_sql: blob_reap_sql(&stub_registry),
@@ -4184,6 +4341,138 @@ impl crate::infrastructure::scheduler::JobHandler for DedupService {
mod tests { mod tests {
use super::*; use super::*;
// ── attached_blob_cache — find_attached_blob read-through ───────────────
//
// Pure in-memory contract tests: `new_stub()` connects lazily to an
// unreachable pool, so anything that reaches the "DB" fails loudly. That
// is exactly what makes these work — a served `Some` proves the cache was
// consulted, and a missing entry after a fault proves the fault was not
// cached. Same no-SQL style as the hash_cache tests in
// `file_blob_read_repository.rs`.
fn attached_key(file_id: &str, kind: &str, variant: &str) -> AttachedBlobKey {
AttachedBlobKey::new(file_id, kind, variant)
}
fn sample_ref(hash: &str) -> DerivedBlobRef {
DerivedBlobRef {
blob_hash: hash.to_string(),
content_type: "image/jpeg".to_string(),
}
}
/// A seeded entry is served without touching the (unreachable) stub pool
/// — returning `Some` at all proves the read-through hit the cache.
#[tokio::test]
async fn attached_lookup_serves_a_seeded_entry() {
let svc = DedupService::new_stub();
let k = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000001", "preview", "icon");
svc.attached_blob_cache
.insert(k.clone(), Some(sample_ref("abc")))
.await;
assert_eq!(
svc.find_attached_blob(&k.file_id, "preview", "icon").await,
Some(sample_ref("abc"))
);
}
/// Negative entries are where most of the win is (most files have no
/// attached preview). A cached `None` must be served as `None` AND
/// survive the call — not be evicted by the miss path.
#[tokio::test]
async fn attached_lookup_serves_and_keeps_a_negative_entry() {
let svc = DedupService::new_stub();
let k = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000002", "preview", "icon");
svc.attached_blob_cache.insert(k.clone(), None).await;
assert_eq!(
svc.find_attached_blob(&k.file_id, "preview", "icon").await,
None
);
assert!(
svc.attached_blob_cache.get(&k).await.is_some(),
"negative entry was dropped by the lookup"
);
}
/// THE contract this change exists for: a DB fault must not be cached.
/// The stub pool cannot connect, so the uncached lookup errors; the
/// wrapper returns `None` (historical behaviour) and leaves the cache
/// empty — a row that appears after a transient outage must be visible
/// on the very next call, not hidden behind a frozen negative entry.
#[tokio::test]
async fn attached_lookup_does_not_cache_a_db_fault() {
let svc = DedupService::new_stub();
let k = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000003", "preview", "icon");
assert_eq!(
svc.find_attached_blob(&k.file_id, "preview", "icon").await,
None
);
assert!(
svc.attached_blob_cache.get(&k).await.is_none(),
"DB fault was cached as a negative entry"
);
}
/// Per-file invalidation drops every `(kind, variant)` of that file and
/// leaves other files' entries alone.
#[tokio::test]
async fn invalidate_attached_blobs_for_file_is_scoped_to_the_file() {
let svc = DedupService::new_stub();
let k1 = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000004", "preview", "icon");
let k2 = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000004", "preview", "large");
let k3 = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000005", "preview", "icon");
for (k, v) in [
(k1.clone(), Some(sample_ref("a"))),
(k2.clone(), None),
(k3.clone(), Some(sample_ref("c"))),
] {
svc.attached_blob_cache.insert(k, v).await;
}
svc.invalidate_attached_blobs_for_file(&k1.file_id).await;
assert!(svc.attached_blob_cache.get(&k1).await.is_none());
assert!(svc.attached_blob_cache.get(&k2).await.is_none());
assert!(
svc.attached_blob_cache.get(&k3).await.is_some(),
"another file's entry must survive"
);
}
/// Invalidation happens only after a SUCCESSFUL write: the store path
/// fails (unreachable pool) before any row is touched, so the previously
/// cached entry must still be there. Invalidating on failure would be
/// harmless but pointless — the row is unchanged and the cache accurate.
#[tokio::test]
async fn failed_attached_store_leaves_the_cache_alone() {
let svc = DedupService::new_stub();
let k = attached_key("0189d1b3-8f2a-7cde-b1ad-000000000006", "preview", "icon");
svc.attached_blob_cache
.insert(k.clone(), Some(sample_ref("xyz")))
.await;
let result = svc
.store_attached_blob(
&k.file_id,
"preview",
"icon",
"image/png",
Bytes::from_static(b"nope"),
uuid::Uuid::nil(),
)
.await;
assert!(
result.is_err(),
"stub pool is unreachable — store must fail"
);
assert_eq!(
svc.attached_blob_cache.get(&k).await,
Some(Some(sample_ref("xyz"))),
"failed store must not disturb the cache"
);
}
/// Golden test for the statement `garbage_collect` runs against production /// Golden test for the statement `garbage_collect` runs against production
/// data. It is assembled from the registered reference sources rather than /// data. It is assembled from the registered reference sources rather than
/// written as a literal, so this pins the whole thing byte-for-byte — the /// written as a literal, so this pins the whole thing byte-for-byte — the
@@ -102,7 +102,23 @@ async fn fsync_paths_parallel(paths: Vec<PathBuf>, strict: bool) -> Result<(), D
tasks.push(tokio::task::spawn_blocking( tasks.push(tokio::task::spawn_blocking(
move || -> Result<(), (PathBuf, std::io::Error)> { move || -> Result<(), (PathBuf, std::io::Error)> {
for path in &group { for path in &group {
let result = std::fs::File::open(path).and_then(|f| f.sync_all()); // `strict` marks blob *file* fsyncs; best-effort marks
// prefix *directory* fsyncs. That distinction also picks
// the open mode: Windows `FlushFileBuffers` needs a
// GENERIC_WRITE handle and fails with ACCESS_DENIED on
// the read-only handle `File::open` returns (POSIX fsync
// accepts read-only fds, which is why this only surfaced
// on Windows). Directories keep the read-only POSIX
// dirent-sync idiom — they can't be fsync'd on Windows
// at all, and their failures stay best-effort warnings.
let result = if strict {
std::fs::OpenOptions::new()
.write(true)
.open(path)
.and_then(|f| f.sync_all())
} else {
std::fs::File::open(path).and_then(|f| f.sync_all())
};
if let Err(e) = result { if let Err(e) = result {
if strict { if strict {
return Err((path.clone(), e)); return Err((path.clone(), e));
@@ -394,7 +410,11 @@ impl BlobStorageBackend for LocalBlobBackend {
format!("Failed to copy file to blob store: {}", ce), format!("Failed to copy file to blob store: {}", ce),
) )
})?; })?;
if let Ok(f) = fs::File::open(&blob_path).await { // Open for write: Windows `FlushFileBuffers` requires a
// GENERIC_WRITE handle — the read-only handle from
// `File::open` fails with ACCESS_DENIED, silently
// skipping this fsync on every Windows deployment.
if let Ok(f) = fs::OpenOptions::new().write(true).open(&blob_path).await {
let _ = f.sync_all().await; let _ = f.sync_all().await;
} }
let _ = fs::remove_file(&source_path).await; let _ = fs::remove_file(&source_path).await;
@@ -1874,10 +1874,16 @@ impl crate::application::ports::file_lifecycle::FileLifecycleHook for ThumbnailR
fn on_file_deleted(&self, file_id: &str) { fn on_file_deleted(&self, file_id: &str) {
let thumbnail = self.thumbnail.clone(); let thumbnail = self.thumbnail.clone();
let file_id = file_id.to_string(); let file_id = file_id.to_string();
// The row is gone (CASCADE cleared file_attached_blobs) — drop any
// cached attached-blob lookup for this file too. TTL would bound the
// staleness anyway, but deletes are rare and the cache lookup after a
// delete is pure waste.
let dedup = self.dedup.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = thumbnail.delete_thumbnails(&file_id).await { if let Err(e) = thumbnail.delete_thumbnails(&file_id).await {
tracing::warn!("Failed to delete thumbnails for file {}: {}", file_id, e); tracing::warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
} }
dedup.invalidate_attached_blobs_for_file(&file_id).await;
}); });
} }
} }
+2 -2
View File
@@ -242,7 +242,7 @@ pub async fn access_shared_item(
// every public share landing). // every public share landing).
let (_, item) = tokio::join!( let (_, item) = tokio::join!(
share_use_case.register_shared_link_access(&token), share_use_case.register_shared_link_access(&token),
share_use_case.get_shared_link_with_unlock(&token, unlock_jwt.as_deref()), share_use_case.get_shared_link_meta_with_unlock(&token, unlock_jwt.as_deref()),
); );
match item { match item {
@@ -639,7 +639,7 @@ pub async fn list_share_contents_subfolder(
path = "/api/s/{token}/file/{file_id}", path = "/api/s/{token}/file/{file_id}",
params( params(
("token" = String, Path, description = "Share token"), ("token" = String, Path, description = "Share token"),
("file_id" = String, Path, description = "File ID (must be inside the share)") ("file_id" = String, Path, description = "File ID (the shared item itself, or a file inside the shared folder's subtree)")
), ),
responses( responses(
(status = 200, description = "File content (or 206 for Range request)"), (status = 200, description = "File content (or 206 for Range request)"),
+4
View File
@@ -100,6 +100,10 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
if reuse_port { if reuse_port {
socket.set_reuse_port(true)?; socket.set_reuse_port(true)?;
} }
// SO_REUSEPORT is Unix-only; on Windows the flag is accepted but inert.
// Consume the parameter so `-D warnings` stays clean on Windows builds.
#[cfg(windows)]
let _ = reuse_port;
// Disable Nagle's algorithm — send small responses (JSON, PROPFIND) // Disable Nagle's algorithm — send small responses (JSON, PROPFIND)
// immediately instead of waiting up to 40ms for coalescing. // immediately instead of waiting up to 40ms for coalescing.
socket.set_tcp_nodelay(true)?; socket.set_tcp_nodelay(true)?;
+127
View File
@@ -0,0 +1,127 @@
# status.md — 计划与进度记录
> 本文件由 agent 自动维护,规则见 [AGENTS.md](AGENTS.md) 末尾的"本地 fork 维护规则"一节。
> 新条目加在"进行中"区域顶部;完成后移入"已完成"。
## 进行中
(暂无)
## 已完成
### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定)
- **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。
- **改动文件**:
- 新增(仅本地):
- `frontend/src/lib/stores/filter.svelte.ts` — 共享筛选 store(模块级 `$state`,唯一真值)
- `frontend/src/lib/composables/useFilterUrlSync.svelte.ts` — store↔URL 双向同步(单 `$effect` + `lastSeenSearch` 回声抑制,一轮收敛不成环)
- `frontend/src/lib/components/FilterFields.svelte` — 5 个 select(类型/大小/修改/创建/条目种类)+ 递归开关,词表与 i18n 标签集中于此(inline/panel 两种布局)
- `frontend/src/lib/components/TopBarFilterPanel.svelte` — 顶栏下拉面板壳(清除/完成按钮,激活计数驱动 badge)
- `frontend/src/lib/components/FilterChips.svelte` — `/search` 可撤销 chips(按维度撤销 + 清除全部)
- 仅本地改造:
- `frontend/src/lib/utils/searchFilters.ts` — 新维度 `kind`/`created` + URL 序列化(`filtersFromParams`/`filterParamEntries`/`filterParamsToString`/`applyFilterParams`/`filterSearchOver`);测试 29 条
- `frontend/src/lib/components/SearchFilterBar.svelte` — 展开区改复用 `FilterFields`;`value` 为普通 prop(原地改共享代理,**故意不用 `$bindable`**,导入绑定不可再赋值)
- 上游文件:
- `frontend/src/lib/components/AppShell.svelte` — 搜索框内筛选按钮(右 44px,激活 badge)+ 面板接线;`searchActive`→`mobileSearchOpen`(CSS 类名不动);提交/建议吃筛选(`filterToSearchOptions`);Escape 链插入面板;全局点击关面板(`.search-container` 内不关)
- `frontend/src/lib/styles/ported/topbar.css` — 输入框右内边距 50→104px(预留 submit/筛选/清除三个控件)单行改动
- `frontend/src/routes/search/+page.svelte` — URL 驱动(`useFilterUrlSync(false)`)、删三个页内 select 与本地词表、接 `FilterChips`、`setScope` 只动 `in`/`scope`
- `frontend/src/routes/files/[...path]/+page.svelte` — 删本地 filter 状态改绑共享 store、`useFilterUrlSync(true)`、`folderTarget` 携带筛选参数(就地筛选/导航存活两不误)、驱动 effect 增加 `created`/`kind` 依赖
- `frontend/static/locales/*.json`(16 个)— `filter` 块新增 `modified_label`/`created_label`/`kind_label`/`kind.{all,file,folder}`(zh/zh-TW 真翻译,其余英文兜底;fr.json 按行拼接保持既有格式)
- 测试:`AppShell.test.ts`(+2:面板开关/预设随提交/清除)、`search/page.test.ts`(+2:URL 水合+chips、非法值回落)、`files/page.test.ts`(+2:深链水合+wire 参数、关键词写回恰好一次)、`searchFilters.test.ts`(扩到 31 条)
- **仅本地文件**: 上述 5 个新增源文件 + `status.md`
- **上游冲突风险**: 高 — `AppShell.svelte`、`topbar.css`、`routes/search/+page.svelte`、16 个 locale 均为上游活跃区。合并核对要点:① store 是唯一真值、URL 是投影(`lastSeenSearch` 回声抑制,勿改回双向各自 `goto`);② `?q=` 归属——files 页 `includeQuery=true`、`/search` 与顶栏 `false`(`applyFilterParams` 删 `q` 前先看该开关);③ `folderTarget` 三处 `goto` 带 `eslint-disable`(helper 内嵌 `resolve()`,规则看不穿);④ `filterSearchOver` 下沉在普通 `.ts` 是为避开 `.svelte.ts` 的 `prefer-svelte-reactivity`(勿移回 composable);⑤ 已知 dev-only 警告 `ownership_invalid_mutation`(共享代理按普通 prop 传入+原地改的既定设计,生产无影响)
### [2026-09-19] 缩略图路径 DB 点查缓存(find_attached_blob 进程内缓存)
- **状态**: 已完成(commit `d33d1932`;cargo fmt --check ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;`cargo test --lib` 927 通过 0 失败,含新增 5 个缓存契约测试 ✓)
- **计划**: 给缩略图热路径(ETag 计算 `thumbnail_content_id` + tier 2b)每次请求都要打的 `find_attached_blob` DB 点查加进程内缓存(moka::future + try_get_with,正+负缓存,Err 不入缓存;写路径成功后失效;删除经 `ThumbnailRefreshHook::on_file_deleted` 搭车失效 + 60s TTL 兜底)。缓解"每次进照片墙 = 每张可见图 1-2 次 DB 点查"的负载。
- **改动文件**:
- `src/infrastructure/services/dedup_service.rs` — 模块顶 `ATTACHED_BLOB_CACHE_*` 常量 + `AttachedBlobKey` + `AttachedLookupFault`;`attached_blob_cache` 字段/`build_attached_blob_cache`;`find_attached_blob` 缓存包装(SQL 下移 `find_attached_blob_uncached` 返回 `sqlx::Result`,Err 永不入缓存);`store_attached_blob`/`store_attached_blob_if_absent` 成功路径失效(`Inserted` 臂失效 + AlreadyPresent 读回注释);`invalidate_attached_blobs_for_file`;5 个纯内存单测
- `src/infrastructure/services/thumbnail_service.rs` — `ThumbnailRefreshHook::on_file_deleted` 搭车失效(补 `self.dedup.clone()`)
- `docs/architecture/caching.md` — Layer 1 表新增 Attached blob 行 + "The attached-blob cache" 小节(键/正负缓存/两条诚实规则)
- `docs/architecture/derived-and-attached-blobs.md` — Lifecycle 节新增"Reads are cached"段落(缓存失效协议)
- **仅本地文件**: 无新增(`status.md` 本身)
- **上游冲突风险**: 低 — 两个上游文件均为局部追加,无重排
- **备注**: 本机此前无 Rust 工具链,本次顺带装好 rustup stable 1.98.1(minimal+clippy+rustfmt)、VS Build Tools 2022(MSVC 14.44 + SDK 10.0.26100)、Node 前端构建(`static-dist/` 已生成)——后续 agent 可直接跑 `cargo` 检查;注意 shell 无管理员权限,提权操作需 UAC 确认
### [2026-09-19] 文档更新:公开分享端点与落地页行为
- **状态**: 已完成
- **计划**: 按 AGENTS.md 的文档约定,把本会话的分享相关修复/功能同步进架构文档与用户指南
- **改动文件**:
- `docs/architecture/share-integration.md` — 公开路由表补全(download/contents/file/zip 共 6 条);新增 "/file/{file_id} 文件作用域"(file 分享仅限分享项本身、folder 分享限子树、其余 404 反枚举)与"落地页 meta 富化"(mime_type/size 仅展示、查询失败不失败响应)两小节
- `docs/guide/sharing.md` — 新增 "What recipients see" 用户向小节(单文件内联预览/视频流式拖动、文件夹浏览 + ZIP)
- **上游冲突风险**: 低 — 两文档均属低频改动区
### [2026-09-19] 文件页刷新按钮
- **状态**: 已完成(vitest 467 通过;svelte-check 0 错误;我改的文件 prettier/eslint 全绿)
- **计划**: actions 工具栏加手动刷新,点击重拉当前文件夹列表
- **改动文件**:
- `frontend/src/routes/files/[...path]/+page.svelte` — "New folder" 右侧新增刷新按钮:`load(true)` 重置分页拉第 1 页,`loading` 时禁用,图标 `repeat`(沿用 AdminJobsPanel 先例),testid `files-refresh-btn`
- `frontend/static/locales/{en,zh,zh-TW}.json` — `common.refresh`(刷新 / 重新整理)
- `frontend/src/routes/files/page.test.ts` — 点击刷新 → `fetchFolderPage` 第二次调用
- **上游冲突风险**: 中 — files 页与 locales 是上游活跃区,但改动面小
### [2026-09-19] 文件页文件夹内容统计
- **状态**: 已完成(验证同上)
- **计划**: 进入文件夹时在面包屑旁显示内容统计
- **改动文件**:
- `frontend/src/routes/files/[...path]/+page.svelte` — `folderStat` derived(对 `rlItems` 即点文件过滤后的展示列表计数;分页未完时数字尾随 "+");统计 span 渲染在 breadcrumb snippet 内(`.rl-breadcrumb` 本身是 flex);空文件夹不显示
- `frontend/static/locales/{en,zh,zh-TW}.json` — `files.folder_stat`(其余 13 语言回退英文)
- `frontend/src/routes/files/page.test.ts` — 精确计数 / 分页 "+" 两条测试
- **上游冲突风险**: 中 — 同上
### [2026-09-19] 开发环境修复 + 测试 locale 固定
- **状态**: 已完成
- **计划**: 本机装 Node 跑前端检查;修复暴露出的换行符与 locale 问题
- **改动文件**:
- `frontend/src/lib/utils/time.test.ts` — `vi.spyOn(Intl, 'RelativeTimeFormat')` 固定 `en` locale(time.ts 用运行时默认 locale,中文系统上 `/second/` 等英文断言必挂);注意 mock 需普通函数(time.ts 经 `new` 调用)
- 环境级(无仓库 diff): winget 装 Node 26.7.0;`core.autocrlf=false`(仓库级)+ `git rm --cached -r . && git reset --hard` 全量重写工作区为 LF——修复 276 个文件的 Prettier 假报错
- **仅本地文件**: 无新增
- **上游冲突风险**: 低 — LF 归一化后与上游(CI 全 LF)一致;建议后续给 `.gitattributes` 加 `* text=auto eol=lf`(未做,待定)
### [2026-09-19] 文件页模糊筛选搜索 + 批量操作共享化
- **状态**: 已完成(`npm run check` 0 错误 0 警告;`vitest run` 467 个测试全部通过)
- **计划**:
1. 文件页(`/files/[...path]`)新增搜索过滤栏:关键词(防抖)+ 类型/大小/时间预设 + 递归开关,
筛选生效时列表切换为当前文件夹的递归搜索(`GET /api/search` 的 `folder_id`+`recursive`,
空 `query` 后端视为匹配全部),支持结果多选/全选 + 批量收藏/移动/复制/下载/删除
2. 批量操作提取为共享 composable,`/search` 结果页同步接入选择 + 批量操作(项目去重规范)
3. 两处页内裸 `apiFetch`(favorites/batch、batch/download)改为 endpoint 封装
- **改动文件**:
- 新增(仅本地):
- `frontend/src/lib/components/SearchFilterBar.svelte` — 搜索过滤栏组件(关键词 + 高级筛选 + 递归开关)
- `frontend/src/lib/composables/useResourceActions.svelte.ts` — 共享批量操作(收藏/下载/删除/移动/复制 + MoveDialog 状态)
- `frontend/src/lib/utils/searchFilters.ts` — 类型/大小/时间预设模型 → `SearchOptions` 映射
- `frontend/src/lib/utils/mapLimit.ts` — 有界并发工具(自文件页提取)
- 以上各文件的 Vitest 测试(`*.test.ts`)
- 修改(上游文件):
- `frontend/src/routes/files/[...path]/+page.svelte` — 搜索模式状态/`runSearch`/模式切换/Escape 优先级/深链兼容;批量 handler 换用 composable
- `frontend/src/routes/search/+page.svelte` — 接入选择 + 批量操作;筛选逻辑改用共享 searchFilters;MoveDialog 绑定 composable
- `frontend/src/lib/api/endpoints/batch.ts` — 新增 `downloadBatch()`
- `frontend/src/lib/api/endpoints/favorites.ts` — 新增 `addFavoritesBatch()`
- `frontend/src/routes/files/page.test.ts`、`frontend/src/routes/search/page.test.ts` — mock 补齐 + 新增搜索模式/批量删除测试
- `frontend/static/locales/*.json`(16 个语言文件)— 新增 `filter.*` 6 个 key(zh/zh-TW 为真实翻译)
- **仅本地文件**: `status.md`、上述新增的 4 个源文件及其测试
- **上游冲突风险**: 高 — locales 与两个页面文件是上游活跃区;页面文件改动较大(files 页 ~490 行、search 页 ~170 行 diff),合并时需逐块核对本地意图
- **设计要点**(合并上游时用于核对行为):
- 批量操作参数化接口:`getItems/getSelected/clearSelection/onChanged/afterDelete` 回调注入;
文件页 `getItems` 按模式切换(`searchActive ? searchItems : orderedItems`),`onChanged` 按模式重跑搜索或重载目录
- 删除保持 per-item `mapLimit(ids, 6)` 行为(后端 `POST /api/batch/trash` 批量软删存在,切换记为可选后续)
- 搜索分页 limit 50;"全选"只覆盖已加载页(与 ResourceList 既有语义一致)
- 文件页搜索 wire 层 `type` 排序映射为 `name`(`SortBy` 无 type),`modified_at` 映射为 `updated_at`;始终传 sortBy 不传 relevance
- Escape 优先级:清除选中 → 清除筛选;过滤输入框内 Escape 自行处理并 stopPropagation
### [2026-09-15] 单文件分享无法流式预览修复
- **状态**: 已完成(commit `68e21f4b`;本机无 Rust 环境,fmt/clippy/api-test 未能在本地运行——**push 前需在有 Rust 的环境跑 `just check` + `just api-test`**)
- **计划**: 修复单文件分享落地页预览窗口无内容/按钮失效——根因是 `resolve_folder_share` 对 `item_type != "folder"` 一律拒绝,`/api/s/{token}/file/{id}` 对 file 分享返回 400
- **改动文件**:
- `src/application/services/share_browse_service.rs` — `assert_file_in_share` 改为按 `item_type` 分支:file 分享仅接受 `file_id == share.item_id`;folder 分享维持 ltree 子树校验;其余一律 404(反枚举,保持与"文件不存在"同形)
- `src/interfaces/api/handlers/share_handler.rs` — OpenAPI `file_id` 参数描述同步("the shared item itself, or a file inside the shared folder's subtree")
- `tests/api/public_shares.hurl` — 8b 节扩展:file 分享 token 流式取自身 item(200 + disposition)+ 局外人 id 404 断言
- **仅本地文件**: 无
- **上游冲突风险**: 中 — `share_browse_service.rs` 属上游安全敏感活跃区;改动集中在单个函数,冲突时按"file 分享限自身、folder 限子树、其余 404"核对意图
## 上游合并记录
(暂无。首次合并前先 `git remote add upstream <上游仓库地址>`。)
+18
View File
@@ -197,6 +197,24 @@ HTTP 200
jsonpath "$.item_type" == "file" jsonpath "$.item_type" == "file"
# The public landing page's inline media preview streams the shared file
# through /api/s/{token}/file/{file_id} — the requested file IS the shared
# item here, so the AuthZ gate must accept it (Range-aware 200, inline
# disposition so <video>/<img> can render it).
GET {{base_url}}/api/s/{{file_share_token}}/file/{{shared_file_id}}
HTTP 200
[Asserts]
header "Content-Disposition" contains "hello.txt"
# A file id that is NOT the shared item must 404 on a file-share token —
# same anti-enumeration shape as the folder-share probe above.
GET {{base_url}}/api/s/{{file_share_token}}/file/{{outsider_file_id}}
HTTP 404
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# 9 — Mint a password-protected share on the same folder. # 9 — Mint a password-protected share on the same folder.
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────