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