Compare commits
8 Commits
4d067b3fc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| deb6dc831b | |||
| 29d0c33558 | |||
| b7640e9be4 | |||
| d33d1932b6 | |||
| 68e21f4bef | |||
| d677f92b7f | |||
| 5f9f91ee2f | |||
| 6ee26e46e6 |
@@ -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 时应剔除。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}` : '';
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
|||||||
@@ -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": "النوع",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "نوع",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "प्रकार",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "種類",
|
||||||
|
|||||||
@@ -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": "유형",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Тип",
|
||||||
|
|||||||
@@ -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": "清除",
|
||||||
|
|||||||
@@ -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": "清除",
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(®istry),
|
manifest_reap_sql: manifest_reap_sql(®istry),
|
||||||
blob_reap_sql: blob_reap_sql(®istry),
|
blob_reap_sql: blob_reap_sql(®istry),
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"),
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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 <上游仓库地址>`。)
|
||||||
@@ -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.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user