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

This commit is contained in:
2026-09-20 00:28:52 +08:00
parent d33d1932b6
commit b7640e9be4
34 changed files with 2026 additions and 302 deletions
+63
View File
@@ -324,3 +324,66 @@ CI runs the same `npm run check` (plus Vitest) — commits that fail will not me
- Leave debug `console.log` statements in code
- Use raw color values in CSS — always use CSS custom properties
- Commit without passing all linters (`npm run check` for the frontend; `cargo fmt` + `cargo clippy` for the backend)
# 本地 fork 维护规则(Local fork rules)
> 本节是仅本地追加的内容,不属于上游 OxiCloud。与上游合并时,若本节之外的部分发生冲突,
> 以上游为准;本节始终保留在文件末尾以减少冲突面。
## 背景
本仓库是开源项目 OxiCloud 的本地副本,上游会持续更新。本地修改必须
**可追溯、可合并**:任何时候都要能知道"我们改了什么",以便与上游主分支合并。
## 规则 1:计划与进度必须记录在 `status.md`
- 每次接到非琐碎任务,**开始前**先在 `status.md` 顶部("进行中"区域)写下计划。
- `status.md` 条目格式(每条任务一个区块):
```markdown
### [YYYY-MM-DD] 任务标题
- **状态**: 进行中 / 已完成 / 已放弃(写明原因)
- **计划**: 要做什么、分几步
- **改动文件**: 列出修改/新增的上游文件(相对路径)+ 一句话说明
- **仅本地文件**: 新增的不属于上游的文件(合并时无需处理)
- **上游冲突风险**: 高 / 中 / 低,以及可能与上游哪些文件冲突
```
- 状态只允许进行中/已完成/已放弃三种;完成的任务移入"已完成"区域,保留记录不删除。
## 规则 2:每次修改后立即更新 `status.md`
- **不需要用户提醒**。任何一次代码/文档修改完成后,agent 必须同步更新
`status.md` 中对应条目的状态、改动文件列表和冲突风险。
- 即使任务中途被打断,也要把当前进度写清(做到哪一步、剩下什么),保证
任何 agent(或人)读了 `status.md` 就能接手。
## 规则 3:与上游主分支合并
- **小步提交**:一个任务一个 commit(或少量 commit),commit message 说清楚改了什么。
不要把多天的工作堆成一个巨型 commit,否则合并时无法选择性丢弃。
- **少改上游文件**:能用新增文件解决的(新组件、新模块、新 endpoint)就不要改上游现有文件;
必须改时尽量小而集中,并在 `status.md` 的"上游冲突风险"里注明。
**`AGENTS.md` 本身也因此只允许在文件末尾追加内容,不得改动上游已有的章节。**
- **不改无关格式**:不要顺手重排上游代码、改无关 import 顺序——纯噪音,制造冲突。
- 合并上游的流程:
```bash
git remote add upstream <上游仓库地址> # 只需配置一次
git fetch upstream
git merge upstream/main # 或 rebase,按团队习惯;首次建议 merge
# 解决冲突时:先读 status.md 的"改动文件"列表,逐个文件核对本地意图
git status # 确认没有遗漏的冲突标记
cargo fmt --all && cargo clippy --all-features --all-targets -- -D warnings
just test
```
- 合并完成后,在 `status.md` 新增一条"上游合并"记录:合并到的 upstream commit、
解决过的冲突文件、是否有本地修改被上游覆盖/废弃。
- 若上游已用别的方式实现了某个本地功能(导致本地补丁不再需要),在 `status.md`
把对应条目标为"已放弃(上游已实现)",并考虑回退本地补丁。
## 规则 4:其他
- `status.md` 属于仅本地文件,不向上游提 PR(除非团队明确决定);
`AGENTS.md` 中仅本节("本地 fork 维护规则")是本地内容,向上游提 PR 时应剔除。
+37 -2
View File
@@ -36,8 +36,43 @@ What used to live on the share row but is now resolved through ReBAC:
| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/s/{token}` | Access a shared item |
| `POST` | `/api/s/{token}/verify` | Verify a password-protected share |
| `GET` | `/api/s/{token}` | Share landing metadata (see [landing enrichment](#share-landing-metadata-enrichment)) |
| `POST` | `/api/s/{token}/verify` | Verify a password-protected share (sets the unlock-JWT cookie) |
| `GET` | `/api/s/{token}/download` | Download a **file share** (Range / 206 / 304 / 416 aware) |
| `GET` | `/api/s/{token}/contents` | List a **folder share's** root (folders + files) |
| `GET` | `/api/s/{token}/contents/{folder_id}` | List a subfolder inside the shared subtree |
| `GET` | `/api/s/{token}/file/{file_id}` | Stream one file — the landing page's inline preview and per-file download path (Range aware) |
| `GET` | `/api/s/{token}/zip` | ZIP archive of a **folder share's** root |
| `GET` | `/api/s/{token}/zip/{folder_id}` | ZIP archive of a subfolder inside the shared subtree |
#### File scoping on `/file/{file_id}`
The AuthZ gate (`ShareBrowseService::assert_file_in_share`) branches on the
share's `item_type` — a single-file share and a folder share scope the
endpoint differently:
- **File share** — only the shared item itself may be streamed
(`file_id == share.item_id`). This is what renders the public landing
page's inline media preview (video player / image) and it is also the
NextCloud-desktop-style per-file fetch path.
- **Folder share** — the file must live inside the shared subtree
(ltree `is_file_in_subtree` against the share's root folder).
- Anything else → **404**, the same shape as "file doesn't exist", so the
endpoint cannot be used to enumerate ids.
Password and expiry checks happen inside `get_shared_link_with_unlock`
before the scope decision; a password-protected share answers 401 with
`requiresPassword: true` until the unlock cookie is presented.
#### Share landing metadata enrichment
`GET /api/s/{token}` resolves the shared **file's** `mime_type` + `size`
at read time so anonymous viewers get an inline media preview (video
player / image) instead of a bare download button. The enrichment is
display-only and never fails the response: a failed file lookup (transient
DB error, race with a delete) leaves the fields absent and the download
endpoints surface the real error — a read failure is never proof that the
data is absent. Folder shares pass through unenriched.
## Service Responsibilities
+13
View File
@@ -47,6 +47,19 @@ If you need to let someone make changes, share with their **email**
instead. They'll receive an invitation, and from then on every change
they make is recorded under their name.
## What recipients see
Opening a **single-file** public link shows the file right on the
landing page — images display inline, and videos play in the browser
with a working seek bar (streamed, so no full download before
playback). A **Download** button always sits below the preview. For
files the browser can't display, recipients get the download button as
usual.
Opening a **folder** public link shows a browsable listing — folders
and files as cards, with a grid/list toggle and a **Download ZIP**
button. Images and videos open in a lightbox viewer.
## Expiration
When you share, you can set an **expiration date**. After that date,
+19
View File
@@ -33,3 +33,22 @@ export function copyFolders(folderIds: string[], targetFolderId: string | null):
target_folder_id: targetFolderId
});
}
/**
* Stream a multi-item selection as a server-built zip (`POST /api/batch/download`
* — folders included, unlike the legacy per-item loop). The caller names and
* saves the returned blob.
*/
export async function downloadBatch(fileIds: string[], folderIds: string[]): Promise<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}`);
}
/** 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}`);
}
@@ -0,0 +1,354 @@
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
defaultFilterState,
type DateKey,
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters';
interface Props {
/** Bindable full filter state (keyword + toggles + presets). */
value?: ResourceFilterState;
/** Advanced section (type/size/date/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 = $bindable(defaultFilterState()),
expanded = $bindable(false),
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
debounceMs = 300,
hideRecursive = false
}: Props = $props();
// Option label lists reuse the /search page's i18n keys — the vocabularies
// themselves (extensions, byte bounds) live in the shared searchFilters util.
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') }
];
// 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), which
// flow back into the buffer via the sync effect below.
let keyword = $state(value.query);
let lastPushed = value.query;
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(
(value.type !== 'all' ? 1 : 0) + (value.size !== 'all' ? 1 : 0) + (value.date !== 'all' ? 1 : 0)
);
</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 class="sfb__advanced" data-testid="filter-advanced-row">
<label class="sfb__field">
<span class="sfb__label">{t('search.type_label', 'Type')}</span>
<select bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.size_label', 'Size')}</span>
<select bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.date_label', 'Date')}</span>
<select bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="sfb__check">
<input type="checkbox" bind:checked={value.recursive} />
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</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;
}
.sfb__advanced {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.sfb__field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sfb__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.sfb__field select {
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;
}
.sfb__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
</style>
@@ -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();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { mapLimit } from './mapLimit';
describe('mapLimit', () => {
it('preserves result order regardless of completion order', async () => {
const result = await mapLimit([200, 50, 100, 10], 4, async (ms) => {
await new Promise((r) => setTimeout(r, ms));
return ms;
});
expect(result).toEqual([200, 50, 100, 10]);
});
it('never exceeds the concurrency cap', async () => {
let active = 0;
let peak = 0;
await mapLimit([1, 2, 3, 4, 5, 6, 7, 8], 3, async () => {
active++;
peak = Math.max(peak, active);
await new Promise((r) => setTimeout(r, 1));
active--;
return null;
});
expect(peak).toBe(3);
});
it('propagates rejections', async () => {
await expect(
mapLimit([1, 2, 3], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
it('handles empty input', async () => {
const fn = vi.fn(async (n: number) => n);
await expect(mapLimit([], 4, fn)).resolves.toEqual([]);
expect(fn).not.toHaveBeenCalled();
});
it('runs items sequentially when limit is 1', async () => {
const calls: number[] = [];
await mapLimit([1, 2, 3], 1, async (n) => {
calls.push(n);
return n;
});
expect(calls).toEqual([1, 2, 3]);
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Map `fn` over `items` with at most `limit` concurrent calls, preserving
* result order regardless of completion order.
*
* Extracted from the files page so batch fan-out paths (delete, drag-move,
* upload probing) and the shared resource-actions composable all use the
* same bounded-concurrency primitive.
*/
export async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.max(0, Math.min(limit, items.length)) }, worker));
return out;
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
clearFilterState,
dateBound,
defaultFilterState,
filterToSearchOptions,
isFilterActive,
sizeBounds,
TYPE_EXT
} from './searchFilters';
const MB = 1024 * 1024;
describe('TYPE_EXT', () => {
it('covers the five non-all type keys', () => {
expect(Object.keys(TYPE_EXT).sort()).toEqual(
['archive', 'audio', 'document', 'image', 'video'].sort()
);
});
it('uses bare lowercase extensions', () => {
for (const exts of Object.values(TYPE_EXT)) {
for (const ext of exts) expect(ext).toBe(ext.toLowerCase());
}
});
});
describe('sizeBounds', () => {
it('maps the presets to byte ranges', () => {
expect(sizeBounds('all')).toEqual({});
expect(sizeBounds('small')).toEqual({ maxSize: MB });
expect(sizeBounds('medium')).toEqual({ minSize: MB, maxSize: 100 * MB });
expect(sizeBounds('large')).toEqual({ minSize: 100 * MB });
});
});
describe('dateBound', () => {
it('maps day to 24h ago', () => {
expect(dateBound('day')).toBe(Math.floor(Date.now() / 1000) - 86400);
});
it('returns undefined for all', () => {
expect(dateBound('all')).toBeUndefined();
});
});
describe('isFilterActive', () => {
it('is false for the default state', () => {
expect(isFilterActive(defaultFilterState())).toBe(false);
});
it('is true for a non-empty keyword (even whitespace-only counts as empty)', () => {
expect(isFilterActive({ ...defaultFilterState(), query: 'x' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), query: ' ' })).toBe(false);
});
it('is true when any preset differs from all', () => {
expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true);
});
it('ignores the recursive toggle', () => {
expect(isFilterActive({ ...defaultFilterState(), recursive: false })).toBe(false);
});
});
describe('clearFilterState', () => {
it('resets every field in place', () => {
const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const };
clearFilterState(f);
expect(f).toEqual(defaultFilterState());
});
});
describe('filterToSearchOptions', () => {
it('omits everything for the default state', () => {
expect(filterToSearchOptions(defaultFilterState())).toEqual({
fileTypes: undefined,
minSize: undefined,
maxSize: undefined,
modifiedAfter: undefined,
recursive: true
});
});
it('maps each active dimension onto the wire options', () => {
const opts = filterToSearchOptions({
query: 'report',
recursive: false,
type: 'archive',
size: 'medium',
date: 'month'
});
expect(opts.fileTypes).toEqual(TYPE_EXT.archive);
expect(opts.minSize).toBe(MB);
expect(opts.maxSize).toBe(100 * MB);
expect(opts.modifiedAfter).toBe(dateBound('month'));
expect(opts.recursive).toBe(false);
});
});
+96
View File
@@ -0,0 +1,96 @@
// Shared resource-filter model for search-backed list views.
//
// Extracted from the /search page so the files page's filter bar and
// /search's filter selects share one source of truth for the preset
// vocabularies (type / size / date) and their mapping onto
// `SearchOptions`. Pure functions only — no runes here, so the module is
// unit-testable without component scaffolding.
import type { SearchOptions } from '$lib/api/endpoints/search';
export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
export type SizeKey = 'all' | 'small' | 'medium' | 'large';
export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
/** Full filter state for a search-backed resource list. */
export interface ResourceFilterState {
query: string;
/** Search subfolders too (backend default is true; exposed explicitly on /files). */
recursive: boolean;
type: TypeKey;
size: SizeKey;
date: DateKey;
}
export function defaultFilterState(): ResourceFilterState {
return { query: '', recursive: true, type: 'all', size: 'all', date: 'all' };
}
export const TYPE_EXT: Record<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() !== '' || f.type !== 'all' || f.size !== 'all' || f.date !== 'all';
}
/** Reset every dimension in place (runes-friendly — mutates the $state proxy). */
export function clearFilterState(f: ResourceFilterState): void {
f.query = '';
f.recursive = true;
f.type = 'all';
f.size = 'all';
f.date = 'all';
}
/**
* Map the filter state onto the search-wire options. Scope (folderId) and
* sorting stay the caller's concern — they differ per surface. The date
* preset maps to `modifiedAfter` (its labels read "Past N", which matches
* modified-time semantics); created-time bounds are a possible follow-up.
*/
export function filterToSearchOptions(
f: ResourceFilterState
): Pick<SearchOptions, 'fileTypes' | 'minSize' | 'maxSize' | 'modifiedAfter' | 'recursive'> {
return {
fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type],
...sizeBounds(f.size),
modifiedAfter: dateBound(f.date),
recursive: f.recursive
};
}
+16 -1
View File
@@ -1,7 +1,22 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
import { relativeTimeAgo } from './time';
describe('relativeTimeAgo', () => {
// The formatter resolves the runtime default locale (`undefined`), so on a
// non-English dev machine (e.g. zh-CN Windows) the output is localized and
// these English-unit regexes fail. Pin English for the tests; vitest's
// per-file isolation keeps the module-level formatter cache from leaking.
const RealRelativeTimeFormat = Intl.RelativeTimeFormat;
beforeAll(() => {
// A regular function, not an arrow: time.ts calls the mock via `new`.
vi.spyOn(Intl, 'RelativeTimeFormat').mockImplementation(function (
locales?: string | string[],
options?: Intl.RelativeTimeFormatOptions
) {
return new RealRelativeTimeFormat('en', options);
} as unknown as typeof Intl.RelativeTimeFormat);
});
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
+267 -220
View File
@@ -40,11 +40,9 @@
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter';
import { preferences } from '$lib/stores/preferences.svelte';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import type { FileItem, FolderItem, ItemType, SortBy } from '$lib/api/types';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import FolderBreadcrumb from '$lib/components/FolderBreadcrumb.svelte';
import ResourceList, {
@@ -62,6 +60,21 @@
import { ui } from '$lib/stores/ui.svelte';
import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte';
import { replaceSet } from '$lib/utils/sets';
import { mapLimit } from '$lib/utils/mapLimit';
import {
defaultFilterState,
clearFilterState,
isFilterActive,
filterToSearchOptions,
type ResourceFilterState
} from '$lib/utils/searchFilters';
import { searchResources } from '$lib/api/endpoints/search';
import {
useResourceActions,
batchZipName,
type ActionTarget
} from '$lib/composables/useResourceActions.svelte';
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
// Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -180,16 +193,11 @@
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
interface ActionTarget {
id: string;
name: string;
kind: ItemType;
}
let moveOpen = $state(false);
let moveMode = $state<'move' | 'copy'>('move');
// Move/copy dialog state lives in the shared `useResourceActions`
// composable (`resActions.moveDialog.*`); this page only keeps the share
// dialog target, which the composable doesn't own.
let shareOpen = $state(false);
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite / shared state now lives inline on every `FileItem` /
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
@@ -200,16 +208,10 @@
// inside `orderedItems`. No more `SvelteSet` shadowing.
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
moveItems = null;
moveMode = 'move';
moveOpen = true;
resActions.openMove({ id, name, kind });
}
function openCopy(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
moveItems = null;
moveMode = 'copy';
moveOpen = true;
resActions.openCopy({ id, name, kind });
}
function openShare(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
@@ -389,9 +391,15 @@
* Fetch and append the next page. Invoked by ResourceList's
* IntersectionObserver when the bottom sentinel enters the viewport.
* The `loadingMore` guard collapses a double-fire (the observer can
* tick twice on the same intersection edge).
* tick twice on the same intersection edge). Mode-aware: appends to
* the search results while the filter bar is active, to the folder
* page otherwise.
*/
async function loadMore() {
if (searchActive) {
await loadMoreSearch();
return;
}
if (loadingMore || pageCursor === undefined) return;
loadingMore = true;
try {
@@ -437,6 +445,74 @@
}
}
// ── Filter / search mode ─────────────────────────────────────────────────
// While any SearchFilterBar dimension is active the listing switches from
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
// with folder_id = currentId). The two data paths keep independent cursors
// and stale guards; entering/leaving the mode neutralizes the other path's
// in-flight response so a slow folder page can never clobber fresh search
// rows (and vice versa).
//
// The backend treats an absent/empty `query` as "match everything"
// (`SearchResourcesQuery.query` is `Option<String>`), so filter-only
// searches (type/size/date, no keyword) work; its Tantivy content index
// additionally requires ≥2 chars before it engages, so empty queries stay
// name/filter-driven.
let filter = $state<ResourceFilterState>(defaultFilterState());
const searchActive = $derived(isFilterActive(filter));
let searchItems = $state<Array<FileItem | FolderItem>>([]);
let searchCursor = $state<string | undefined>(undefined);
let searchSeq = 0;
let searchAbort: AbortController | null = null;
async function runSearch(reset: boolean = true) {
const folderId = currentId;
if (!searchActive || !folderId) return;
error = null;
const seq = ++searchSeq;
searchAbort?.abort();
const ctl = new AbortController();
searchAbort = ctl;
loading = true;
const activeAtStart = searchActive;
try {
// The search wire has no `type` order (that's a client-side group-by)
// and calls modified time `updated_at` — map both before sending.
// Relevance is meaningless with a (possibly empty) filter query, so
// the current sort field is always sent instead.
const sortBy: SortBy =
sortField === 'type' ? 'name' : sortField === 'modified_at' ? 'updated_at' : sortField;
const res = await searchResources(filter.query.trim(), {
folderId,
...filterToSearchOptions(filter),
sortBy,
reverse: reversed,
limit: 50,
cursor: reset ? undefined : searchCursor,
signal: ctl.signal
});
if (seq !== searchSeq || searchActive !== activeAtStart) return; // superseded
// Unwrap the search envelope: each hit's `resource` is already the
// shared FileItem | FolderItem shape ResourceList consumes.
const hits = res.items.map((it) => it.resource);
searchItems = reset ? hits : [...searchItems, ...hits];
searchCursor = res.next_cursor;
loading = false;
} catch (e) {
if (seq !== searchSeq || searchActive !== activeAtStart) return;
loading = false;
if ((e as Error)?.name !== 'AbortError') error = errorMessage(e);
} finally {
if (searchAbort === ctl) searchAbort = null;
}
}
/** Append the next search page — the search-mode twin of `loadMore()`. */
async function loadMoreSearch() {
if (searchCursor === undefined) return;
await runSearch(false);
}
// ── Live folder updates (message bus) ────────────────────────────
// Subscribe to `folder:{currentId}` and refresh when THIS session's
// tabs, another tab of the same user, or another user with a share
@@ -465,7 +541,11 @@
// event upload without feeling laggy.
setTimeout(() => {
reloadScheduled = false;
void reload();
// In search mode re-run the SEARCH, not the folder page: a
// recursive filter covers subfolders, and a mutation in any of
// them (or of a matched row itself) can invalidate the results.
if (searchActive) void runSearch(true);
else void reload();
}, 100);
}
useFolderTopic(() => currentId, {
@@ -670,24 +750,6 @@
}
}
/** Map `fn` over `items` with at most `limit` concurrent calls, preserving order. */
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
/** Split items into the readable ones and the unreadable (FIFO/socket/…) ones. */
async function partitionReadable<T>(
items: T[],
@@ -1117,7 +1179,11 @@
// viewer-state changes, so a user-initiated close can't be re-opened here.
$effect(() => {
const fileId = page.url.searchParams.get('file');
const files = listing.files;
// Deep links must also resolve while the filter bar is active — the
// hit may only exist in the search results, not the folder page.
const files = searchActive
? searchItems.filter((it): it is FileItem => isFile(it))
: listing.files;
untrack(() => {
if (!fileId) {
if (viewerOpen) viewerOpen = false;
@@ -1167,140 +1233,35 @@
selected.clear();
}
/**
* Download the whole selection as a single zip via POST /api/batch/download —
* folders are included (the old per-item loop silently skipped them). A lone
* file still streams directly so it keeps its original name/extension.
*/
/** Name for a server-zipped multi-item archive (matches the legacy format). */
function batchZipName(): string {
const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-');
return `oxicloud ${stamp}.zip`;
}
async function batchDownload() {
const fileIds: string[] = [];
const folderIds: string[] = [];
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
const fileIdSet = new Set(listing.files.map((f) => f.id));
for (const id of selected) {
if (folderIdSet.has(id)) folderIds.push(id);
else if (fileIdSet.has(id)) fileIds.push(id);
}
if (fileIds.length === 0 && folderIds.length === 0) return;
// Single file, no folders → direct download (preserves the real name).
if (fileIds.length === 1 && folderIds.length === 0) {
const file = listing.files.find((f) => f.id === fileIds[0]);
if (file) {
const a = document.createElement('a');
a.href = fileDownloadUrl(file.id);
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
}
return;
}
const zipName = batchZipName();
try {
const res = await apiFetch('/api/batch/download', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
errorToast(e);
}
}
/** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() {
// Build an id → item index so the "already favorite" filter is
// O(1) per selection member instead of an O(N·M) scan. Reused
// after success to flip `is_favorite` in place on each row.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, FileItem | FolderItem>();
for (const it of orderedItems) byId.set(it.id, it);
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection();
return;
}
try {
const res = await apiFetch('/api/favorites/batch', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({
items: items.map((it) => ({ item_id: it.id, item_type: it.kind }))
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) {
const row = byId.get(it.id);
if (row) row.is_favorite = true;
}
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
errorToast(e);
}
}
function selectionTargets(): ActionTarget[] {
// One O(M) index build instead of an O(N·M) `find` per selected id.
// Folders win id collisions, matching the old folder-first probe.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, ActionTarget>();
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
return [...selected]
.map((id) => byId.get(id) ?? null)
.filter((x): x is ActionTarget => x !== null);
}
function batchMove() {
const items = selectionTargets();
if (items.length) {
moveItems = items;
moveMode = 'move';
moveOpen = true;
}
}
function batchCopy() {
const items = selectionTargets();
if (items.length) {
moveItems = items;
moveMode = 'copy';
moveOpen = true;
}
}
// Shared batch actions (favorite / download / delete / move / copy),
// extracted so this page and the /search results page share one
// implementation. `getItems` switches with the view mode: batch
// operations act on search hits while the filter is active, on the
// folder listing otherwise. (`orderedItems` rather than `rlItems` —
// hidden dotfiles can never be selected, and the raw array keeps the
// lone-file download name lookup working.)
const resActions = useResourceActions({
getItems: () => (searchActive ? searchItems : orderedItems),
getSelected: () => selected,
clearSelection,
onChanged: () => (searchActive ? runSearch(true) : reload()),
afterDelete: () => void session.refresh()
});
function onKeydown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Escape priority: selection first, then an active filter. The filter
// input handles its own Escape (clears the keyword, stopPropagation)
// so reaching here means the focus is outside the search bar.
if (e.key === 'Escape' && selected.size) {
clearSelection();
} else if (e.key === 'Escape' && searchActive) {
clearFilterState(filter);
} else if (e.key === 'Delete' && selected.size) {
// Delete only — Backspace was dropped: it triggered accidental deletes.
e.preventDefault();
void batchDelete();
void resActions.batchDelete();
}
// Ctrl+A "select all" moved to the list-header checkbox owned by
// ResourceList — the row-level selection UX now lives entirely
@@ -1309,33 +1270,6 @@
// gestures that reference the local `selected` mirror.
}
async function batchDelete() {
const ids = [...selected];
const ok = await confirmDialog({
title: t('files.batch_delete', 'Delete selected'),
message: t('files.confirm_batch_delete', { n: ids.length }, 'Move {{n}} items to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
// Bounded fan-out instead of a serial await per item: 100 deletes at
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
// windows. Failures toast individually and the rest still proceed,
// exactly like the old serial loop.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
await mapLimit(ids, 6, async (id) => {
try {
if (folderIdSet.has(id)) await deleteFolder(id);
else await deleteFile(id);
} catch (e) {
errorToast(e);
}
});
clearSelection();
await reload();
void session.refresh();
}
// ── Drag-to-move ─────────────────────────────────────────────────────────
const DRAG_TYPE = 'application/x-oxi-item';
let dropFolderId = $state<string | null>(null);
@@ -1380,7 +1314,7 @@
*/
function onItemDragStart(e: DragEvent, kind: ItemType, id: string, name: string) {
const items: ActionTarget[] =
selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }];
selected.has(id) && selected.size > 1 ? resActions.selectionTargets() : [{ id, name, kind }];
e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items));
if (e.dataTransfer) {
// `copyMove` advertises both operations; the drop-target's
@@ -1575,7 +1509,7 @@
$effect(() => {
if (viewerOpen) void fileViewer.load();
if (wopiOpen) void wopiEditor.load();
if (moveOpen) void moveDialog.load();
if (resActions.moveDialog.open) void moveDialog.load();
if (shareOpen) void shareDialog.load();
});
// Editability of the current context-menu target file, resolved async.
@@ -1798,6 +1732,10 @@
// user has active. First-appearance bucketing in
// `buildResourceSections` keys off the item order in the input list.
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => {
// Search mode: the rows are the scoped search hits; the swimlane
// hoist never applies there (it's cleared on mode entry, see the
// filter effect) so the dotfile filter passes straight through.
if (searchActive) return filterDotfiles(searchItems, preferences.hideDotfiles);
const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
if (newlyAdded.size === 0) return filtered;
const hoisted: Array<FileItem | FolderItem> = [];
@@ -1809,6 +1747,23 @@
return [...hoisted, ...rest];
});
// ── Folder content stat ──────────────────────────────────────────────────
// Counts of what the listing actually renders (post-dotfile-filter, i.e.
// exactly the rows on screen), shown next to the breadcrumb. Listing is
// cursor-paginated, so while more pages exist (`pageCursor` defined) the
// numbers are partial — a "+" suffix says so instead of claiming exactness
// for a folder larger than one page.
const folderStat = $derived.by(() => {
const files = rlItems.filter(isFile).length;
// Search mode shows a flat result count instead of the folders/files
// split (the recursive result set isn't "this folder's content");
// partial/+ semantics carry over via the active mode's cursor.
if (searchActive) {
return { folders: 0, files: rlItems.length, partial: searchCursor !== undefined };
}
return { folders: rlItems.length - files, files, partial: pageCursor !== undefined };
});
// Group-by state (bound to <ResourceList>). Kept as a `string` prop
// value; the current `sortField` mirrors from the picked group's
// `orderBy` so a group-by change also drives the sort.
@@ -1965,10 +1920,37 @@
// you just added"; carrying it across folders would surface
// stale ids that don't belong to the new listing.
newlyAdded.clear();
// Always re-load the folder page: even in search mode `load()`
// resolves the canonical folder id + breadcrumbs that the
// scoped search below is anchored to.
void load(true);
});
});
// Scoped-search driver. Re-runs the search from page 1 whenever its
// inputs change: any filter dimension, the folder it's scoped to
// (`currentId`, resolved by `load()` above), or the sort dimension.
// Inactive (plain folder listing) is the no-op fast path. `filter` is
// a `$state` proxy — the field reads are what register the deps.
$effect(() => {
void filter.query;
void filter.type;
void filter.size;
void filter.date;
void filter.recursive;
void currentId;
void sortField;
void reversed;
const active = searchActive;
untrack(() => {
if (!active) return;
// Mode entry / re-run → drop the swimlane so its hoisting
// never fights the search ordering.
newlyAdded.clear();
void runSearch(true);
});
});
// The command palette's "Upload files" action navigates here then dispatches
// this event so the hidden file picker opens (the input lives on this page).
$effect(() => {
@@ -2001,6 +1983,14 @@
<ReadOnlyBanner driveName={currentDrive.name} />
{/if}
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
current folder (recursive toggle inside). While any dimension is
active the listing below switches from the folder page to the
search results; clearing it returns to the plain folder view. -->
<div class="files-filter-row">
<SearchFilterBar bind:value={filter} />
</div>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the
`actions` snippet can click() them without ResourceList's internal
@@ -2026,16 +2016,20 @@
<ResourceList
title={t('nav.files', 'Files')}
items={rlItems}
emptyText={hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
emptyHint={hiddenCount > 0
? t(
'files.empty_hidden_hint',
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
)
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={hiddenCount > 0 ? 'eye-slash' : undefined}
emptyText={searchActive
? t('search.no_results', 'No results found for this search')
: hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
emptyHint={searchActive
? t('search.prompt', 'Type a query in the search bar above.')
: hiddenCount > 0
? t(
'files.empty_hidden_hint',
"Files whose name starts with '.' are hidden. Toggle the setting to see them."
)
: t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
emptyIcon={searchActive ? 'search' : hiddenCount > 0 ? 'eye-slash' : undefined}
{loading}
error={error ?? undefined}
selectable
@@ -2052,7 +2046,7 @@
groupBys={rlGroupBys}
bind:groupBy
bind:reversed
hasMore={pageCursor !== undefined}
hasMore={searchActive ? searchCursor !== undefined : pageCursor !== undefined}
onloadmore={loadMore}
onreload={(orderBy) => {
sortField = orderBy as SortField;
@@ -2074,8 +2068,10 @@
<!-- Surfaces only when the folder isn't really empty — it's just
filtered because the user chose to hide dotfiles. Clicking
flips the app-wide `preferences.hideDotfiles` back off,
re-populating the list without a hunt through settings. -->
{#if hiddenCount > 0}
re-populating the list without a hunt through settings.
Suppressed in search mode: an empty result there is a real
"nothing matched", not a hidden-items artifact. -->
{#if !searchActive && hiddenCount > 0}
<button
class="btn btn-secondary"
onclick={() => preferences.setHideDotfiles(false)}
@@ -2106,6 +2102,29 @@
onDrop={(target, e) => onCrumbDrop(e, target)}
dragMime={DRAG_TYPE}
/>
{#if searchActive}
<!-- Filter mode: a flat result count replaces the folders/files
split — the recursive result set isn't "this folder's
content". "+" keeps the partial-pages meaning. -->
{#if rlItems.length > 0}
<span class="folder-stat" data-testid="files-folder-stat">
{t('filter.results_count', { n: rlItems.length }, '{{n}} results')}{folderStat.partial
? '+'
: ''}
</span>
{/if}
{:else if folderStat.folders + folderStat.files > 0}
<!-- Item count for the folder on screen. Sits in the same sticky
strip as the breadcrumb so it stays visible while scrolling.
"+" = more pages are still loading via infinite scroll. -->
<span class="folder-stat" data-testid="files-folder-stat">
{t(
'files.folder_stat',
{ folders: folderStat.folders, files: folderStat.files },
'{{folders}} folders · {{files}} files'
)}{folderStat.partial ? '+' : ''}
</span>
{/if}
{/snippet}
{#snippet actions()}
@@ -2155,6 +2174,21 @@
<Icon name="folder-plus" class="icon-mr" />
<span>{t('actions.new_folder', 'New folder')}</span>
</button>
<!-- Manual reload of the current folder: resets pagination to page 1
and refetches (listing accumulator + folder stat + dotfile
filter all recompute). Disabled while a load is already in
flight — the button is a convenience, not a hammer. -->
<button
class="btn btn-secondary"
data-testid="files-refresh-btn"
title={t('common.refresh', 'Refresh')}
aria-label={t('common.refresh', 'Refresh')}
disabled={loading}
onclick={() => void load(true)}
>
<Icon name="repeat" class="icon-mr" />
<span>{t('common.refresh', 'Refresh')}</span>
</button>
{/snippet}
{#snippet batchActions(_sel)}
@@ -2162,7 +2196,7 @@
class="batch-btn"
title={t('files.add_favorites', 'Add to favorites')}
data-testid="files-batch-favorite-btn"
onclick={() => void batchFavorites()}
onclick={() => void resActions.batchFavorites()}
>
<Icon name="star" />
<span>{t('files.add_favorites', 'Add to favorites')}</span>
@@ -2171,7 +2205,7 @@
class="batch-btn"
title={t('files.move', 'Move')}
data-testid="files-batch-move-btn"
onclick={batchMove}
onclick={resActions.batchMove}
>
<Icon name="arrows-alt" />
<span>{t('files.move', 'Move')}</span>
@@ -2180,7 +2214,7 @@
class="batch-btn"
title={t('files.copy', 'Copy')}
data-testid="files-batch-copy-btn"
onclick={batchCopy}
onclick={resActions.batchCopy}
>
<Icon name="copy" />
<span>{t('files.copy', 'Copy')}</span>
@@ -2189,7 +2223,7 @@
class="batch-btn"
title={t('common.download', 'Download')}
data-testid="files-batch-download-btn"
onclick={() => void batchDownload()}
onclick={() => void resActions.batchDownload()}
>
<Icon name="download" />
<span>{t('common.download', 'Download')}</span>
@@ -2198,7 +2232,7 @@
class="batch-btn batch-btn-danger"
title={t('common.delete', 'Delete')}
data-testid="files-batch-delete-btn"
onclick={batchDelete}
onclick={() => void resActions.batchDelete()}
>
<Icon name="trash" />
<span>{t('common.delete', 'Delete')}</span>
@@ -2210,14 +2244,11 @@
{#if moveDialog.component}
{@const MoveDialog = moveDialog.component}
<MoveDialog
bind:open={moveOpen}
item={actionTarget}
items={moveItems}
mode={moveMode}
onmoved={() => {
clearSelection();
void reload();
}}
bind:open={resActions.moveDialog.open}
item={resActions.moveDialog.item}
items={resActions.moveDialog.items}
mode={resActions.moveDialog.mode}
onmoved={resActions.handleMoved}
/>
{/if}
{#if shareDialog.component}
@@ -2434,6 +2465,17 @@
z-index: 1000;
}
/* Item count next to the breadcrumb (same sticky strip; `.rl-breadcrumb`
is already a flex row, so the span just flows beside the crumbs).
`flex-shrink: 0` keeps long crumb trails from squeezing the digits
into a vertical stack on narrow viewports — the crumbs wrap instead. */
.folder-stat {
flex-shrink: 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
white-space: nowrap;
}
.ctx-menu {
position: fixed;
z-index: 1001;
@@ -2471,4 +2513,9 @@
rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt);
}
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style>
+85 -8
View File
@@ -27,14 +27,19 @@ vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn() }));
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
vi.mock('$lib/api/endpoints/deltaUpload', () => ({
instantUploadOwned: vi.fn(),
resolveOwnedHashes: vi.fn(),
tryDeltaUpload: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn(), removeFavorite: vi.fn() }));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
addFavoritesBatch: vi.fn()
}));
vi.mock('$lib/api/endpoints/wopi', () => ({
canEditWithWopi: () => false,
getEditorUrlWithFallback: vi.fn()
@@ -77,7 +82,8 @@ vi.mock('$lib/api/endpoints/folders', () => ({
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile, uploadFileWithProgress } from '$lib/api/endpoints/files';
import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
import { apiFetch } from '$lib/api/client';
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
import { searchResources } from '$lib/api/endpoints/search';
import { files as filesStore } from '$lib/stores/files.svelte';
import FilesPage from './[...path]/+page.svelte';
@@ -196,6 +202,41 @@ it('loads the home folder listing on mount and renders its contents', async () =
await screen.findByTestId('files-new-folder-btn');
});
it('shows the folder item count next to the breadcrumb', async () => {
withListing(); // 1 folder + 1 file, `nextCursor` undefined → last page
render(FilesPage);
const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 folders · 1 files');
// No "+" suffix — the listing is complete, the count is exact.
expect(stat.textContent!.trim().endsWith('+')).toBe(false);
});
it('marks the folder count as partial while more pages exist', async () => {
const folder = folderItem('sub1', 'Sub');
const file = fileItem('f1', 'hello.txt');
m(fetchFolderPage).mockResolvedValue({
items: [folder, file],
folders: [folder],
files: [file],
nextCursor: 'page-2'
});
render(FilesPage);
const stat = await screen.findByTestId('files-folder-stat');
// Counts reflect the pages loaded so far; the trailing "+" says more
// are on the way via infinite scroll instead of claiming exactness.
expect(stat.textContent).toContain('1 folders · 1 files');
expect(stat.textContent!.trim().endsWith('+')).toBe(true);
});
it('reloads the listing when the refresh button is clicked', async () => {
withListing();
render(FilesPage);
await screen.findByTestId('files-refresh-btn');
// Initial mount load = 1 call; the click resets pagination and refetches.
await fireEvent.click(screen.getByTestId('files-refresh-btn'));
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledTimes(2));
});
it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage);
@@ -233,14 +274,50 @@ it('batch-deletes the whole selection after confirmation', async () => {
it('batch-favorites the selection via the favorites batch endpoint', async () => {
withListing();
m(apiFetch).mockResolvedValue({ ok: true });
m(addFavoritesBatch).mockResolvedValue(undefined);
render(FilesPage);
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/api/favorites/batch',
expect.objectContaining({ method: 'POST' })
)
expect(addFavoritesBatch).toHaveBeenCalledWith([
{ item_id: 'sub1', item_type: 'folder' },
{ item_id: 'f1', item_type: 'file' }
])
);
});
it('runs a scoped recursive search when the filter keyword is set', async () => {
withListing();
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
expect(searchResources).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ folderId: 'home', recursive: true })
);
});
it('shows the search result count while the filter is active', async () => {
withListing();
const hit = fileItem('s1', 'found.txt');
m(searchResources).mockResolvedValue({
items: [{ resource_type: 'file', resource: hit, meta: { score: 50 } }],
query_time_ms: 1
});
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'found' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 results');
});
+107 -67
View File
@@ -22,10 +22,18 @@
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import { TYPE_EXT, dateBound, sizeBounds } from '$lib/utils/searchFilters';
import { SvelteSet } from 'svelte/reactivity';
import { replaceSet } from '$lib/utils/sets';
import {
useResourceActions,
type ActionTarget
} from '$lib/composables/useResourceActions.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
const query = $derived(page.url.searchParams.get('q') ?? '');
@@ -145,7 +153,9 @@
void goto(target, { replaceState: true, keepFocus: true, noScroll: true });
}
// Filters
// Filters — the preset vocabularies and their SearchOptions mapping live
// in the shared `searchFilters` util (also consumed by the files page's
// filter bar); only the i18n label lists stay local since they need `t()`.
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
type SizeKey = 'all' | 'small' | 'medium' | 'large';
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
@@ -153,26 +163,6 @@
let sizeFilter = $state<SizeKey>('all');
let dateFilter = $state<DateKey>('all');
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 TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
@@ -195,36 +185,6 @@
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const MB = 1024 * 1024;
function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } {
switch (k) {
case 'small':
return { maxSize: MB };
case 'medium':
return { minSize: MB, maxSize: 100 * MB };
case 'large':
return { minSize: 100 * MB };
default:
return {};
}
}
function dateBound(k: DateKey): number | undefined {
const day = 86400;
const now = Math.floor(Date.now() / 1000);
switch (k) {
case 'day':
return now - day;
case 'week':
return now - 7 * day;
case 'month':
return now - 30 * day;
case 'year':
return now - 365 * day;
default:
return undefined;
}
}
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
function clearFilters() {
typeFilter = 'all';
@@ -294,6 +254,8 @@
scope === 'folder' && filesStore.section !== 'trash'
? (effectiveFolder ?? undefined)
: undefined;
// TYPE_EXT / sizeBounds / dateBound come from `$lib/utils/searchFilters`
// (shared with the files-page filter bar).
return {
recursive: true,
sortBy: orderByForGroup() as SortBy,
@@ -383,16 +345,46 @@
// all reuse the same lazy dialogs.
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte'));
const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.svelte'));
// ── Multi-select + batch actions ─────────────────────────────────────
// Same wiring as the files page: ResourceList owns the row-level
// selection UX and mirrors it out via `onselectionchange`; the shared
// composable owns the batch favorite/download/delete/move/copy flows
// and the MoveDialog state. After any mutation the search re-runs —
// a move/delete can shift rows in or out of the current scope and
// filter set, so patching in place would go stale.
const selected = new SvelteSet<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(() => {
if (viewerOpen) void fileViewer.load();
if (moveOpen) void moveDialog.load();
if (resActions.moveDialog.open) void moveDialog.load();
if (shareOpen) void shareDialog.load();
});
@@ -446,8 +438,8 @@
}
function openMoveDialog(item: FileItem | FolderItem) {
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) };
moveOpen = true;
const target: ActionTarget = { id: item.id, name: item.name, kind: kindOf(item) };
resActions.openMove(target);
}
function downloadItem(item: FileItem | FolderItem) {
@@ -615,7 +607,7 @@
<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}
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
@@ -629,6 +621,9 @@
emptyText={t('search.no_results', 'No results found for this search')}
hasMore={!!cursor}
onloadmore={loadMore}
selectable
shiftRangeSelect
onselectionchange={(ids) => replaceSet(selected, ids)}
showPath
showViewToggle
onopen={open}
@@ -750,6 +745,56 @@
<FolderBreadcrumb folderId={scopeFolderId} />
{/if}
{/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)}
<!--
Per-row "Open parent folder" quick-action — search results
@@ -787,16 +832,11 @@
{#if moveDialog.component}
{@const MoveDialog = moveDialog.component}
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
onmoved={() => {
// A move can shift the row out of the current scope (`?in=<uuid>`)
// or into it, and the SQL name-match count may change. Reload
// 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);
}}
bind:open={resActions.moveDialog.open}
item={resActions.moveDialog.item}
items={resActions.moveDialog.items}
mode={resActions.moveDialog.mode}
onmoved={resActions.handleMoved}
/>
{/if}
{#if shareDialog.component}
+100 -4
View File
@@ -1,24 +1,108 @@
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(),
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/state', () => ({ page: pageState }));
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 { 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';
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(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/search?q=report');
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 () => {
@@ -41,3 +125,15 @@ it('surfaces a search error', async () => {
await waitFor(() => expect(searchResources).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
});
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());
});
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "تم إرسال الإشعار بنجاح",
"shared_notificationFailed": "فشل إرسال الإشعار"
},
"filter": {
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
"keyword": "الكلمة المفتاحية",
"advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث"
},
"files": {
"name": "الاسم",
"type": "النوع",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Benachrichtigung erfolgreich gesendet",
"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"
},
"files": {
"name": "Name",
"type": "Typ",
+10
View File
@@ -451,11 +451,20 @@
"shared_notificationSent": "Notification sent successfully",
"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"
},
"files": {
"name": "Name",
"type": "Type",
"size": "Size",
"modified": "Modified",
"folder_stat": "{{folders}} folders · {{files}} files",
"no_files": "No files in this folder",
"empty_hint": "Upload files or create folders to get started",
"drop_to_upload": "Drop files here to upload",
@@ -1645,6 +1654,7 @@
"toggle_theme": "Toggle theme"
},
"common": {
"refresh": "Refresh",
"add": "Add",
"cancel": "Cancel",
"clear": "Clear",
+8
View File
@@ -416,6 +416,14 @@
"mit_license": "Licencia MIT",
"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"
},
"files": {
"name": "Nombre",
"type": "Tipo",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "آگاه‌سازی با موفقیت ارسال شد",
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
},
"filter": {
"placeholder": "جستجو در این پوشه و زیرپوشه‌ها…",
"keyword": "کلیدواژه",
"advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو"
},
"files": {
"name": "نام",
"type": "نوع",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Notification envoyée avec succès",
"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"
},
"files": {
"name": "Nom",
"type": "Type",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "सूचना सफलतापूर्वक भेजी गई",
"shared_notificationFailed": "सूचना भेजने में विफल"
},
"filter": {
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
"keyword": "कीवर्ड",
"advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें"
},
"files": {
"name": "नाम",
"type": "प्रकार",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Notifica inviata con successo",
"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"
},
"files": {
"name": "Nome",
"type": "Tipo",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "通知が正常に送信されました",
"shared_notificationFailed": "通知の送信に失敗しました"
},
"filter": {
"placeholder": "このフォルダとサブフォルダを検索…",
"keyword": "キーワード",
"advanced": "フィルター",
"recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア"
},
"files": {
"name": "名前",
"type": "種類",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "알림이 성공적으로 전송되었습니다",
"shared_notificationFailed": "알림 전송에 실패했습니다"
},
"filter": {
"placeholder": "이 폴더와 하위 폴더 검색…",
"keyword": "키워드",
"advanced": "필터",
"recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기"
},
"files": {
"name": "이름",
"type": "유형",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Notificatie verzonden",
"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"
},
"files": {
"name": "Naam",
"type": "Type",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Powiadomienie wysłane pomyślnie",
"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"
},
"files": {
"name": "Nazwa",
"type": "Typ",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Notificação enviada com sucesso",
"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"
},
"files": {
"name": "Nome",
"type": "Tipo",
+8
View File
@@ -416,6 +416,14 @@
"shared_notificationSent": "Уведомление успешно отправлено",
"shared_notificationFailed": "Не удалось отправить уведомление"
},
"filter": {
"placeholder": "Поиск в этой папке и подпапках…",
"keyword": "Ключевое слово",
"advanced": "Фильтры",
"recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск"
},
"files": {
"name": "Имя",
"type": "Тип",
+10
View File
@@ -416,11 +416,20 @@
"shared_typeFile": "檔案",
"shared_typeFolder": "資料夾"
},
"filter": {
"placeholder": "搜尋此資料夾及子資料夾…",
"keyword": "關鍵字",
"advanced": "篩選",
"recursive": "包含子資料夾",
"results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋"
},
"files": {
"name": "名稱",
"type": "型別",
"size": "大小",
"modified": "修改日期",
"folder_stat": "{{folders}} 個資料夾 · {{files}} 個檔案",
"no_files": "此資料夾中沒有檔案",
"empty_hint": "上傳檔案或建立資料夾以開始使用",
"drop_to_upload": "將檔案拖放到此處上傳",
@@ -1598,6 +1607,7 @@
"videos": "影片"
},
"common": {
"refresh": "重新整理",
"add": "新增",
"cancel": "取消",
"clear": "清除",
+10
View File
@@ -416,11 +416,20 @@
"shared_typeFile": "文件",
"shared_typeFolder": "文件夹"
},
"filter": {
"placeholder": "搜索此文件夹及子文件夹…",
"keyword": "关键词",
"advanced": "筛选",
"recursive": "包含子文件夹",
"results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索"
},
"files": {
"name": "名称",
"type": "类型",
"size": "大小",
"modified": "修改日期",
"folder_stat": "{{folders}} 个文件夹 · {{files}} 个文件",
"no_files": "此文件夹中没有文件",
"empty_hint": "上传文件或创建文件夹以开始使用",
"drop_to_upload": "将文件拖放到此处上传",
@@ -1598,6 +1607,7 @@
"videos": "视频"
},
"common": {
"refresh": "刷新",
"add": "添加",
"cancel": "取消",
"clear": "清除",
+103
View File
@@ -0,0 +1,103 @@
# status.md — 计划与进度记录
> 本文件由 agent 自动维护,规则见 [AGENTS.md](AGENTS.md) 末尾的"本地 fork 维护规则"一节。
> 新条目加在"进行中"区域顶部;完成后移入"已完成"。
## 进行中
(暂无)
## 已完成
### [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 <上游仓库地址>`。)