Merge pull request #645 from EdouardVanbelle/refactor/search

This commit is contained in:
Dionisio Pozo
2026-07-27 01:08:35 +02:00
committed by GitHub
45 changed files with 2790 additions and 819 deletions
+68 -31
View File
@@ -1,69 +1,106 @@
# Search
OxiCloud provides authenticated file and folder search with simple query parameters, advanced JSON criteria, pagination, recursive traversal, and in-memory result caching.
OxiCloud provides authenticated file and folder search with a
cursor-paginated response, filter/sort query parameters, recursive
traversal, and in-memory result caching.
## Endpoints
| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/api/search/` | Simple search using query parameters |
| `POST` | `/api/search/advanced` | Advanced search with a JSON body |
| `GET` | `/api/search` | Cursor-paginated search |
| `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions |
| `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) |
All search endpoints require authentication. The cache flush is
additionally restricted to administrators — see [Result Caching](#result-caching).
## Simple Search Parameters
## Query Parameters
| Parameter | Description |
| --- | --- |
| `query` | Text to search in file and folder names |
| `type` | Comma-separated file extensions |
| `created_after` / `created_before` | Filter by creation time |
| `modified_after` / `modified_before` | Filter by modification time |
| `min_size` / `max_size` | Filter by file size in bytes |
| `query` | Text to search in file/folder names (and file content when the Tantivy index is enabled) |
| `type` | Comma-separated file extensions (files only) |
| `folder_id` | Restrict search scope to one folder |
| `recursive` | Search subfolders, defaults to `true` |
| `limit` | Maximum results, defaults to `100` |
| `offset` | Pagination offset |
| `sort_by` | `relevance`, `name`, `name_desc`, `date`, `date_desc`, `size`, or `size_desc` |
| `created_after` / `created_before` | Filter by creation time (unix seconds) |
| `modified_after` / `modified_before` | Filter by modification time |
| `min_size` / `max_size` | Filter by file size in bytes |
| `resource_types` | Comma-separated: `file`, `folder` (both by default) |
| `order_by` | Sort dimension: `relevance` (default), `name`, `size`, `updated_at`, `created_at` |
| `reverse` | Reverse sort direction (no-op for `relevance`) |
| `limit` | Page size (1–200, default 50) |
| `cursor` | Opaque cursor returned by the previous page |
## Response Shape
`/api/search` returns the same envelope as every other `/*/resources`
listing (folders, favorites, recent, trash, shared) so a single
client component can render all of them:
```json
{
"items": [
{
"resource_type": "file",
"resource": { "id": "…", "name": "report.pdf", "size": 12345, "…": "…" },
"meta": {
"score": 0.82,
"snippet": "…quarterly <mark>report</mark>…",
"via": "content"
}
},
{
"resource_type": "folder",
"resource": { "id": "…", "name": "Reports", "…": "…" },
"meta": { "score": 0.31, "via": "name" }
}
],
"next_cursor": "eyJvZmZzZXQiOjUwLCJvcmRlcl9ieSI6InJlbGV2YW5jZSJ9",
"query_time_ms": 12,
"total": 137
}
```
- `resource_type`: `"file"` or `"folder"` — tells the client which
variant of `resource` to render.
- `resource`: the same `FileDto` / `FolderDto` shape any other
endpoint would emit.
- `meta.score`: relevance in `[0, 1]`.
- `meta.snippet`: optional HTML-safe excerpt from the content index
(present only when the match came from file content).
- `meta.via`: `"name"`, `"content"`, or `"path"` — where the match
fired.
- `next_cursor`: opaque; present iff another page exists. Pass it
verbatim as `?cursor=…` for the next call.
- `total`: integer, approximate — reflects the caller-visible match
count (permission-filtered). Omitted when unknown; never leaks a
count for rows the caller cannot see.
### Example
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://oxicloud.example.com/api/search/?query=report&type=pdf,docx&recursive=true&limit=20"
```
## Advanced Search
```json
{
"name_contains": "report",
"file_types": ["pdf", "docx"],
"min_size": 1024,
"folder_id": "folder-uuid",
"recursive": true,
"limit": 50,
"offset": 0
}
"https://oxicloud.example.com/api/search?query=report&type=pdf,docx&limit=20"
```
## Suggestions
Use `/api/search/suggest?query=rep&limit=10` for quick autocomplete-style results. Suggestions can also be scoped to a folder with `folder_id`.
Use `/api/search/suggest?query=rep&limit=10` for quick
autocomplete-style results. Suggestions can also be scoped to a
folder with `folder_id`.
## Result Caching
Search results are cached in memory using the search criteria and user ID as the cache key.
Search results are cached in memory using the search criteria and
user ID as the cache key.
- Cache TTL: 5 minutes
- Max entries: 1000
- Manual invalidation: `DELETE /api/admin/search/cache` — admin-only.
The endpoint calls `invalidate_all()` on the shared moka cache, so
one call cold-starts every subsequent search for every tenant; it's
an operator debug lever, not a per-user affordance.
one call cold-starts every subsequent search for every tenant;
it's an operator debug lever, not a per-user affordance.
## Feature Flag
+24 -5
View File
@@ -145,6 +145,11 @@ fn synth_entry(idx: u64) -> Arc<SearchResultsDto> {
blob_hash: pseudo_hex(&mut rng, 64),
snippet: content_hit.then(|| SNIPPET.to_string()),
match_source: Some(match_source.to_string()),
etag: pseudo_hex(&mut rng, 16),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
});
}
@@ -203,12 +208,20 @@ struct PhaseReport {
/// Insert the full corpus, settle the cache, then measure retention and
/// hot-key read latency. Identical for both variants — only the cache
/// configuration differs.
async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> PhaseReport {
async fn run_phase(
cache: &moka::future::Cache<(uuid::Uuid, u64), Arc<SearchResultsDto>>,
) -> PhaseReport {
let hwm_start_kb = status_kb("VmHWM");
let rss_start_kb = status_kb("VmRSS");
// Cache key changed to `(Uuid, u64)` in the per-user invalidation
// refactor (2026-07-26). Bench uses one fixed user across all keys —
// varying the u64 part exercises the same cardinality the pre-refactor
// benchmark did (one entry per query variant).
let bench_user = uuid::Uuid::nil();
for i in 0..ENTRIES {
cache.insert(i, synth_entry(i)).await;
cache.insert((bench_user, i), synth_entry(i)).await;
// Let eviction run as it would under live traffic, so evicted pages
// are actually freed instead of piling up in moka's pending queue.
if i % 64 == 0 {
@@ -226,7 +239,7 @@ async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> P
.sum();
// Hot-key read latency: p50 over GETS reads of one resident key.
let hot: u64 = *cache.iter().next().expect("cache is empty after fill").0;
let hot: (uuid::Uuid, u64) = *cache.iter().next().expect("cache is empty after fill").0;
for _ in 0..1_000 {
black_box(cache.get(&hot).await); // warmup
}
@@ -257,7 +270,10 @@ async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> P
#[tokio::main]
async fn main() {
let entry_weight = u64::from(search_results_entry_weight(&0, &synth_entry(0)));
let entry_weight = u64::from(search_results_entry_weight(
&(uuid::Uuid::nil(), 0),
&synth_entry(0),
));
println!("\n###########################################################");
println!("# Search-results cache: entry-count bound vs byte bound");
println!(
@@ -274,7 +290,10 @@ async fn main() {
println!("###########################################################\n");
// --- Phase 1: BEFORE (entry-count bound, exactly the old wiring) ---
let before_cache: moka::future::Cache<u64, Arc<SearchResultsDto>> =
// Key type mirrors production's post-2026-07-26 tuple key so both
// phases exercise the same `Cache<(Uuid, u64), _>` shape; only the
// capacity bound differs (entry-count here vs weigher below).
let before_cache: moka::future::Cache<(uuid::Uuid, u64), Arc<SearchResultsDto>> =
moka::future::Cache::builder()
.max_capacity(BEFORE_MAX_ENTRIES)
.time_to_live(Duration::from_secs(TTL_SECS))
+19 -4
View File
@@ -2,24 +2,39 @@ import { it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
import { apiFetch, apiJson } from '$lib/api/client';
import { searchFiles, searchSuggest, clearSearchCache } from './search';
import { searchResources, searchSuggest, clearSearchCache } from './search';
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
j.mockResolvedValue({ files: [], folders: [] });
j.mockResolvedValue({ items: [], query_time_ms: 0 });
});
it('builds search requests including filters', async () => {
await searchFiles('q', {
await searchResources('q', {
recursive: true,
fileTypes: ['mp3', 'wav'],
minSize: 1,
maxSize: 9,
sortBy: 'date'
sortBy: 'updated_at'
}).catch(() => {});
expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything());
// Sort dimension is sent on the wire as `order_by`, matching the
// backend's `SearchResourcesQuery` (post-normalization).
expect(j).toHaveBeenCalledWith(expect.stringContaining('order_by=updated_at'), expect.anything());
await searchSuggest('q').catch(() => {});
await clearSearchCache().catch(() => {});
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
});
it('forwards cursor pagination and resource-type filter', async () => {
await searchResources('q', {
cursor: 'abc',
resourceTypes: ['file'],
limit: 25
}).catch(() => {});
const call = j.mock.calls.at(-1)?.[0] as string;
expect(call).toContain('cursor=abc');
expect(call).toContain('resource_types=file');
expect(call).toContain('limit=25');
});
+29 -8
View File
@@ -1,6 +1,6 @@
/** Search endpoint — ported from features/files/search.js. */
// Search endpoint — hits the normalized `/*/resources` envelope shape.
import { apiFetch, apiJson } from '$lib/api/client';
import type { SearchResults, SortBy } from '$lib/api/types';
import type { ItemType, SearchResourcesResponse, SortBy } from '$lib/api/types';
export interface SearchOptions {
folderId?: string;
@@ -16,14 +16,33 @@ export interface SearchOptions {
modifiedAfter?: number;
/** Unix-seconds upper bound on modified time. */
modifiedBefore?: number;
/** Page size (1–200 server-side; default 50). */
limit?: number;
offset?: number;
/**
* Cursor from a previous response's `next_cursor`. Absent → first page.
* The wire uses cursor pagination now; the old `offset` param is gone.
*/
cursor?: string;
/** Sort dimension; maps to backend `order_by`. */
sortBy?: SortBy;
/** Reverse sort direction; maps to backend `reverse`. */
reverse?: boolean;
/** Restrict to files, folders, or both (default). */
resourceTypes?: ItemType[];
/** Abort the request when a newer search supersedes it. */
signal?: AbortSignal;
}
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
/**
* Cursor-paginated search. Returns the shared envelope
* `{ items[], next_cursor?, query_time_ms, total? }` — same shape as
* favorites / recent / trash / folder listings so `ResourceList`
* consumes the items without a demux step.
*/
export function searchResources(
query: string,
opts: SearchOptions = {}
): Promise<SearchResourcesResponse> {
const params = new URLSearchParams();
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
@@ -37,10 +56,12 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
params.append('limit', String(opts.limit ?? 100));
params.append('offset', String(opts.offset ?? 0));
params.append('sort_by', opts.sortBy ?? 'relevance');
return apiJson<SearchResults>(`/api/search?${params.toString()}`, {
if (opts.resourceTypes?.length) params.append('resource_types', opts.resourceTypes.join(','));
if (opts.limit != null) params.append('limit', String(opts.limit));
if (opts.cursor) params.append('cursor', opts.cursor);
if (opts.sortBy) params.append('order_by', opts.sortBy);
if (opts.reverse) params.append('reverse', 'true');
return apiJson<SearchResourcesResponse>(`/api/search?${params.toString()}`, {
credentials: 'same-origin',
signal: opts.signal
});
+42 -30
View File
@@ -248,40 +248,52 @@ export interface AuthResponse {
expires_in: number;
}
export type SortBy =
| 'relevance'
| 'name'
| 'name_desc'
| 'date'
| 'date_desc'
| 'size'
| 'size_desc';
/**
* Sort dimension for `GET /api/search`. Wire-matches the backend's
* `SearchResourcesQuery.order_by` — 5 canonical values, direction is
* a separate `reverse` boolean (the `_desc` suffix pattern was
* retired 2026-07-26; `date` was renamed to the more explicit
* `updated_at` alongside the new `created_at`).
*/
export type SortBy = 'relevance' | 'name' | 'size' | 'updated_at' | 'created_at';
export interface SearchCriteria {
sort_by: SortBy;
recursive: boolean;
limit: number;
offset: number;
name_contains?: string;
file_types?: string[];
folder_id?: string;
min_size?: number;
max_size?: number;
created_before?: number;
created_after?: number;
modified_before?: number;
modified_after?: number;
/**
* Per-item search metadata inline on every hit in the normalized
* `/api/search` envelope. Mirrors backend `SearchMeta` — see
* `application/dtos/search_dto.rs`.
*/
export interface SearchMeta {
/** Relevance in [0, 1]; the higher the better. */
score: number;
/** Optional HTML-safe excerpt when the match fired via content index. */
snippet?: string;
/** Where the match fired. */
via?: 'name' | 'content' | 'path';
}
export interface SearchResults {
files: FileItem[];
folders: FolderItem[];
total_count: number | null;
limit: number;
offset: number;
has_more: boolean;
/**
* Single hit in the `/api/search` envelope. `resource_type` disambiguates
* `resource`'s union so the shared `ResourceList` component can render it
* exactly like a folders/favorites/recent/trash row.
*/
export interface SearchResourceItem {
resource_type: ItemType;
resource: FileItem | FolderItem;
meta: SearchMeta;
}
/**
* Wire response of `GET /api/search`. Same envelope shape as the other
* "resources" listing endpoints (`items[]` + optional `next_cursor`),
* plus two search-specific top-level fields: `query_time_ms` (health
* signal for admins, "Found N in Xms" for users) and `total` (approximate,
* caller-visible; never leaks a count for rows the caller can't see).
*/
export interface SearchResourcesResponse {
items: SearchResourceItem[];
next_cursor?: string;
query_time_ms: number;
sort_by: string;
total?: number;
}
export type DriveKind = 'personal' | 'shared';
+30 -10
View File
@@ -4,7 +4,7 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { searchResources } from '$lib/api/endpoints/search';
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
import { deleteFolder } from '$lib/api/endpoints/folders';
import { addFavorite } from '$lib/api/endpoints/favorites';
@@ -17,6 +17,7 @@
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte';
@@ -255,11 +256,22 @@
function goToResults() {
const q = searchQuery.trim();
if (q) {
suggestOpen = false;
searchActive = false;
goto(resolve(`/search?q=${encodeURIComponent(q)}`));
if (!q) return;
suggestOpen = false;
searchActive = false;
// Carry the currently-open folder into the search URL as `?in=<uuid>`
// so a hard refresh, a shared link, or a bookmark all restore the
// "This folder" scope. Trash section is always global — skip. See
// `/search/+page.svelte` for the receiver side.
//
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; the two params here don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
if (filesStore.currentFolder && filesStore.section !== 'trash') {
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
}
goto(resolve(`/search?${parts.join('&')}`));
}
function onSearch(e: SubmitEvent) {
@@ -285,12 +297,20 @@
suggestInflight = ctl;
suggestBusy = true;
try {
const r = await searchFiles(q, { recursive: true, limit: 6, signal: ctl.signal });
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal });
if (seq !== suggestSeq) return; // superseded while awaiting
suggestions = [
...r.folders.slice(0, 3).map((item) => ({ kind: 'folder' as const, item })),
...r.files.slice(0, 6).map((item) => ({ kind: 'file' as const, item }))
];
// The wire is ordered — folders first, then files — but slice
// per kind explicitly so the header preview stays a folder-heavy
// list even when files dominate the result set.
const folders = r.items
.filter((it) => it.resource_type === 'folder')
.slice(0, 3)
.map((it) => ({ kind: 'folder' as const, item: it.resource as FolderItem }));
const files = r.items
.filter((it) => it.resource_type === 'file')
.slice(0, 6)
.map((it) => ({ kind: 'file' as const, item: it.resource as FileItem }));
suggestions = [...folders, ...files];
suggestOpen = suggestions.length > 0;
} catch {
if (seq !== suggestSeq || ctl.signal.aborted) return;
+3 -1
View File
@@ -9,7 +9,9 @@ const { goto, pageState } = vi.hoisted(() => ({
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/search', () => ({
searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 }))
}));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { logout } from '$lib/api/endpoints/auth';
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { searchResources } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
@@ -193,24 +193,33 @@
}
searchTimer = setTimeout(async () => {
try {
const r = await searchFiles(q, { recursive: true, limit: 5 });
const folders: Command[] = r.folders.slice(0, 3).map((f) => ({
id: `fld-${f.id}`,
label: f.name,
icon: 'folder',
hint: t('files.folder', 'Folder'),
run: nav(`/files/${f.id}`)
}));
const files: Command[] = r.files.slice(0, 5).map((f) => ({
id: `fil-${f.id}`,
label: f.name,
icon: 'file',
hint: t('files.file', 'File'),
run: () => {
close();
window.open(fileInlineUrl(f.id), '_blank', 'noopener');
}
}));
const r = await searchResources(q, { recursive: true, limit: 8 });
// Wire items are ordered folders-first-then-files, but demux
// explicitly so the palette keeps the two-section layout even
// when file hits dominate the result set.
const folders: Command[] = r.items
.filter((it) => it.resource_type === 'folder')
.slice(0, 3)
.map((it) => ({
id: `fld-${it.resource.id}`,
label: it.resource.name,
icon: 'folder',
hint: t('files.folder', 'Folder'),
run: nav(`/files/${it.resource.id}`)
}));
const files: Command[] = r.items
.filter((it) => it.resource_type === 'file')
.slice(0, 5)
.map((it) => ({
id: `fil-${it.resource.id}`,
label: it.resource.name,
icon: 'file',
hint: t('files.file', 'File'),
run: () => {
close();
window.open(fileInlineUrl(it.resource.id), '_blank', 'noopener');
}
}));
fileMatches = [...folders, ...files];
} catch {
fileMatches = [];
@@ -4,11 +4,13 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { goto } = vi.hoisted(() => ({ goto: vi.fn() }));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/search', () => ({
searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 }))
}));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
import { searchFiles } from '$lib/api/endpoints/search';
import { searchResources } from '$lib/api/endpoints/search';
import { session } from '$lib/stores/session.svelte';
import CommandPalette from './CommandPalette.svelte';
@@ -42,6 +44,6 @@ it('searches files as the query is typed', async () => {
await openPalette();
const input = await screen.findByTestId('command-palette-input');
await fireEvent.input(input, { target: { value: 'report' } });
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][0]).toBe('report');
});
+9
View File
@@ -370,6 +370,15 @@ export const OxiIcons: Record<string, IconEntry> = {
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
],
// Font Awesome Free 6.7.2 `ranking-star` (podium with a star crown).
// Hand-added — /search's group-by dropdown uses this glyph for the
// "Relevance" option (Ed's 2026-07-26 UX call). Registry header says
// "regenerate from the source" but no generator lives in-repo, so
// hand-inserting matches the shape of every other entry.
"ranking-star": [
640,
"M353.8 54.1L330.2 6.3c-3.9-8.3-16.1-8.3-20 0L286.6 54.1l-52.9 7.7c-9.2 1.3-12.9 12.7-6.2 19.3l38.3 37.3-9.1 52.7c-1.6 9.2 8.1 16.2 16.3 11.9l47.3-24.9 47.3 24.9c8.2 4.3 17.9-2.7 16.3-11.9l-9-52.7 38.3-37.3c6.7-6.5 3-17.9-6.2-19.3L353.8 54.1zM256 256c-17.7 0-32 14.3-32 32l0 192c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-192c0-17.7-14.3-32-32-32l-128 0zM32 320c-17.7 0-32 14.3-32 32L0 480c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32L32 320zm416 96l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-128 0c-17.7 0-32 14.3-32 32z"
],
"repeat": [
512,
"M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z"
+160 -56
View File
@@ -197,13 +197,33 @@
.list-header-checkbox input[type="checkbox"],
.file-item .checkbox-cell input[type="checkbox"] {
width: 17px;
height: 17px;
/* Bumped from 17px to 20px so the row-selection checkbox reads at
a similar visual weight to the 28x28 action buttons that sit at
the other end of the row (Ed's 2026-07-26 UX note). 20px is the
upper end of the browser-native checkbox range — beyond that
platforms start rendering an oversized-and-blurry glyph. */
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--color-accent);
border-radius: var(--radius-sm);
}
/* Orange outline on hover for the row-selection checkbox (Ed's UX ask
2026-07-26). `outline` (not `border`) because native checkboxes in
list view honor `accent-color` but their border rendering is
platform-inconsistent — `outline` is drawn OUTSIDE the box and
doesn't reflow the layout. Grid view uses a custom-drawn checkbox
(see `.files-grid-view … input[type="checkbox"]` below) which
accepts real border styling; its hover override lives with that
block and beats this via specificity. Matches the "everything
hovers to accent" convention shared with row-action buttons. */
.list-header-checkbox input[type="checkbox"]:hover,
.file-item .checkbox-cell input[type="checkbox"]:hover {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.list-header.selection-mode {
grid-template-columns: 36px 1fr;
background-color: var(--color-multiselect-bg);
@@ -496,30 +516,58 @@
.files-list-view .file-item .action-cell button:not(.btn-action):hover {
background: var(--color-border-subtle);
color: var(--color-text-dark);
/* Accent-orange hover — matches the grid view's `.file-actions:hover`
and the shared `.btn-action:hover` rule. Semantic overrides on
`.favorite-star:hover` / `.shared-button:hover` (gold / blue) take
precedence via more-specific selectors below. */
color: var(--color-accent);
}
/* Fav-star + shared-button share the same visibility rule: hidden on
quiet rows, visible on row hover, and — crucially — always visible
when their `.active` class is set. That's what lets a favorited or
shared row be discoverable at a glance in list view without the
user having to mouse over it.
/* List-view resting/active state colors for favorite (gold) and shared
(blue). Hover is intentionally NOT overridden here — the generic
`.action-cell button:not(.btn-action):hover → --color-accent` rule
above owns the orange hover for every button (kebab / favorite /
shared) per Ed's 2026-07-26 spec. This block only paints the
at-rest state on `.active` rows so the star / shared chip stays
discoverable on a quiet row. */
.files-list-view .file-item .action-cell button.favorite-star.active {
color: var(--color-star-text-hover);
}
We use `visibility: hidden` (not `display: none`) so a hidden
button still reserves its slot in the action cell. Otherwise a row
that's shared-but-not-favorited would slide its shared icon into
the fav-star's column, breaking vertical alignment across rows. */
.files-list-view .file-item .action-cell button.shared-button.active {
color: var(--color-badge-blue-text);
}
/* Fav-star + shared-button — hidden on quiet rows, visible on row hover,
and always visible when their `.active` class is set (so a favorited /
shared row is discoverable without mousing over).
`opacity: 0` + `pointer-events: none` (instead of `visibility: hidden`)
so the hide transitions match the kebab's fade — Ed's 2026-07-26 UX
note: pre-refactor kebab faded over motion-fast while star/shared
snapped out instantly (visibility flips have no animatable value),
producing a staggered exit when the pointer left a row. The slot
still reserves layout because the button geometry is unchanged; only
its paint is toggled. */
.files-list-view .file-item .action-cell button.favorite-star,
.files-list-view .file-item .action-cell button.shared-button {
visibility: hidden;
opacity: 0;
pointer-events: none;
border: none;
transition: opacity var(--motion-fast) var(--ease-standard);
}
/* Strict reveal: star + shared appear ONLY on `.active` (row is
favorited / shared) or pointer hover — Ed's 2026-07-26 spec. No
`:focus-within` reveal, so keyboard focus on the row-body (name /
path cells) does NOT flash the actions cluster. Quiet rows stay
quiet at rest. */
.files-list-view .file-item:hover .action-cell button.favorite-star,
.files-list-view .file-item:hover .action-cell button.shared-button,
.files-list-view .file-item .action-cell button.favorite-star.active,
.files-list-view .file-item .action-cell button.shared-button.active {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable
@@ -668,8 +716,11 @@
position: absolute;
top: calc(var(--space-3) + 8px);
left: calc(var(--space-3) + 8px);
width: 26px;
height: 26px;
/* 30x30 matches the action-cell chip pills (`.file-actions`, star,
shared, `.btn-action`) so the checkbox and the corner-cluster
buttons read as siblings of one visual size (Ed 2026-07-26). */
width: 30px;
height: 30px;
border-radius: var(--radius-md);
background: var(--color-scrim-control);
backdrop-filter: blur(6px);
@@ -699,8 +750,10 @@
.files-grid-view .file-item .checkbox-cell input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
/* Custom-drawn glyph sized proportionally to the 30x30 chip pill —
bumped from 18x18 to match the list-view checkbox's 20x20. */
width: 20px;
height: 20px;
margin: 0;
border: 2px solid var(--color-border-medium);
border-radius: var(--radius-sm);
@@ -713,6 +766,17 @@
border-color var(--motion-fast) var(--ease-standard);
}
/* Grid view uses a custom-drawn checkbox (`appearance: none`) so we can
swap the real border color on hover — cleaner than the list-view
`outline` trick, and no double-ring on this variant. Specificity
(0,4,1 + 0,0,1) beats the shared `input[type="checkbox"]:hover`
outline rule above so the grid-view chip doesn't get both a border
AND an outline. */
.files-grid-view .file-item .checkbox-cell input[type="checkbox"]:hover {
border-color: var(--color-accent);
outline: none;
}
.files-grid-view .file-item .checkbox-cell input[type="checkbox"]::after {
content: "";
width: 5px;
@@ -783,9 +847,11 @@
markup still has shared, favorite, itemActions, kebab in that
sequence so list-view's inline right-aligned flow is unchanged. */
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) {
/* Start right after the checkbox column (26px chip + inline gap)
so the left group aligns visually with the checkbox row. */
left: calc(var(--space-3) + 8px + 26px + var(--space-2));
/* Start right after the checkbox column (30px chip + inline gap)
so the left group aligns visually with the checkbox row. Kept in
sync with `.checkbox-cell` width above — the two share this
constant. */
left: calc(var(--space-3) + 8px + 30px + var(--space-2));
right: calc(var(--space-3) + 8px);
display: flex;
align-items: center;
@@ -826,26 +892,36 @@
/* Per-button visibility (mirrors list view): each button is independently
gated by its own `.active` flag OR row-hover. This prevents a favorited
row from also lighting up the shared button (and vice versa) — the cell
used to reveal all its children together via a single opacity toggle. */
row from also lighting up the shared button (and vice versa).
`opacity + pointer-events` so the fade timing matches the kebab and
every `.btn-action--hover` — see the list-view block above for why. */
.files-grid-view .file-item .action-cell .favorite-star,
.files-grid-view .file-item .action-cell .shared-button {
visibility: hidden;
transition: visibility var(--motion-fast) var(--ease-standard);
opacity: 0;
pointer-events: none;
transition: opacity var(--motion-fast) var(--ease-standard);
}
/* Strict reveal: same rule as the list view — `.active` OR pointer
hover, no `:focus-within` (Ed's 2026-07-26 spec). */
.files-grid-view .file-item:hover .action-cell .favorite-star,
.files-grid-view .file-item:hover .action-cell .shared-button,
.files-grid-view .file-item:focus-within .action-cell .favorite-star,
.files-grid-view .file-item:focus-within .action-cell .shared-button,
.files-grid-view .file-item .action-cell .favorite-star.active,
.files-grid-view .file-item .action-cell .shared-button.active {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they
line up in the flex row. */
line up in the flex row.
NOTE: no `opacity: 1` here. Per-button reveal rules above own the
opacity (star/shared: `.active` + `:hover`; kebab + btn-action:
`:hover`). Pre-refactor this block force-set `opacity: 1` on the
assumption that reveal lived on the parent `.action-cell` — that
assumption is gone (Ed 2026-07-26), and the leftover made star +
shared appear always-visible in grid view regardless of hover /
`.active` state. */
.files-grid-view .file-item .action-cell .file-actions,
.files-grid-view .file-item .action-cell .favorite-star,
.files-grid-view .file-item .action-cell .shared-button,
@@ -871,11 +947,25 @@
color: var(--color-text);
font-size: var(--text-md);
cursor: pointer;
/* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */
opacity: 1;
}
.files-grid-view .file-item .action-cell .file-actions:hover {
/* Unified row-action hover: every button in the grid-view corner cluster
(kebab, star, shared, btn-action*) turns accent-orange on hover.
Semantic states are conveyed by the `.active` class, not by hover
color, so favorite = gold when starred, shared = blue when shared,
both regardless of pointer position (see `.active` rules below).
Ed's 2026-07-26 UX call: "orange for mouse over on all buttons;
blue only for active shared."
Selector specificity (0,4,1 + 0,0,1 = high) beats the chip-visual
base rule at ~line 852 (`.files-grid-view .file-item .action-cell
.btn-action { color: var(--color-text) }`, 0,4,0), which is why the
simpler `.btn-action:hover` didn't take effect inside the corner
cluster. */
.files-grid-view .file-item .action-cell .file-actions:hover,
.files-grid-view .file-item .action-cell .btn-action:hover,
.files-grid-view .file-item .action-cell .favorite-star:hover,
.files-grid-view .file-item .action-cell .shared-button:hover {
color: var(--color-accent);
}
@@ -884,13 +974,20 @@
border: 2px dashed var(--color-warning-border);
}
/* Favorite star + shared button — visual overrides only. Position,
hover-reveal, chip geometry all come from the shared corner-cluster
rule on `.files-grid-view .file-item .action-cell`. What's left
here is just the per-state colour: subtle at rest, saturated when
the item's flag is set. `.active` on either button also bumps the
parent cluster's opacity (via `:has()` above) so an unhovered card
still shows its favorited/shared state. */
/* Favorite star + shared button — resting/active state colors only.
HOVER color for both lives in the unified `.action-cell button:hover
→ --color-accent` rule above; the per-state palette here only fires
when the button is NOT hovered. Convention (Ed 2026-07-26):
• hover → orange (accent) — every row action
• star.active (not hover) → gold
• shared.active (not hover) → blue
`.active` retains its color even on hover for `favorite-star` (star
users expect gold-on-gold on the currently-starred item; losing the
glyph mid-click reads as broken) but yields to orange for `shared`
per Ed's explicit "blue only for active shared" — pointer-over on
an already-shared row should still communicate "you're about to
toggle something." */
.files-grid-view .file-item button.favorite-star,
.files-grid-view .file-item button.shared-button {
color: var(--color-text-subtle);
@@ -898,19 +995,10 @@
line-height: var(--leading-none);
}
.files-grid-view .file-item button.favorite-star:hover {
color: var(--color-star-text);
}
.files-grid-view .file-item button.favorite-star.active {
color: var(--color-star-text-hover);
}
.files-grid-view .file-item button.favorite-star.active:hover {
color: var(--color-star-active);
}
.files-grid-view .file-item button.shared-button:hover,
.files-grid-view .file-item button.shared-button.active {
color: var(--color-badge-blue-text);
}
@@ -1274,25 +1362,41 @@
.btn-action:hover {
background: var(--color-border-subtle);
color: var(--color-text-dark);
/* Accent (orange) hover matches `.file-actions` (kebab) and the
favorite / shared button semantics — the pre-refactor grey-only
tint left `/recent`'s broom, `/trash`'s restore and `/search`'s
"open parent" reading as inert on hover next to the accented
kebab. Ed's 2026-07-26 UX ask: "all row buttons should change
color on hover, not just kebab and favorite." Section-specific
variants (`.btn-action--delete` in trash, `--on` in ShareDialog)
still win via more-specific selectors so red / etc. semantics
are preserved. */
color: var(--color-accent);
}
/* Opt-in modifier: hide the button until the row is hovered / focused.
Used by `/recent`'s per-row broom (a history-management action that
shouldn't distract from the row content at rest). Trash's Restore /
Delete stay on the plain `.btn-action` — those are the reason the
user opened trash, and hiding them would fail Fitts' law. */
Used by `/recent`'s per-row broom and `/search`'s open-parent
(history-management / navigational actions that shouldn't distract
from the row content at rest). Trash's Restore / Delete stay on the
plain `.btn-action` — those are the reason the user opened trash,
and hiding them would fail Fitts' law.
`opacity + pointer-events` (not `visibility`) so the fade timing
matches the kebab + star + shared button — Ed's 2026-07-26 UX note:
pre-refactor each reveal mechanism was different, producing a
staggered exit when the pointer left a row. */
.files-list-view .file-item .action-cell .btn-action--hover,
.files-grid-view .file-item .action-cell .btn-action--hover {
visibility: hidden;
transition: visibility var(--motion-fast) var(--ease-standard);
opacity: 0;
pointer-events: none;
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-list-view .file-item:hover .action-cell .btn-action--hover,
.files-list-view .file-item:focus-within .action-cell .btn-action--hover,
.files-grid-view .file-item:hover .action-cell .btn-action--hover,
.files-grid-view .file-item:focus-within .action-cell .btn-action--hover {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
+16 -8
View File
@@ -24,7 +24,7 @@
type Playlist,
type PlaylistItem
} from '$lib/api/endpoints/music';
import { searchFiles } from '$lib/api/endpoints/search';
import { searchResources } from '$lib/api/endpoints/search';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
@@ -450,17 +450,25 @@
async function runAddSearch(query = '') {
addSearching = true;
try {
const res = await searchFiles(query.trim(), {
const res = await searchResources(query.trim(), {
recursive: true,
fileTypes: AUDIO_TYPES,
resourceTypes: ['file'],
limit: 200
});
// Belt-and-braces: keep only audio mime types.
addResults = res.files.filter(
(f) =>
(f.mime_type ?? '').startsWith('audio/') ||
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
);
// Belt-and-braces: keep only audio mime types. The envelope
// items are `{resource_type, resource}` — resourceTypes:['file']
// already restricts to files, but re-narrow here so the
// downstream `FileItem[]` cast is honest even if the wire
// ordering ever surfaces a folder.
addResults = res.items
.filter((it) => it.resource_type === 'file')
.map((it) => it.resource as FileItem)
.filter(
(f) =>
(f.mime_type ?? '').startsWith('audio/') ||
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
);
} catch (e) {
errorToast(e);
addResults = [];
+620 -217
View File
@@ -1,32 +1,170 @@
<script lang="ts">
import EmptyState from '$lib/components/EmptyState.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import { errorMessage } from '$lib/utils/errors';
import ResourceList, {
isFile,
type ContextAction,
type GroupByDef
} from '$lib/components/ResourceList.svelte';
import { errorMessage, errorToast } from '$lib/utils/errors';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem, SearchResults, SortBy } from '$lib/api/types';
import { searchResources } from '$lib/api/endpoints/search';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder, getFolder, getFolderName } from '$lib/api/endpoints/folders';
import {
addFavorite,
removeFavorite,
dateBucket,
sizeBucket
} from '$lib/api/endpoints/favorites';
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
const query = $derived(page.url.searchParams.get('q') ?? '');
let results = $state<SearchResults | null>(null);
// Folder scope encoded in the URL as `?in=<uuid>` so refresh survives —
// pre-refactor the scope lived only in `filesStore.currentFolder`, which
// resets to null on a hard reload. Ed hit this 2026-07-26: refresh
// silently switched to "Everywhere" and disabled "This folder".
// `AppShell` also encodes this param when the user searches from a
// specific `/files/<uuid>`, so the round-trip is symmetric.
//
// `?in=` is *sticky* — it stays in the URL even after the user picks
// "Everywhere" — so they can toggle back to "This folder" without
// losing the reference. The active-scope flip is signalled separately
// by `?scope=all` (absent = default to folder when `in` is present).
const scopeFolderId = $derived(page.url.searchParams.get('in') ?? null);
const scopeOverride = $derived(page.url.searchParams.get('scope'));
// A concrete folder for the scope: prefer the URL param (durable across
// refresh), fall back to whatever folder the Files view has open in
// this session (the pre-URL-param behaviour).
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
// Breadcrumb — resolves the scope folder's display name so the sticky
// header can show WHICH directory the results come from ("we have no
// clue on which directory the search was done" — Ed 2026-07-26).
// `getFolderName` is a sync cache peek populated by prior /files
// listings; on a cold /search deep-link we fall back to `getFolder`
// once, cache the result, and re-render. `$state<string | null>`
// with a `$effect` primer avoids blocking the initial render.
let scopeFolderName = $state<string | null>(null);
$effect(() => {
if (!scopeFolderId) {
scopeFolderName = null;
return;
}
const cached = getFolderName(scopeFolderId);
if (cached) {
scopeFolderName = cached;
return;
}
// Cold deep-link — fire once, populate on resolve. If it fails
// (folder was deleted, caller lost Read), keep name null so the
// breadcrumb just falls back to a short UUID.
const id = scopeFolderId;
void getFolder(id)
.then((f) => {
if (scopeFolderId === id) scopeFolderName = f.name;
})
.catch(() => {
if (scopeFolderId === id) scopeFolderName = id.slice(0, 8);
});
});
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
// query time / result count into the title string because ResourceList
// doesn't (yet) expose a subtitle slot, and lifting the "Xms" affordance
// into the title is small enough not to warrant one. The bare "Search"
// label is the empty-query fallback for the browser-tab title path
// (`<svelte:head>`); when there IS a query, ResourceList never mounts
// with this string — the `else` branch below owns the render.
const resultsTitle = $derived.by(() => {
// No query yet — the fresh-page tab title.
if (!query) return t('search.title', 'Search');
// Still loading the first response — show just the "Results for X"
// half. `queryTimeMs` becomes non-null the moment the backend answers.
if (queryTimeMs == null) return t('search.results_for', { q: query }, 'Results for “{{q}}”');
// Full summary keys concatenate the whole title in one string so
// translators can rearrange the `·` join, punctuation, and word
// order (Ed's 2026-07-26 i18n pass — pre-refactor the JS
// concatenated the "Results for" head and "N results in Xms" tail
// with a hard-coded ` · ` separator, which is awkward for RTL
// locales and CJK spacing).
if (total != null) {
return t(
'search.results_summary',
{ q: query, n: total, ms: queryTimeMs },
'Results for “{{q}}” · {{n}} results in {{ms}} ms'
);
}
return t(
'search.results_summary_no_total',
{ q: query, ms: queryTimeMs },
'Results for “{{q}}” · {{ms}} ms'
);
});
// Accumulated pages of results. Cursor pagination — a filter/sort/query
// change resets to page 1, infinite scroll appends via `loadMore()`.
let raw = $state<SearchResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let queryTimeMs = $state<number | null>(null);
let total = $state<number | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let sortBy = $state<SortBy>('relevance');
// Scope: search everywhere, or within the folder last open in the files view.
// Default to the current folder when one is set (and we're not in the trash
// section), mirroring the legacy searchView behaviour.
let scope = $state<'all' | 'folder'>(
filesStore.currentFolder && filesStore.section !== 'trash' ? 'folder' : 'all'
// Sort dimension + direction are surfaced through ResourceList's
// built-in group-by selector (DisplayModeControls) rather than a
// standalone sort `<select>`, so /search matches /favorites /
// /recent / /trash. `groupBy` = active group-by key from the
// `groupBys` list below; `reversed` = the asc/desc toggle.
let groupBy = $state('');
let reversed = $state(false);
// Derived from the URL — URL is the single source of truth so refresh,
// bookmarks and shared links all restore the same scope. Rules:
// `?scope=all` → 'all' (explicit "Everywhere" toggle;
// `in=` may still be present as
// a sticky fallback for the
// "This folder" button)
// `?in=<uuid>` (no override) → 'folder'
// neither → 'all'
const scope = $derived<'all' | 'folder'>(
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
);
function setScope(next: 'all' | 'folder') {
// Build the query string by hand — Svelte's lint flags mutating a
// stdlib `URLSearchParams`, and we don't need reactivity here.
//
// Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved
// even when switching to "Everywhere" so "This folder" stays
// clickable and remembers WHICH folder. The active-scope flip
// rides on `scope=all` instead.
const parts: string[] = [];
if (query) parts.push(`q=${encodeURIComponent(query)}`);
// Sticky `in=`: keep whatever's already in the URL, or seed it
// from filesStore when the user first pins "This folder" from a
// fresh /search visit.
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
if (stickyFolder) {
parts.push(`in=${encodeURIComponent(stickyFolder)}`);
}
if (next === 'all' && stickyFolder) {
// Only meaningful when there's a folder to override — otherwise
// the URL is "everywhere by default" and the flag would be noise.
parts.push('scope=all');
}
const target = resolve(parts.length ? `/search?${parts.join('&')}` : '/search');
// `replaceState: true` keeps the browser back-button meaningful —
// scope changes are UI state, not navigation. `keepFocus: true`
// keeps focus on whatever button the user just clicked.
void goto(target, { replaceState: true, keepFocus: true, noScroll: true });
}
// Filters
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
@@ -115,31 +253,90 @@
dateFilter = 'all';
}
const SORTS: { v: SortBy; l: string }[] = [
{ v: 'relevance', l: t('search.sort.relevance', 'Relevance') },
{ v: 'name', l: t('search.sort.name_asc', 'Name A-Z') },
{ v: 'name_desc', l: t('search.sort.name_desc', 'Name Z-A') },
{ v: 'date_desc', l: t('search.sort.newest', 'Newest') },
{ v: 'date', l: t('search.sort.oldest', 'Oldest') },
{ v: 'size_desc', l: t('search.sort.largest', 'Largest') },
{ v: 'size', l: t('search.sort.smallest', 'Smallest') }
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
// Ed's 2026-07-26 spec: 4 options total —
// • Relevance (default, flat) — search's native ranking
// • Name (flat) — A-Z with the asc/desc toggle for Z-A
// • Size (grouped) — bucketed via the shared `sizeBucket`
// • Modified (grouped) — bucketed via the shared `dateBucket`
// Omitting `bucketOf` = flat list (see the ResourceList interface).
// `orderBy` values map 1:1 to the backend `SearchResourcesQuery.order_by`.
// The asc/desc button binds to `reversed` and passes through to the
// backend `reverse` flag.
const groupBys: GroupByDef[] = [
{
key: '',
label: t('search.sort.relevance', 'Relevance'),
orderBy: 'relevance',
icon: 'ranking-star'
},
{ key: 'name', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (item) => sizeBucket(isFile(item) ? item.size : null)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'updated_at',
bucketOf: (item) => dateBucket(item.modified_at)
},
{
key: 'createdAt',
label: t('groupby.createdAt', 'Created date'),
orderBy: 'created_at',
bucketOf: (item) => dateBucket(item.created_at)
}
];
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'relevance';
}
// Stale-response guard: rapid-fire query/filter/sort changes each start a
// full recursive backend search; without the token a SLOW earlier response
// could resolve after (and clobber) a newer one, and the superseded server
// work ran to completion. The seq token keeps only the latest result; the
// AbortController cancels the superseded request outright.
// AbortController cancels the superseded request outright. The same token
// invalidates any in-flight `loadMore()` when the query/filter changes so
// its rows never append to a fresh result set.
let runSeq = 0;
let inflight: AbortController | null = null;
function currentQueryParams() {
// Trash section searches are always global — there is no folder to
// scope to. Otherwise the folder comes from the URL (`?in=<uuid>`)
// via `scopeFolderId`, which survives a hard refresh and shared
// links unlike `filesStore.currentFolder` (session-only, resets on
// reload).
const folderId =
scope === 'folder' && filesStore.section !== 'trash'
? (effectiveFolder ?? undefined)
: undefined;
return {
recursive: true,
sortBy: orderByForGroup() as SortBy,
reverse: reversed,
folderId,
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
};
}
async function run(q: string) {
const seq = ++runSeq;
inflight?.abort();
inflight = null;
if (!q) {
results = null;
raw = [];
cursor = undefined;
queryTimeMs = null;
total = undefined;
loading = false;
error = null;
return;
}
const ctl = new AbortController();
@@ -147,22 +344,12 @@
loading = true;
error = null;
try {
// Trash section searches are always global — there is no folder to scope to.
const folderId =
scope === 'folder' && filesStore.section !== 'trash'
? (filesStore.currentFolder ?? undefined)
: undefined;
const fresh = await searchFiles(q, {
recursive: true,
sortBy,
folderId,
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter),
signal: ctl.signal
});
const fresh = await searchResources(q, { ...currentQueryParams(), signal: ctl.signal });
if (seq !== runSeq) return; // superseded while awaiting
results = fresh;
raw = fresh.items;
cursor = fresh.next_cursor;
queryTimeMs = fresh.query_time_ms;
total = fresh.total;
} catch (e) {
// An aborted request is not an error — a newer run owns the UI.
if (seq !== runSeq || ctl.signal.aborted) return;
@@ -172,31 +359,225 @@
}
}
function openFolder(folder: FolderItem) {
goto(resolve(`/files/${folder.id}`));
async function loadMore() {
if (!cursor || loading) return;
// Snapshot the current seq — if a filter/sort/query change bumps
// `runSeq` while we're awaiting, we drop this page on the floor
// (its rows belong to a stale filter set).
const seq = runSeq;
const ctl = new AbortController();
// Don't overwrite `inflight` — that belongs to `run()` and lets a
// query change abort a page-1 fetch. A concurrent loadMore is
// harmless: at most one appends because of the seq guard.
loading = true;
try {
const nextPage = await searchResources(query, {
...currentQueryParams(),
cursor,
signal: ctl.signal
});
if (seq !== runSeq) return;
raw = [...raw, ...nextPage.items];
cursor = nextPage.next_cursor;
// total/query_time refresh — the server recomputes both per page.
total = nextPage.total ?? total;
} catch (e) {
if (seq !== runSeq || ctl.signal.aborted) return;
error = errorMessage(e);
} finally {
if (seq === runSeq) loading = false;
}
}
function openFile(file: FileItem) {
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
// Feed ResourceList the raw resource objects — that's the shared
// `{FileItem | FolderItem}[]` shape every other /*/resources page
// uses. Search-specific meta (score/snippet/via) is not surfaced
// today; a follow-up commit will extend ResourceList with an
// optional per-row meta slot for the snippet + a "matched: content"
// chip. Score isn't worth surfacing to end users.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
// ── Per-row actions ──────────────────────────────────────────────────
// Match the shape of `/favorites` + `/recent`: files open inline in
// the shared FileViewer (was new-tab pre-refactor — a discontinuity
// with the rest of the app), folders navigate. Share + move + delete
// all reuse the same lazy dialogs.
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte'));
const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.svelte'));
$effect(() => {
if (viewerOpen) void fileViewer.load();
if (moveOpen) void moveDialog.load();
if (shareOpen) void shareDialog.load();
});
function kindOf(item: FileItem | FolderItem): 'file' | 'folder' {
return isFile(item) ? 'file' : 'folder';
}
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
function open(item: FileItem | FolderItem) {
if (!isFile(item)) {
goto(resolve(`/files/${item.id}`));
return;
}
viewerFile = item;
viewerOpen = true;
}
// Flatten folders + files into one list so the results render through a single
// windowed list (only the visible rows hit the DOM, even for 100s of hits).
type SearchEntry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
const entries = $derived<SearchEntry[]>(
results
? [
...results.folders.map((folder) => ({ kind: 'folder' as const, folder })),
...results.files.map((file) => ({ kind: 'file' as const, file }))
]
: []
);
// Files carry `folder_id`, folders carry `parent_id`; both are nullable
// at drive roots. Null → no meaningful parent to open. Mirrors the
// same helper in /favorites and /recent.
function parentFolderId(item: FileItem | FolderItem): string | null {
return isFile(item) ? item.folder_id : item.parent_id;
}
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = kindOf(item);
try {
if (item.is_favorite) {
await removeFavorite(kind, item.id);
} else {
await addFavorite(kind, item.id);
}
// Optimistic in-place update — a full reload would jump the
// user out of their scroll position on an infinite-scroll
// page. Mutating the raw envelope entry updates the derived
// `items` array and ResourceList's star widget flips.
const idx = raw.findIndex((it) => it.resource.id === item.id);
if (idx !== -1) {
raw[idx] = {
...raw[idx],
resource: { ...raw[idx].resource, is_favorite: !item.is_favorite }
};
}
} catch (e) {
errorToast(e);
}
}
function openShareDialog(item: FileItem | FolderItem) {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}
function openMoveDialog(item: FileItem | FolderItem) {
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) };
moveOpen = true;
}
function downloadItem(item: FileItem | FolderItem) {
if (!isFile(item)) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(item.id);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function rename(item: FileItem | FolderItem) {
const name = await promptDialog({
title: t('common.rename', 'Rename'),
defaultValue: item.name,
confirmText: t('common.rename', 'Rename')
});
if (!name || name === item.name) return;
try {
if (isFile(item)) await renameFile(item.id, name);
else await renameFolder(item.id, name);
// Update the row in place so the user keeps their scroll
// position instead of jumping back to page 1.
const idx = raw.findIndex((it) => it.resource.id === item.id);
if (idx !== -1) {
raw[idx] = { ...raw[idx], resource: { ...raw[idx].resource, name } };
}
} catch (e) {
errorToast(e);
}
}
async function remove(item: FileItem | FolderItem) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete', { name: item.name }, 'Delete "{{name}}"?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
if (isFile(item)) await deleteFile(item.id);
else await deleteFolder(item.id);
raw = raw.filter((it) => it.resource.id !== item.id);
} catch (e) {
errorToast(e);
}
}
function openParent(item: FileItem | FolderItem) {
const pid = parentFolderId(item);
if (pid) goto(resolve(`/files/${pid}`));
}
// Canonical context-menu order — matches `/favorites` and `/files`
// (Open parent, Download, Share, Move, Rename, Delete) so users
// don't have to relearn the menu when switching sections.
const contextActions: ContextAction[] = [
{
key: 'open_parent',
label: t('files.open_parent', 'Open parent folder'),
icon: 'folder-open',
// Hidden only when there is literally no parent (drive-root
// folders where `parent_id === null`). Otherwise stay visible
// and disable when the caller lacks Read on the parent — a
// grey entry reads as "you can't do this here" rather than
// "the option is missing." `folderAccessCached` returns
// true/false/undefined; disable only on explicit `false`.
visible: (item) => parentFolderId(item) !== null,
disabled: (item) => {
const pid = parentFolderId(item);
return pid === null || folderAccessCached(pid) === false;
},
run: openParent
},
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
visible: isFile,
run: downloadItem
},
{
key: 'share',
label: t('files.share', 'Share'),
icon: 'share-alt',
run: openShareDialog
},
{
key: 'move',
label: t('files.move', 'Move'),
icon: 'arrows-alt',
run: openMoveDialog
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{
key: 'delete',
label: t('common.delete', 'Delete'),
icon: 'trash',
danger: true,
run: remove
}
];
$effect(() => {
// re-run when query, sort, scope, or any filter changes
void sortBy;
// re-run when query, sort/direction, scope, or any filter changes
void groupBy;
void reversed;
void scope;
void typeFilter;
void sizeFilter;
@@ -211,13 +592,12 @@
* browser's default handler navigates to the dropped file — the tab
* REPLACES the app with the file itself, which is data-loss-esque
* (user loses their in-progress search + any unsaved UI state).
* The `ResourceList`-based views (`/photos`, `/shared`, `/trash`, …)
* already fire a "wrong drop zone" toast via their `.rl-root`
* wrapper when `enableSystemDrop` is false — this handler brings
* `/search` to the same contract, and covers the whole viewport
* (not just a list surface) so drops on the sticky header /
* result-card margins are caught too. Same toast copy + "Go to
* Files" action as `ResourceList` for a consistent recovery UX.
* ResourceList already fires a "wrong drop zone" toast via its
* `.rl-root` wrapper when `enableSystemDrop` is false, but that
* covers only the list area — this handler covers the whole
* viewport (sticky header, empty-state screen, gaps around the
* list). Same toast copy + "Go to Files" action as ResourceList
* for a consistent recovery UX.
*/
function onWindowDragOver(e: DragEvent) {
if (!e.dataTransfer?.types?.includes('Files')) return;
@@ -258,38 +638,80 @@
<svelte:window ondragover={onWindowDragOver} ondrop={onWindowDrop} />
<div class="page-sticky-header search-head">
<h1 class="page-title">
{#if query}{t('search.results_for', { q: query }, 'Results for “{{q}}”')}{:else}{t(
'search.title',
'Search'
)}{/if}
{#if results?.query_time_ms != null}
<span class="search-time">({results.query_time_ms} ms)</span>
{/if}
</h1>
{#if query}
<div class="search-controls">
{#if filesStore.currentFolder}
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button
class="seg__btn"
class:active={scope === 'all'}
data-testid="search-scope-all-btn"
onclick={() => (scope = 'all')}
>
{t('search.everywhere', 'Everywhere')}
</button>
<button
class="seg__btn"
class:active={scope === 'folder'}
data-testid="search-scope-folder-btn"
onclick={() => (scope = 'folder')}
>
{t('search.this_folder', 'This folder')}
</button>
</div>
{/if}
{#if !query}
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
{:else}
<ResourceList
title={resultsTitle}
{items}
{loading}
{error}
emptyIcon="search"
emptyText={t('search.no_results', 'No results found for this search')}
hasMore={!!cursor}
onloadmore={loadMore}
showPath
showViewToggle
onopen={open}
onfavorite={toggleFavorite}
onshared={openShareDialog}
{contextActions}
{groupBys}
bind:groupBy
bind:reversed
onreload={() => {
// Group-by or asc/desc changed — reset pagination and let the
// `$effect` above pick up the new state on its next tick (it's
// already reactive on `groupBy` + `reversed`). Explicit
// `cursor = undefined` here just guarantees the in-flight
// `next_cursor` from the OLD sort can't feed a stale page 2.
cursor = undefined;
}}
menuPrepare={async (item) => {
// Lazy folder-access probe — fires only when the user opens
// the context menu on a row, not proactively for every row on
// load. Cached in the LRU (see `folderAccess.ts`) so
// subsequent right-clicks on the same folder are instant.
// Mirrors /favorites + /recent so the "Open parent folder"
// entry lands enabled/disabled without a "flash of enabled"
// on first right-click.
const pid = parentFolderId(item);
if (pid) await probeFolderAccess(pid);
}}
>
{#snippet actions()}
<!--
Scope segment is always visible so the affordance is
discoverable even from a cold `/search?q=…` load; when
there's no `filesStore.currentFolder` (user typed the URL
directly, or search bar navigation didn't carry a folder),
"This folder" disables — clicking it wouldn't have a
folder to scope to. Pre-refactor the whole segment was
hidden in that case, which read as "the option was
removed" (2026-07-26 UX feedback).
-->
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button
class="seg__btn"
class:active={scope === 'all'}
data-testid="search-scope-all-btn"
onclick={() => setScope('all')}
>
{t('search.everywhere', 'Everywhere')}
</button>
<button
class="seg__btn"
class:active={scope === 'folder'}
data-testid="search-scope-folder-btn"
disabled={!effectiveFolder}
title={effectiveFolder
? undefined
: t('search.this_folder_disabled', 'Open a folder in Files to scope search to it')}
onclick={() => setScope('folder')}
>
{t('search.this_folder', 'This folder')}
</button>
</div>
<select
class="sort-select"
bind:value={typeFilter}
@@ -317,114 +739,117 @@
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={sortBy}
aria-label={t('search.sort_by', 'Sort by')}
data-testid="search-sort-select"
>
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
>{/each}
</select>
<!--
NOTE: sort dimension + asc/desc live in ResourceList's
built-in DisplayModeControls now (fed by `groupBys` +
`bind:groupBy` + `bind:reversed` below), matching
/favorites / /recent / /trash. The old
`<select bind:value={sortBy}>` was removed with the
`SORTS` array.
-->
{#if hasFilters}
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
</div>
{/if}
</div>
{/snippet}
{#snippet breadcrumb()}
<!--
Only render when the search is folder-scoped AND the URL
param is present — the sticky "Home > Photos" cue answers
the "which directory was this search done in?" question
Ed raised 2026-07-26. Hidden for scope='all' (searching
everywhere → no folder to breadcrumb) and for a fresh
`/search?q=…` with no `in=` param.
{#if loading}
<div class="search-loading">
<Icon name="spinner" class="search-loading__spinner" />
<h2 class="search-loading__text">
{t('search.searching_for', { q: query }, 'Searching for “{{q}}”…')}
</h2>
</div>
{:else if error}
<EmptyState title={error} error />
{:else if !query}
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
{:else if isEmpty}
<EmptyState icon="search" title={t('search.no_results', 'No results found for this search')} />
{:else if results}
<div class="files-container">
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
<div class="list-header">
<div>{t('files.col_name', 'Name')}</div>
<div>{t('files.col_path', 'Path')}</div>
<div>{t('files.col_size', 'Size')}</div>
<div>{t('files.col_modified', 'Modified')}</div>
</div>
Single-segment for now (Home icon + scope folder as a
link). Full parent-chain walk is a follow-up; it needs
stepping through `parent_id` via `getFolder`, which
would be a second pass here.
-->
{#if scope === 'folder' && scopeFolderId}
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
data-testid="search-breadcrumb-home-link"
>
<Icon name="home" />
</a>
<span class="breadcrumb-separator">&gt;</span>
<a
href={resolve(`/files/${scopeFolderId}`)}
class="breadcrumb-item breadcrumb-current breadcrumb-link"
data-testid="search-breadcrumb-folder-link"
>
{scopeFolderName ?? '…'}
</a>
</nav>
{/if}
{/snippet}
{#snippet itemActions(item)}
<!--
Per-row "Open parent folder" quick-action — search results
are context-poor by nature (the path column shows WHERE the
match is, but jumping there takes an extra right-click on
every other section). Surface it as a direct button so a
single click navigates. Same `.btn-action` treatment as
trash's Restore / Delete and recent's broom, so the row's
action-cell keeps the visual rhythm shared across sections.
Hidden when there's no meaningful parent (drive-root
folders where `parent_id === null`).
-->
{#if parentFolderId(item) !== null}
<button
class="btn-action btn-action--hover"
data-testid={`search-open-parent-btn-${item.id}`}
title={t('files.open_parent', 'Open parent folder')}
aria-label={t('files.open_parent', 'Open parent folder')}
onclick={(e) => {
e.stopPropagation();
openParent(item);
}}
>
<Icon name="folder-open" />
</button>
{/if}
{/snippet}
</ResourceList>
{/if}
<VirtualList
items={entries}
rowHeight={56}
key={(e) => (e.kind === 'folder' ? e.folder.id : e.file.id)}
>
{#snippet row(e)}
{#if e.kind === 'folder'}
<div
class="file-item"
role="button"
tabindex="0"
aria-label={e.folder.name}
data-testid={e.folder.name}
onclick={() => openFolder(e.folder)}
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
>
<div class="name-cell">
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
<span>{e.folder.name}</span>
</div>
<div class="path-cell">{e.folder.path}</div>
<div class="size-cell">—</div>
<div class="date-cell">{formatDate(e.folder.modified_at)}</div>
</div>
{:else}
<div
class="file-item"
role="button"
tabindex="0"
aria-label={e.file.name}
data-testid={e.file.name}
onclick={() => openFile(e.file)}
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
>
<div class="name-cell">
<span class="file-icon {fileIconKindClass(iconNameFromClass(e.file.icon_class))}"
><Icon name={iconNameFromClass(e.file.icon_class)} /></span
>
<span>{e.file.name}</span>
</div>
<div class="path-cell">{e.file.path}</div>
<div class="size-cell">{e.file.size != null ? formatBytes(e.file.size) : ''}</div>
<div class="date-cell">{formatDate(e.file.modified_at)}</div>
</div>
{/if}
{/snippet}
</VirtualList>
</div>
</div>
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}
{#if moveDialog.component}
{@const MoveDialog = moveDialog.component}
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
onmoved={() => {
// A move can shift the row out of the current scope (`?in=<uuid>`)
// or into it, and the SQL name-match count may change. Reload
// page 1 rather than trying to patch state in place — search
// state is already reactive on query/scope so a fresh `run()`
// is cheap and correct.
void run(query);
}}
/>
{/if}
{#if shareDialog.component}
{@const ShareDialog = shareDialog.component}
<ShareDialog bind:open={shareOpen} item={shareTarget} />
{/if}
<style>
.search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.search-controls {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Filter cluster lives inside ResourceList's action-bar snippet now,
but the actual DOM is scoped to THIS component's <style> block —
Svelte's scoped selectors still apply because these are declared
with the elements they style below.
Every color/border here uses tokens; no raw values (Stylelint gate). */
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
@@ -453,10 +878,9 @@
color: var(--color-on-accent);
}
.search-time {
font-size: var(--text-sm);
font-weight: var(--weight-normal);
color: var(--color-text-muted);
.seg__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clear-filters {
@@ -474,25 +898,4 @@
.clear-filters:hover {
background: var(--color-bg-hover);
}
.search-loading {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) 0;
color: var(--color-text-muted);
}
.search-loading :global(.search-loading__spinner) {
font-size: var(--text-xl);
color: var(--color-accent);
animation: spin var(--spin-duration) linear infinite;
}
.search-loading__text {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
}
</style>
+8 -8
View File
@@ -7,10 +7,10 @@ const { goto, pageState } = vi.hoisted(() => ({
}));
vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/state', () => ({ page: pageState }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
import { searchFiles } from '$lib/api/endpoints/search';
import { searchResources } from '$lib/api/endpoints/search';
import SearchPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
@@ -18,13 +18,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
pageState.url = new URL('http://localhost/search?q=report');
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 0, total: 0 });
});
it('runs a search from the q query parameter on mount', async () => {
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][0]).toBe('report');
});
it('does not search when there is no query', async () => {
@@ -32,12 +32,12 @@ it('does not search when there is no query', async () => {
render(SearchPage);
// Give the reactive effect a tick to (not) fire.
await Promise.resolve();
expect(searchFiles).not.toHaveBeenCalled();
expect(searchResources).not.toHaveBeenCalled();
});
it('surfaces a search error', async () => {
m(searchFiles).mockRejectedValue(new Error('search boom'));
m(searchResources).mockRejectedValue(new Error('search boom'));
render(SearchPage);
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
await waitFor(() => expect(searchResources).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
});
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "أنشئ بواسطة",
"col_opened": "افتُح",
"col_path": "الموقع",
"new_elements": "عناصر جديدة"
"new_elements": "عناصر جديدة",
"open_parent": "فتح المجلد الأصلي",
"move": "نقل",
"favorite": "إضافة إلى المفضلة",
"unfavorite": "إزالة من المفضلة",
"open": "فتح",
"download_zip": "تنزيل كملف مضغوط"
},
"dialogs": {
"rename_folder": "إعادة تسمية المجلد",
@@ -1197,9 +1203,43 @@
"size_label": "الحجم",
"title": "بحث",
"type": {
"audio": "صوت"
"audio": "صوت",
"all": "جميع الأنواع",
"image": "الصور",
"video": "مقاطع الفيديو",
"document": "المستندات",
"archive": "الأرشيفات"
},
"type_label": "النوع"
"type_label": "النوع",
"sort": {
"relevance": "الصلة"
},
"results_for": "نتائج البحث عن \"{{q}}\"",
"results_summary": "نتائج البحث عن \"{{q}}\" · {{n}} نتيجة في {{ms}} مللي ثانية",
"results_summary_no_total": "نتائج البحث عن \"{{q}}\" · {{ms}} مللي ثانية",
"everywhere": "في كل مكان",
"this_folder": "هذا المجلد",
"this_folder_disabled": "افتح مجلدًا في الملفات لقصر البحث عليه",
"scope": "النطاق",
"date_label": "التاريخ",
"sort_by": "الترتيب حسب",
"clear_filters": "مسح الفلاتر",
"searching_for": "البحث عن \"{{q}}\"…",
"no_results": "لم يتم العثور على نتائج لهذا البحث",
"prompt": "اكتب استعلامًا في شريط البحث أعلاه.",
"size": {
"all": "أي حجم",
"small": "< 1 ميغابايت",
"medium": "1–100 ميغابايت",
"large": "> 100 ميغابايت"
},
"date": {
"all": "في أي وقت",
"day": "آخر 24 ساعة",
"week": "الأسبوع الماضي",
"month": "الشهر الماضي",
"year": "العام الماضي"
}
},
"sizeBucket": {
"folders": "مجلدات"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Erstellt von",
"col_opened": "Geöffnet",
"col_path": "Speicherort",
"new_elements": "Neue Elemente"
"new_elements": "Neue Elemente",
"open_parent": "Übergeordneten Ordner öffnen",
"move": "Verschieben",
"favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen",
"open": "Öffnen",
"download_zip": "Als ZIP herunterladen"
},
"dialogs": {
"rename_folder": "Ordner umbenennen",
@@ -1197,9 +1203,43 @@
"size_label": "Größe",
"title": "Suchen",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Alle Typen",
"image": "Bilder",
"video": "Videos",
"document": "Dokumente",
"archive": "Archive"
},
"type_label": "Typ"
"type_label": "Typ",
"sort": {
"relevance": "Relevanz"
},
"results_for": "Ergebnisse für „{{q}}\"",
"results_summary": "Ergebnisse für „{{q}}\" · {{n}} Ergebnisse in {{ms}} ms",
"results_summary_no_total": "Ergebnisse für „{{q}}\" · {{ms}} ms",
"everywhere": "Überall",
"this_folder": "Dieser Ordner",
"this_folder_disabled": "Öffnen Sie einen Ordner in Dateien, um die Suche darauf zu beschränken",
"scope": "Bereich",
"date_label": "Datum",
"sort_by": "Sortieren nach",
"clear_filters": "Filter zurücksetzen",
"searching_for": "Suche nach „{{q}}\"…",
"no_results": "Keine Ergebnisse für diese Suche gefunden",
"prompt": "Geben Sie oben eine Suchanfrage ein.",
"size": {
"all": "Beliebige Größe",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "Beliebiger Zeitraum",
"day": "Letzte 24 Stunden",
"week": "Letzte Woche",
"month": "Letzter Monat",
"year": "Letztes Jahr"
}
},
"sizeBucket": {
"folders": "Ordner"
+4 -1
View File
@@ -1576,7 +1576,10 @@
"image": "Images",
"video": "Videos"
},
"type_label": "Type"
"type_label": "Type",
"results_summary": "Results for “{{q}}” · {{n}} results in {{ms}} ms",
"results_summary_no_total": "Results for “{{q}}” · {{ms}} ms",
"this_folder_disabled": "Open a folder in Files to scope search to it"
},
"settings": {
"language": "Language"
+43 -3
View File
@@ -389,7 +389,13 @@
"col_created_by": "Creado por",
"col_opened": "Abierto",
"col_path": "Ubicación",
"new_elements": "Nuevos elementos"
"new_elements": "Nuevos elementos",
"open_parent": "Abrir carpeta principal",
"move": "Mover",
"favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos",
"open": "Abrir",
"download_zip": "Descargar como ZIP"
},
"dialogs": {
"rename_folder": "Renombrar carpeta",
@@ -1212,9 +1218,43 @@
"size_label": "Tamaño",
"title": "Buscar",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Todos los tipos",
"image": "Imágenes",
"video": "Vídeos",
"document": "Documentos",
"archive": "Archivos"
},
"type_label": "Tipo"
"type_label": "Tipo",
"sort": {
"relevance": "Relevancia"
},
"results_for": "Resultados para \"{{q}}\"",
"results_summary": "Resultados para \"{{q}}\" · {{n}} resultados en {{ms}} ms",
"results_summary_no_total": "Resultados para \"{{q}}\" · {{ms}} ms",
"everywhere": "En cualquier lugar",
"this_folder": "Esta carpeta",
"this_folder_disabled": "Abre una carpeta en Archivos para limitar la búsqueda a ella",
"scope": "Ámbito",
"date_label": "Fecha",
"sort_by": "Ordenar por",
"clear_filters": "Borrar filtros",
"searching_for": "Buscando \"{{q}}\"…",
"no_results": "No se encontraron resultados para esta búsqueda",
"prompt": "Escribe una consulta en la barra de búsqueda superior.",
"size": {
"all": "Cualquier tamaño",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "Cualquier momento",
"day": "Últimas 24 horas",
"week": "Última semana",
"month": "Último mes",
"year": "Último año"
}
},
"sizeBucket": {
"folders": "Carpetas"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "ایجاد شده توسط",
"col_opened": "باز شده",
"col_path": "مکان",
"new_elements": "موارد جدید"
"new_elements": "موارد جدید",
"open_parent": "باز کردن پوشه والد",
"move": "انتقال",
"favorite": "افزودن به علاقه‌مندی‌ها",
"unfavorite": "حذف از علاقه‌مندی‌ها",
"open": "باز کردن",
"download_zip": "دانلود به صورت ZIP"
},
"dialogs": {
"rename_folder": "تغییر نام پوشه",
@@ -1197,9 +1203,43 @@
"size_label": "اندازه",
"title": "جست‌و‌جو",
"type": {
"audio": "صوتی"
"audio": "صوتی",
"all": "همه انواع",
"image": "تصاویر",
"video": "ویدیوها",
"document": "اسناد",
"archive": "بایگانی‌ها"
},
"type_label": "نوع"
"type_label": "نوع",
"sort": {
"relevance": "مرتبط بودن"
},
"results_for": "نتایج جستجو برای «{{q}}»",
"results_summary": "نتایج جستجو برای «{{q}}» · {{n}} نتیجه در {{ms}} میلی‌ثانیه",
"results_summary_no_total": "نتایج جستجو برای «{{q}}» · {{ms}} میلی‌ثانیه",
"everywhere": "همه جا",
"this_folder": "این پوشه",
"this_folder_disabled": "پوشه‌ای را در پرونده‌ها باز کنید تا جستجو به آن محدود شود",
"scope": "محدوده",
"date_label": "تاریخ",
"sort_by": "مرتب‌سازی بر اساس",
"clear_filters": "پاک کردن فیلترها",
"searching_for": "در حال جستجوی «{{q}}»…",
"no_results": "نتیجه‌ای برای این جستجو یافت نشد",
"prompt": "در نوار جستجوی بالا، عبارتی را وارد کنید.",
"size": {
"all": "هر اندازه",
"small": "< 1 مگابایت",
"medium": "1–100 مگابایت",
"large": "> 100 مگابایت"
},
"date": {
"all": "هر زمان",
"day": "24 ساعت گذشته",
"week": "هفته گذشته",
"month": "ماه گذشته",
"year": "سال گذشته"
}
},
"sizeBucket": {
"folders": "پوشه‌ها"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Créé par",
"col_opened": "Ouvert",
"col_path": "Emplacement",
"new_elements": "Nouveaux éléments"
"new_elements": "Nouveaux éléments",
"open_parent": "Ouvrir le dossier parent",
"move": "Déplacer",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris",
"open": "Ouvrir",
"download_zip": "Télécharger en ZIP"
},
"dialogs": {
"rename_folder": "Renommer le dossier",
@@ -1197,9 +1203,43 @@
"size_label": "Taille",
"title": "Rechercher",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Tous les types",
"image": "Images",
"video": "Vidéos",
"document": "Documents",
"archive": "Archives"
},
"type_label": "Type"
"type_label": "Type",
"sort": {
"relevance": "Pertinence"
},
"results_for": "Résultats pour « {{q}} »",
"results_summary": "Résultats pour « {{q}} » · {{n}} résultats en {{ms}} ms",
"results_summary_no_total": "Résultats pour « {{q}} » · {{ms}} ms",
"everywhere": "Partout",
"this_folder": "Ce dossier",
"this_folder_disabled": "Ouvrez un dossier dans Fichiers pour y limiter la recherche",
"scope": "Portée",
"date_label": "Date",
"sort_by": "Trier par",
"clear_filters": "Effacer les filtres",
"searching_for": "Recherche de « {{q}} »…",
"no_results": "Aucun résultat pour cette recherche",
"prompt": "Saisissez une requête dans la barre de recherche ci-dessus.",
"size": {
"all": "Toute taille",
"small": "< 1 Mo",
"medium": "1–100 Mo",
"large": "> 100 Mo"
},
"date": {
"all": "N'importe quand",
"day": "Dernières 24 heures",
"week": "Semaine dernière",
"month": "Mois dernier",
"year": "Année dernière"
}
},
"sizeBucket": {
"folders": "Dossiers"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "द्वारा बनाया गया",
"col_opened": "खोला गया",
"col_path": "स्थान",
"new_elements": "नए तत्व"
"new_elements": "नए तत्व",
"open_parent": "मूल फ़ोल्डर खोलें",
"move": "स्थानांतरित करें",
"favorite": "पसंदीदा में जोड़ें",
"unfavorite": "पसंदीदा से हटाएं",
"open": "खोलें",
"download_zip": "ZIP के रूप में डाउनलोड करें"
},
"dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें",
@@ -1197,9 +1203,43 @@
"size_label": "आकार",
"title": "खोजें",
"type": {
"audio": "ऑडियो"
"audio": "ऑडियो",
"all": "सभी प्रकार",
"image": "छवियाँ",
"video": "वीडियो",
"document": "दस्तावेज़",
"archive": "संग्रह"
},
"type_label": "प्रकार"
"type_label": "प्रकार",
"sort": {
"relevance": "प्रासंगिकता"
},
"results_for": "\"{{q}}\" के लिए परिणाम",
"results_summary": "\"{{q}}\" के लिए परिणाम · {{n}} परिणाम {{ms}} ms में",
"results_summary_no_total": "\"{{q}}\" के लिए परिणाम · {{ms}} ms",
"everywhere": "कहीं भी",
"this_folder": "यह फ़ोल्डर",
"this_folder_disabled": "खोज को इसमें सीमित करने के लिए फ़ाइलें में एक फ़ोल्डर खोलें",
"scope": "दायरा",
"date_label": "दिनांक",
"sort_by": "क्रमबद्ध करें",
"clear_filters": "फ़िल्टर साफ़ करें",
"searching_for": "\"{{q}}\" खोजा जा रहा है…",
"no_results": "इस खोज के लिए कोई परिणाम नहीं मिला",
"prompt": "ऊपर सर्च बार में एक क्वेरी टाइप करें।",
"size": {
"all": "कोई भी आकार",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "कोई भी समय",
"day": "पिछले 24 घंटे",
"week": "पिछला सप्ताह",
"month": "पिछला महीना",
"year": "पिछला वर्ष"
}
},
"sizeBucket": {
"folders": "फ़ोल्डर"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Creato da",
"col_opened": "Aperto",
"col_path": "Posizione",
"new_elements": "Nuovi elementi"
"new_elements": "Nuovi elementi",
"open_parent": "Apri cartella principale",
"move": "Sposta",
"favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti",
"open": "Apri",
"download_zip": "Scarica come ZIP"
},
"dialogs": {
"rename_folder": "Rinomina cartella",
@@ -1197,9 +1203,43 @@
"size_label": "Dimensione",
"title": "Cerca",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Tutti i tipi",
"image": "Immagini",
"video": "Video",
"document": "Documenti",
"archive": "Archivi"
},
"type_label": "Tipo"
"type_label": "Tipo",
"sort": {
"relevance": "Rilevanza"
},
"results_for": "Risultati per \"{{q}}\"",
"results_summary": "Risultati per \"{{q}}\" · {{n}} risultati in {{ms}} ms",
"results_summary_no_total": "Risultati per \"{{q}}\" · {{ms}} ms",
"everywhere": "Ovunque",
"this_folder": "Questa cartella",
"this_folder_disabled": "Apri una cartella in File per limitare la ricerca ad essa",
"scope": "Ambito",
"date_label": "Data",
"sort_by": "Ordina per",
"clear_filters": "Cancella filtri",
"searching_for": "Ricerca di \"{{q}}\"…",
"no_results": "Nessun risultato per questa ricerca",
"prompt": "Digita una query nella barra di ricerca in alto.",
"size": {
"all": "Qualsiasi dimensione",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "In qualsiasi momento",
"day": "Ultime 24 ore",
"week": "Ultima settimana",
"month": "Ultimo mese",
"year": "Ultimo anno"
}
},
"sizeBucket": {
"folders": "Cartelle"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "作成者",
"col_opened": "アクセス日時",
"col_path": "場所",
"new_elements": "新しいアイテム"
"new_elements": "新しいアイテム",
"open_parent": "親フォルダーを開く",
"move": "移動",
"favorite": "お気に入りに追加",
"unfavorite": "お気に入りから削除",
"open": "開く",
"download_zip": "ZIP形式でダウンロード"
},
"dialogs": {
"rename_folder": "フォルダ名を変更",
@@ -1197,9 +1203,43 @@
"size_label": "サイズ",
"title": "検索",
"type": {
"audio": "音声"
"audio": "音声",
"all": "すべての種類",
"image": "画像",
"video": "動画",
"document": "文書",
"archive": "アーカイブ"
},
"type_label": "種類"
"type_label": "種類",
"sort": {
"relevance": "関連度"
},
"results_for": "「{{q}}」の検索結果",
"results_summary": "「{{q}}」の検索結果 · {{n}}件 · {{ms}} ms",
"results_summary_no_total": "「{{q}}」の検索結果 · {{ms}} ms",
"everywhere": "どこでも",
"this_folder": "このフォルダー",
"this_folder_disabled": "検索を限定するには、ファイル内でフォルダーを開いてください",
"scope": "範囲",
"date_label": "日付",
"sort_by": "並べ替え",
"clear_filters": "フィルターをクリア",
"searching_for": "「{{q}}」を検索中…",
"no_results": "この検索の結果が見つかりませんでした",
"prompt": "上の検索バーにクエリを入力してください。",
"size": {
"all": "任意のサイズ",
"small": "1 MB 未満",
"medium": "1–100 MB",
"large": "100 MB 超"
},
"date": {
"all": "任意の期間",
"day": "過去24時間",
"week": "過去1週間",
"month": "過去1ヶ月",
"year": "過去1年"
}
},
"sizeBucket": {
"folders": "フォルダ"
+4 -1
View File
@@ -1556,7 +1556,10 @@
"smallest": "작은 순"
},
"sort_by": "정렬 기준",
"this_folder": "이 폴더"
"this_folder": "이 폴더",
"results_summary": "\"{{q}}\"에 대한 검색 결과 · {{n}}개 결과, {{ms}} ms",
"results_summary_no_total": "\"{{q}}\"에 대한 검색 결과 · {{ms}} ms",
"this_folder_disabled": "검색을 제한하려면 파일에서 폴더를 여세요"
},
"sizeBucket": {
"folders": "폴더",
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Gemaakt door",
"col_opened": "Geopend",
"col_path": "Locatie",
"new_elements": "Nieuwe items"
"new_elements": "Nieuwe items",
"open_parent": "Bovenliggende map openen",
"move": "Verplaatsen",
"favorite": "Toevoegen aan favorieten",
"unfavorite": "Verwijderen uit favorieten",
"open": "Openen",
"download_zip": "Downloaden als ZIP"
},
"dialogs": {
"rename_folder": "Map hernoemen",
@@ -1197,9 +1203,43 @@
"size_label": "Grootte",
"title": "Zoeken",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Alle typen",
"image": "Afbeeldingen",
"video": "Video's",
"document": "Documenten",
"archive": "Archieven"
},
"type_label": "Type"
"type_label": "Type",
"sort": {
"relevance": "Relevantie"
},
"results_for": "Resultaten voor \"{{q}}\"",
"results_summary": "Resultaten voor \"{{q}}\" · {{n}} resultaten in {{ms}} ms",
"results_summary_no_total": "Resultaten voor \"{{q}}\" · {{ms}} ms",
"everywhere": "Overal",
"this_folder": "Deze map",
"this_folder_disabled": "Open een map in Bestanden om de zoekopdracht ertoe te beperken",
"scope": "Bereik",
"date_label": "Datum",
"sort_by": "Sorteren op",
"clear_filters": "Filters wissen",
"searching_for": "Zoeken naar \"{{q}}\"…",
"no_results": "Geen resultaten gevonden voor deze zoekopdracht",
"prompt": "Typ een zoekopdracht in de zoekbalk hierboven.",
"size": {
"all": "Elke grootte",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "Elk moment",
"day": "Afgelopen 24 uur",
"week": "Afgelopen week",
"month": "Afgelopen maand",
"year": "Afgelopen jaar"
}
},
"sizeBucket": {
"folders": "Mappen"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Utworzone przez",
"col_opened": "Otwarte",
"col_path": "Lokalizacja",
"new_elements": "Nowe elementy"
"new_elements": "Nowe elementy",
"open_parent": "Otwórz folder nadrzędny",
"move": "Przenieś",
"favorite": "Dodaj do ulubionych",
"unfavorite": "Usuń z ulubionych",
"open": "Otwórz",
"download_zip": "Pobierz jako ZIP"
},
"dialogs": {
"rename_folder": "Zmień nazwę folderu",
@@ -1197,9 +1203,43 @@
"size_label": "Rozmiar",
"title": "Szukaj",
"type": {
"audio": "Audio"
"audio": "Audio",
"all": "Wszystkie typy",
"image": "Obrazy",
"video": "Filmy",
"document": "Dokumenty",
"archive": "Archiwa"
},
"type_label": "Typ"
"type_label": "Typ",
"sort": {
"relevance": "Trafność"
},
"results_for": "Wyniki dla \"{{q}}\"",
"results_summary": "Wyniki dla \"{{q}}\" · {{n}} wyników w {{ms}} ms",
"results_summary_no_total": "Wyniki dla \"{{q}}\" · {{ms}} ms",
"everywhere": "Wszędzie",
"this_folder": "Ten folder",
"this_folder_disabled": "Otwórz folder w Plikach, aby ograniczyć wyszukiwanie do niego",
"scope": "Zakres",
"date_label": "Data",
"sort_by": "Sortuj według",
"clear_filters": "Wyczyść filtry",
"searching_for": "Wyszukiwanie „{{q}}\"…",
"no_results": "Nie znaleziono wyników dla tego wyszukiwania",
"prompt": "Wpisz zapytanie na pasku wyszukiwania powyżej.",
"size": {
"all": "Dowolny rozmiar",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "Kiedykolwiek",
"day": "Ostatnie 24 godziny",
"week": "Ostatni tydzień",
"month": "Ostatni miesiąc",
"year": "Ostatni rok"
}
},
"sizeBucket": {
"folders": "Foldery"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Criado por",
"col_opened": "Aberto",
"col_path": "Localização",
"new_elements": "Novos itens"
"new_elements": "Novos itens",
"open_parent": "Abrir pasta pai",
"move": "Mover",
"favorite": "Adicionar aos favoritos",
"unfavorite": "Remover dos favoritos",
"open": "Abrir",
"download_zip": "Baixar como ZIP"
},
"dialogs": {
"rename_folder": "Renomear pasta",
@@ -1197,9 +1203,43 @@
"size_label": "Tamanho",
"title": "Pesquisar",
"type": {
"audio": "Áudio"
"audio": "Áudio",
"all": "Todos os tipos",
"image": "Imagens",
"video": "Vídeos",
"document": "Documentos",
"archive": "Arquivos"
},
"type_label": "Tipo"
"type_label": "Tipo",
"sort": {
"relevance": "Relevância"
},
"results_for": "Resultados para \"{{q}}\"",
"results_summary": "Resultados para \"{{q}}\" · {{n}} resultados em {{ms}} ms",
"results_summary_no_total": "Resultados para \"{{q}}\" · {{ms}} ms",
"everywhere": "Em qualquer lugar",
"this_folder": "Esta pasta",
"this_folder_disabled": "Abra uma pasta em Ficheiros para limitar a pesquisa a ela",
"scope": "Âmbito",
"date_label": "Data",
"sort_by": "Ordenar por",
"clear_filters": "Limpar filtros",
"searching_for": "Pesquisando \"{{q}}\"…",
"no_results": "Nenhum resultado encontrado para esta pesquisa",
"prompt": "Digite uma consulta na barra de pesquisa acima.",
"size": {
"all": "Qualquer tamanho",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "A qualquer momento",
"day": "Últimas 24 horas",
"week": "Última semana",
"month": "Último mês",
"year": "Último ano"
}
},
"sizeBucket": {
"folders": "Pastas"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "Создано",
"col_opened": "Открыт",
"col_path": "Расположение",
"new_elements": "Новые элементы"
"new_elements": "Новые элементы",
"open_parent": "Открыть родительскую папку",
"move": "Переместить",
"favorite": "Добавить в избранное",
"unfavorite": "Удалить из избранного",
"open": "Открыть",
"download_zip": "Скачать как ZIP"
},
"dialogs": {
"rename_folder": "Переименовать папку",
@@ -1197,9 +1203,43 @@
"size_label": "Размер",
"title": "Найти",
"type": {
"audio": "Аудио"
"audio": "Аудио",
"all": "Все типы",
"image": "Изображения",
"video": "Видео",
"document": "Документы",
"archive": "Архивы"
},
"type_label": "Тип"
"type_label": "Тип",
"sort": {
"relevance": "Релевантность"
},
"results_for": "Результаты для «{{q}}»",
"results_summary": "Результаты для «{{q}}» · {{n}} результатов за {{ms}} мс",
"results_summary_no_total": "Результаты для «{{q}}» · {{ms}} мс",
"everywhere": "Везде",
"this_folder": "Эта папка",
"this_folder_disabled": "Откройте папку в «Файлах», чтобы ограничить поиск ею",
"scope": "Область",
"date_label": "Дата",
"sort_by": "Сортировать по",
"clear_filters": "Очистить фильтры",
"searching_for": "Поиск «{{q}}»…",
"no_results": "По этому запросу ничего не найдено",
"prompt": "Введите запрос в строке поиска выше.",
"size": {
"all": "Любой размер",
"small": "< 1 МБ",
"medium": "1–100 МБ",
"large": "> 100 МБ"
},
"date": {
"all": "Любое время",
"day": "Последние 24 часа",
"week": "Последняя неделя",
"month": "Последний месяц",
"year": "Последний год"
}
},
"sizeBucket": {
"folders": "Папки"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "建立者",
"col_opened": "開啟日期",
"col_path": "位置",
"new_elements": "新項目"
"new_elements": "新項目",
"open_parent": "開啟上層資料夾",
"move": "移動",
"favorite": "加入我的最愛",
"unfavorite": "從我的最愛移除",
"open": "開啟",
"download_zip": "下載為 ZIP"
},
"dialogs": {
"rename_folder": "重新命名資料夾",
@@ -1197,9 +1203,43 @@
"size_label": "大小",
"title": "搜尋",
"type": {
"audio": "音訊"
"audio": "音訊",
"all": "所有類型",
"image": "圖片",
"video": "影片",
"document": "文件",
"archive": "壓縮檔"
},
"type_label": "型別"
"type_label": "型別",
"sort": {
"relevance": "相關性"
},
"results_for": "\"{{q}}\" 的搜尋結果",
"results_summary": "\"{{q}}\" 的搜尋結果 · {{n}} 個結果 · {{ms}} 毫秒",
"results_summary_no_total": "\"{{q}}\" 的搜尋結果 · {{ms}} 毫秒",
"everywhere": "任何位置",
"this_folder": "此資料夾",
"this_folder_disabled": "開啟檔案中的資料夾以將搜尋限制在其中",
"scope": "範圍",
"date_label": "日期",
"sort_by": "排序方式",
"clear_filters": "清除篩選",
"searching_for": "正在搜尋「{{q}}」…",
"no_results": "此搜尋未找到結果",
"prompt": "在上方搜尋列中輸入查詢內容。",
"size": {
"all": "任何大小",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "任何時間",
"day": "過去 24 小時",
"week": "過去一週",
"month": "過去一個月",
"year": "過去一年"
}
},
"sizeBucket": {
"folders": "資料夾"
+43 -3
View File
@@ -384,7 +384,13 @@
"col_created_by": "创建者",
"col_opened": "打开日期",
"col_path": "位置",
"new_elements": "新元素"
"new_elements": "新元素",
"open_parent": "打开父文件夹",
"move": "移动",
"favorite": "添加到收藏",
"unfavorite": "从收藏中移除",
"open": "打开",
"download_zip": "下载为 ZIP"
},
"dialogs": {
"rename_folder": "重命名文件夹",
@@ -1197,9 +1203,43 @@
"size_label": "大小",
"title": "搜索",
"type": {
"audio": "音频"
"audio": "音频",
"all": "所有类型",
"image": "图片",
"video": "视频",
"document": "文档",
"archive": "压缩包"
},
"type_label": "类型"
"type_label": "类型",
"sort": {
"relevance": "相关度"
},
"results_for": "\"{{q}}\" 的搜索结果",
"results_summary": "\"{{q}}\" 的搜索结果 · {{n}} 条结果 · {{ms}} 毫秒",
"results_summary_no_total": "\"{{q}}\" 的搜索结果 · {{ms}} 毫秒",
"everywhere": "任何位置",
"this_folder": "此文件夹",
"this_folder_disabled": "打开文件中的文件夹以将搜索限制在其中",
"scope": "范围",
"date_label": "日期",
"sort_by": "排序方式",
"clear_filters": "清除筛选",
"searching_for": "正在搜索\"{{q}}\"…",
"no_results": "此搜索未找到结果",
"prompt": "在上方搜索栏中输入查询内容。",
"size": {
"all": "任意大小",
"small": "< 1 MB",
"medium": "1–100 MB",
"large": "> 100 MB"
},
"date": {
"all": "任何时间",
"day": "过去24小时",
"week": "过去一周",
"month": "过去一个月",
"year": "过去一年"
}
},
"sizeBucket": {
"folders": "文件夹"
+434 -6
View File
@@ -1,6 +1,11 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
/**
* Data Transfer Object for file search criteria.
@@ -59,9 +64,18 @@ pub struct SearchCriteriaDto {
#[serde(default)]
pub offset: usize,
/// Sort order for results: "relevance", "name", "name_desc", "date", "date_desc", "size", "size_desc"
/// Sort dimension. Canonical set: `"relevance"` | `"name"` | `"size"`
/// | `"updated_at"` | `"created_at"`. Direction is `reverse` below —
/// the `_desc` suffix pattern was retired 2026-07-26 in favour of
/// a single boolean, so every consumer treats "which column" and
/// "which direction" as orthogonal concerns.
#[serde(default = "default_sort_by")]
pub sort_by: String,
/// Reverse the sort direction — descending for name/size/date,
/// no-op for `relevance` (a descending relevance sort is meaningless).
#[serde(default)]
pub reverse: bool,
}
/// Default value for recursive search (true)
@@ -95,11 +109,21 @@ impl Default for SearchCriteriaDto {
limit: default_limit(),
offset: 0,
sort_by: default_sort_by(),
reverse: false,
}
}
}
/// A file search result enriched with server-computed metadata
/// A file search result enriched with server-computed metadata.
///
/// Phase 1-plus extension (AuthZ-adjacent audit follow-up, 2026-07-26):
/// carries `etag`, `created_by`, `updated_by`, `is_favorite`, `is_shared`
/// through from `FileDto`. Pre-fix these fields were dropped at `enrich_file`
/// time, so the wire-normalised `SearchResourcesDto` handler couldn't
/// reconstruct a full `FileDto` for its `resource` slot — every result
/// looked unfavorited / unshared, and provenance was blank. The extra
/// columns come from `file_blob_read_repository.rs::search_files_paginated`
/// (SELECT'd inline, EXISTS subqueries for the caller-scoped booleans).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchFileResultDto {
/// File ID
@@ -148,9 +172,36 @@ pub struct SearchFileResultDto {
/// "content" (discovered via the full-text content index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub match_source: Option<String>,
/// HTTP ETag — derived from `blob_hash + modified_at`. Duplicates
/// `FileDto::etag` so the wire handler can hand a client the same
/// token for `If-Match` / `If-None-Match` conditional requests on
/// search results as it would on a folder listing.
#[serde(default)]
pub etag: String,
/// §14 provenance — user that originally created this file. `None`
/// when the referenced user has been deleted or for legacy rows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance — user that performed the most recent mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this file. Populated by an EXISTS subquery in the search SQL —
/// the search repo carries it back as a per-row bool that the
/// service plumbs into this DTO.
#[serde(default)]
pub is_favorite: bool,
/// Resource-scoped: `true` when the file has ANY explicit role-grant.
/// Populated by the sibling EXISTS on `storage.role_grants`.
#[serde(default)]
pub is_shared: bool,
}
/// A folder search result enriched with server-computed metadata
/// A folder search result enriched with server-computed metadata.
///
/// See `SearchFileResultDto` for the Phase 1-plus rationale — same
/// story: the six caller/provenance fields are carried through so the
/// wire-normalised handler can hand the frontend a complete `FolderDto`.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchFolderResultDto {
/// Folder ID
@@ -164,7 +215,7 @@ pub struct SearchFolderResultDto {
/// Drive that owns this folder. Same column as `storage.folders.drive_id`,
/// carried through so downstream callers (e.g. the NC search REPORT
/// handler) can populate `FolderDto::drive_id` without a fallback sentinel.
pub drive_id: uuid::Uuid,
pub drive_id: Uuid,
/// Creation timestamp
pub created_at: u64,
/// Last modification timestamp
@@ -173,6 +224,25 @@ pub struct SearchFolderResultDto {
pub is_root: bool,
/// Relevance score (0-100) computed server-side
pub relevance_score: u32,
/// HTTP ETag — folders derive theirs from the tree-etag propagator.
/// Duplicating it here keeps the wire handler's `FolderDto`
/// reconstruction complete.
#[serde(default)]
pub etag: String,
/// §14 provenance — creator user id. `None` for legacy folders.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance — last-mutator user id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this folder. EXISTS on `auth.user_favorites`.
#[serde(default)]
pub is_favorite: bool,
/// Resource-scoped: `true` when the folder has any explicit
/// role-grant. EXISTS on `storage.role_grants`.
#[serde(default)]
pub is_shared: bool,
}
/**
@@ -182,7 +252,7 @@ pub struct SearchFolderResultDto {
* both files and folders that match the search criteria, along with pagination
* information and server-computed metadata.
*/
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchResultsDto {
/// Files matching the search criteria (enriched with metadata)
pub files: Vec<SearchFileResultDto>,
@@ -252,6 +322,364 @@ impl SearchResultsDto {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// New wire shape — normalised to the `/*/resources` envelope
// (`items[] { resource_type, resource, meta }` + `next_cursor` + optional
// `total`/`query_time_ms`). Phase 1-plus: internal service still speaks
// `SearchCriteriaDto`/`SearchResultsDto`; the REST handler translates.
// ═══════════════════════════════════════════════════════════════════════════
/// Query parameters for `GET /api/search`.
///
/// Mirrors `FolderResourcesQuery` for the shared axes (`limit`, `cursor`,
/// `order_by`, `resource_types`, `reverse`) then adds search-specific
/// filters (`query`, `folder_id`, `recursive`, `file_types`,
/// `created_after`/`before`, `modified_after`/`before`, `min_size`/
/// `max_size`). `serde_urlencoded` doesn't support `#[serde(flatten)]`,
/// so the paging fields are inlined rather than composed from
/// `CursorQuery`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct SearchResourcesQuery {
/// Search phrase (matched against name; optionally content when the
/// full-text index is enabled). Absent = "match everything," so
/// callers can page through with just a folder scope + filters.
pub query: Option<String>,
/// Maximum items per page (1–200, default 50).
#[serde(default = "SearchResourcesQuery::default_limit")]
pub limit: u32,
/// Opaque cursor from a previous response. Absent = first page.
/// Encodes the current offset — Phase 1-plus still uses the
/// existing offset-based service internals under the hood.
pub cursor: Option<String>,
/// Sort dimension. Supported: `"relevance"` (default), `"name"`,
/// `"size"`, `"updated_at"`, `"created_at"`. Direction is the
/// separate `reverse` flag — the historical `_desc` suffix pattern
/// (`name_desc`, `date_desc`, `size_desc`) was retired 2026-07-26
/// in favour of a single boolean, and `"date"` was renamed to the
/// more explicit `"updated_at"` alongside the new `"created_at"`.
pub order_by: Option<String>,
/// Comma-separated resource types to include, e.g. `"file,folder"`.
/// Absent = both. Matches the `FolderResourcesQuery` idiom.
pub resource_types: Option<String>,
/// Reverse the sort order. Default `false`.
#[serde(default)]
pub reverse: bool,
/// Comma-separated file extensions filter, e.g. `"pdf,docx"`.
#[serde(rename = "type")]
pub type_filter: Option<String>,
/// Restrict search to this folder.
pub folder_id: Option<String>,
/// Recursive traversal below `folder_id`. Default `true`.
#[serde(default = "SearchResourcesQuery::default_recursive")]
pub recursive: bool,
/// Minimum creation timestamp (seconds since epoch).
pub created_after: Option<u64>,
/// Maximum creation timestamp (seconds since epoch).
pub created_before: Option<u64>,
/// Minimum modification timestamp (seconds since epoch).
pub modified_after: Option<u64>,
/// Maximum modification timestamp (seconds since epoch).
pub modified_before: Option<u64>,
/// Minimum file size in bytes.
pub min_size: Option<u64>,
/// Maximum file size in bytes.
pub max_size: Option<u64>,
}
impl SearchResourcesQuery {
pub fn default_limit() -> u32 {
50
}
pub fn default_recursive() -> bool {
true
}
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
pub fn decode_cursor(&self) -> Option<SearchResourceCursor> {
self.cursor
.as_deref()
.and_then(SearchResourceCursor::decode)
}
/// Convert to the internal `SearchCriteriaDto` the service consumes.
/// `limit` / `offset` come from the decoded cursor (or the query's
/// `limit` on the first page). `sort_by` + `reverse` pass through as
/// two orthogonal fields — every downstream consumer (SQL builders,
/// in-memory folder sort) reads both.
pub fn to_criteria(&self) -> SearchCriteriaDto {
let offset = self.decode_cursor().map(|c| c.offset).unwrap_or(0);
let file_types = self.type_filter.as_deref().map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
});
SearchCriteriaDto {
name_contains: self.query.clone(),
file_types,
created_after: self.created_after,
created_before: self.created_before,
modified_after: self.modified_after,
modified_before: self.modified_before,
min_size: self.min_size,
max_size: self.max_size,
folder_id: self.folder_id.clone(),
recursive: self.recursive,
limit: self.limit_clamped(),
offset,
sort_by: self.order_by.clone().unwrap_or_else(default_sort_by),
reverse: self.reverse,
}
}
/// Which resource kinds to include. `None` = both. Anything else
/// selects the intersection.
pub fn include_files(&self) -> bool {
match self.resource_types.as_deref() {
None => true,
Some(s) => s.split(',').any(|t| t.trim() == "file"),
}
}
pub fn include_folders(&self) -> bool {
match self.resource_types.as_deref() {
None => true,
Some(s) => s.split(',').any(|t| t.trim() == "folder"),
}
}
}
/// Opaque cursor for `/api/search`. Encodes the offset the underlying
/// service still uses, plus the sort dimension so a page fetched with
/// a different `order_by` than the previous one cannot silently drift
/// into a broken keyset. Phase 2 (service rewrite) would replace this
/// with a true keyset cursor over `(sort_key, id)`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResourceCursor {
pub offset: usize,
pub order_by: String,
}
impl PageCursor for SearchResourceCursor {}
/// Search-specific per-item metadata (relevance score, snippet, hit
/// source). Sits inline on each `SearchResourceItem` so consumers get
/// data locality — no keyed lookup. `ResourceList` ignores the field.
///
/// Wire keys are shortened (`meta.score`, `meta.via`) vs the internal
/// `SearchFileResultDto` field names (`relevance_score`, `match_source`)
/// to keep the envelope compact on large result pages.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchMeta {
/// Relevance 0-100. Higher = better match.
pub score: u32,
/// Plain-text fragment around the first content-index hit. Absent
/// for name-only matches and for folder results.
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
/// Where the hit came from: `"name"` or `"content"`. Absent when the
/// origin is ambiguous (empty query → everything matches).
#[serde(skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
}
/// One search result — same `resource_type + resource` shape as the
/// `/*/resources` envelopes so `ResourceList` consumes it as-is, plus
/// the inline `meta` for search-specific enrichment.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResourceItem {
pub resource_type: ResourceTypeDto,
/// Full resource details (untagged: `FileDto | FolderDto | DriveDto`).
/// Shape determined by `resource_type`.
pub resource: ResourceContentDto,
/// Search-specific metadata for this row.
pub meta: SearchMeta,
}
/// Response envelope for `GET /api/search` — cursor-paginated + search
/// metadata. `total` is an approximate caller-visible count (permission-
/// filtered) when the service can compute it cheaply, absent otherwise —
/// matches sibling envelope endpoints, which all serialise counts as
/// integers (see `app_password_dto`, `plugin_dto`, `pagination`).
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResourcesDto {
pub items: Vec<SearchResourceItem>,
/// Opaque cursor for the next page. Absent on the last page.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
/// Server-side query time. UI shows "Found N in Xms" and admins use
/// it as a health signal.
pub query_time_ms: u64,
/// Approximate caller-visible total match count. Never leaks a count
/// for rows the caller cannot see. Omitted when unknown.
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<usize>,
}
impl SearchResourcesDto {
/// Build the envelope from the service's existing offset-paginated
/// result plus the request's cursor position. The service returns
/// `SearchResultsDto` with `total_count` + `has_more` derived from
/// COUNT(*) OVER(); we translate:
/// - `has_more` → derive `next_cursor` (encoding `offset + returned`).
/// - `total_count` → pass through as `Some(42)` when known, else `None`.
///
/// Ownership: consumes the service result so the enriched DTOs move
/// into the `resource` slot without cloning.
pub fn from_service_result(
results: crate::application::dtos::search_dto::SearchResultsDto,
query: &SearchResourcesQuery,
) -> Self {
let order_by = query.order_by.clone().unwrap_or_else(default_sort_by);
let current_offset = query.decode_cursor().map(|c| c.offset).unwrap_or(0);
let returned = results.files.len() + results.folders.len();
let next_cursor = if results.has_more {
Some(
SearchResourceCursor {
offset: current_offset + returned,
order_by: order_by.clone(),
}
.encode(),
)
} else {
None
};
let total = results.total_count;
// Build items in an order the UI expects: folders first (like the
// legacy split-shape) unless a specific sort is requested. When
// ordering by relevance / date / size the caller almost always
// wants interleaved output; when ordering by name the folders-
// first convention matches file managers. Splitting the choice
// by sort dimension keeps folder browsing intuitive.
let mut items: Vec<SearchResourceItem> = Vec::with_capacity(returned);
let query_lower = query
.query
.as_deref()
.map(|s| s.to_lowercase())
.unwrap_or_default();
let folders_first = matches!(order_by.as_str(), "name" | "name_desc");
if folders_first {
append_folders(&mut items, results.folders);
append_files(&mut items, results.files, &query_lower);
} else {
// Interleave by relevance_score (or the natural service order for
// date/size — the service already returns rows in the requested
// dimension, but folders and files come as two separate arrays
// that we merge here by score for `relevance`, or just append
// for size/date since the two arrays are individually ordered.
append_folders(&mut items, results.folders);
append_files(&mut items, results.files, &query_lower);
if order_by == "relevance" {
items.sort_by_key(|item| std::cmp::Reverse(item.meta.score));
}
}
Self {
items,
next_cursor,
query_time_ms: results.query_time_ms,
total,
}
}
}
fn append_files(
items: &mut Vec<SearchResourceItem>,
files: Vec<SearchFileResultDto>,
_query_lower: &str,
) {
for f in files {
let meta = SearchMeta {
score: f.relevance_score,
snippet: f.snippet.clone(),
via: f.match_source.clone(),
};
// Reconstruct FileDto from the enriched search result. `size_formatted`
// and display fields were already computed by `enrich_file`; the
// Phase 1-plus extensions (etag / created_by / updated_by /
// is_favorite / is_shared) carry through so the DTO is complete.
let file_dto = crate::application::dtos::file_dto::FileDto {
id: f.id,
name: f.name,
path: f.path,
size: f.size,
mime_type: f.mime_type,
folder_id: f.folder_id,
created_at: f.created_at,
modified_at: f.modified_at,
icon_class: f.icon_class,
icon_special_class: f.icon_special_class,
category: f.category,
size_formatted: f.size_formatted,
content_hash: f.blob_hash,
etag: f.etag,
created_by: f.created_by,
updated_by: f.updated_by,
is_favorite: f.is_favorite,
is_shared: f.is_shared,
sort_date: None,
};
items.push(SearchResourceItem {
resource_type: ResourceTypeDto::File,
resource: ResourceContentDto::File(file_dto),
meta,
});
}
}
fn append_folders(items: &mut Vec<SearchResourceItem>, folders: Vec<SearchFolderResultDto>) {
for f in folders {
let meta = SearchMeta {
score: f.relevance_score,
snippet: None,
via: None,
};
let folder_dto = crate::application::dtos::folder_dto::FolderDto {
id: f.id.clone(),
name: f.name,
path: f.path,
parent_id: f.parent_id,
drive_id: f.drive_id,
created_at: f.created_at,
modified_at: f.modified_at,
is_root: f.is_root,
// Folders carry closed-set display fields — always the
// same three static strings. Cheap to build via `Arc::from`
// (interning-worthy but not on the search hot path).
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag: f.etag,
created_by: f.created_by,
updated_by: f.updated_by,
is_favorite: f.is_favorite,
is_shared: f.is_shared,
};
items.push(SearchResourceItem {
resource_type: ResourceTypeDto::Folder,
resource: ResourceContentDto::Folder(folder_dto),
meta,
});
}
}
// `search_meta` map form was explored earlier and rejected in favour of
// inline `meta` per item (Ed 2026-07-26): data locality wins, no
// keyed-lookup step for consumers, matches the extensibility other
// `/*/resources` endpoints will want later.
#[allow(dead_code)]
fn _keep_hashmap_import_alive_for_future(_: HashMap<String, SearchMeta>) {}
/// DTO for search suggestion results (quick prefix search)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchSuggestionsDto {
+14 -4
View File
@@ -160,13 +160,22 @@ pub trait FileReadPort: Send + Sync + 'static {
/// are expanded inline via `storage.caller_group_ids($caller)`.
///
/// # Returns
/// A tuple of (files, total_count) where files are paginated and filtered
/// A tuple `(files, caller_flags, total_count)`:
/// - `files`: the paginated + filtered file rows.
/// - `caller_flags`: parallel `Vec<(is_favorite, is_shared)>` aligned
/// 1:1 with `files` by index. Populated in-SQL via per-row EXISTS
/// subqueries on `auth.user_favorites` and `storage.role_grants` so
/// the caller's SPA can render badges without a follow-up round-trip
/// (same pattern the photos-timeline listing uses). Kept as a
/// parallel vec rather than folded into `File` so the domain
/// entity stays caller-agnostic.
/// - `total_count`: `COUNT(*) OVER()` total for pagination.
async fn search_files_paginated(
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError>;
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError>;
/// Search files recursively in a folder subtree using ltree.
///
@@ -177,13 +186,14 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Post-PR-B: scoped by drive-membership grants (same semantics as
/// `search_files_paginated`), not by `files.user_id`.
///
/// Returns a tuple of (matching files, total count for pagination).
/// Returns the same shape as [`Self::search_files_paginated`]:
/// `(files, caller_flags, total_count)`.
async fn search_files_in_subtree(
&self,
root_folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
// Default: delegate to paginated search (non-recursive fallback)
self.search_files_paginated(root_folder_id, criteria, caller_id)
.await
+37 -2
View File
@@ -11,6 +11,7 @@ use crate::application::dtos::favorites_dto::{
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
use crate::application::services::search_service::SearchService;
use crate::common::errors::Result;
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
@@ -29,14 +30,28 @@ pub struct FavoritesService {
/// return name/mime/size/drive_id for any UUID the caller was
/// able to enroll. See `docs/plan/authz_audit/rest_storage.md`.
authorization: Arc<PgAclEngine>,
/// Optional search-cache invalidator. Every favorite mutation
/// changes what `is_favorite` returns on the caller's cached
/// search result pages; without this hook the user sees a stale
/// star badge for up to the search cache's 5-minute TTL (Ed's
/// 2026-07-26 UX report). `None` when search is disabled
/// (`OXICLOUD_ENABLE_SEARCH=false`).
search: Option<Arc<SearchService>>,
}
impl FavoritesService {
/// Create a new FavoritesService with the given repository port
pub fn new(repo: Arc<FavoritesPgRepository>, authorization: Arc<PgAclEngine>) -> Self {
/// Create a new FavoritesService with the given repository port.
/// `search` is `None` when search is disabled — the favorites path
/// still works, just without the cache-invalidation callback.
pub fn new(
repo: Arc<FavoritesPgRepository>,
authorization: Arc<PgAclEngine>,
search: Option<Arc<SearchService>>,
) -> Self {
Self {
repo,
authorization,
search,
}
}
@@ -103,6 +118,12 @@ impl FavoritesUseCase for FavoritesService {
.await?;
self.repo.add_favorite(user_id, item_id, item_type).await?;
// Drop this user's cached search pages so a subsequent search
// reflects the new star. Scoped to the caller — other tenants'
// caches are untouched.
if let Some(search) = &self.search {
search.invalidate_for_user(user_id).await;
}
info!(
"Successfully added {} '{}' to favorites for user {}",
item_type, item_id, user_id
@@ -125,6 +146,12 @@ impl FavoritesUseCase for FavoritesService {
.repo
.remove_favorite(user_id, item_id, item_type)
.await?;
// Only invalidate when a row was actually removed — a no-op
// remove (item wasn't favorited) doesn't need to cold-start the
// cache. Keeps the "toggle a non-favorite" no-op cheap.
if removed && let Some(search) = &self.search {
search.invalidate_for_user(user_id).await;
}
info!(
"{} {} '{}' from favorites for user {}",
if removed {
@@ -181,6 +208,14 @@ impl FavoritesUseCase for FavoritesService {
let requested = items.len();
let inserted = self.repo.add_favorites_batch(user_id, items).await?;
let already_existed = requested as u64 - inserted;
// Any actual insert flips is_favorite for at least one row —
// invalidate. Skip when the batch was fully idempotent (every
// item was already favorited); no user-visible change.
if inserted > 0
&& let Some(search) = &self.search
{
search.invalidate_for_user(user_id).await;
}
info!(
"Batch favorites for user {}: {} requested, {} inserted, {} already existed",
+279 -111
View File
@@ -72,7 +72,14 @@ pub struct SearchService {
/// Keys span user × query × offset × limit, and each page holds up to 500
/// enriched rows (~500–900 B of owned Strings each) — an entry-count bound
/// let hundreds of MB of result pages accumulate invisibly.
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
///
/// Key is `(user_id, criteria_hash)` (not a single fused `u64`) so
/// `invalidate_for_user` can predicate on `k.0` — a per-user flush
/// runs when the user favorites/shares a file so their next search
/// sees the fresh `is_favorite` / `is_shared` flags instead of a
/// cache entry that hardened at compute-time (was up to 5 min stale
/// before 2026-07-26 — Ed reported the mismatch).
search_cache: moka::future::Cache<(Uuid, u64), Arc<SearchResultsDto>>,
}
// ─── Search-results cache (byte-bounded) ─────────────────────────────────
@@ -88,7 +95,7 @@ pub struct SearchService {
///
/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained
/// bytes with the exact production formula.
pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) -> u32 {
pub fn search_results_entry_weight(_key: &(Uuid, u64), value: &Arc<SearchResultsDto>) -> u32 {
/// Fixed per-row overhead: struct scalars + one 24-B header per `String`
/// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator
/// slop. Deliberately a round upper-ish estimate — under-weighing is the
@@ -132,11 +139,15 @@ pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) ->
pub fn build_search_results_cache(
cache_ttl_secs: u64,
max_bytes: u64,
) -> moka::future::Cache<u64, Arc<SearchResultsDto>> {
) -> moka::future::Cache<(Uuid, u64), Arc<SearchResultsDto>> {
moka::future::Cache::builder()
.max_capacity(max_bytes)
.weigher(search_results_entry_weight)
.time_to_live(Duration::from_secs(cache_ttl_secs))
// Required for `invalidate_entries_if` to actually match anything
// — without this the closure silently no-ops (per the
// `bug_moka_invalidate_entries_if_needs_opt_in` memo).
.support_invalidation_closures()
.build()
}
@@ -201,14 +212,23 @@ fn content_relevance(score: f32, max_score: f32) -> u32 {
/// Re-sort the merged file list with the same semantics the folder list
/// uses. Only invoked when content hits were merged into a SQL-ordered page.
fn sort_enriched_files(files: &mut [SearchFileResultDto], sort_by: &str) {
///
/// Sort dimension + direction are orthogonal (matches the wire
/// `SearchResourcesQuery` / internal `SearchCriteriaDto` split): 5
/// canonical `sort_by` values (`relevance | name | size | updated_at |
/// created_at`) × the boolean `reverse`.
fn sort_enriched_files(files: &mut [SearchFileResultDto], sort_by: &str, reverse: bool) {
match sort_by {
"name" if reverse => files.sort_by_cached_key(|f| Reverse(f.name.to_lowercase())),
"name" => files.sort_by_cached_key(|f| f.name.to_lowercase()),
"name_desc" => files.sort_by_cached_key(|f| Reverse(f.name.to_lowercase())),
"date" => files.sort_by_key(|f| f.modified_at),
"date_desc" => files.sort_by_key(|f| Reverse(f.modified_at)),
"updated_at" if reverse => files.sort_by_key(|f| Reverse(f.modified_at)),
"updated_at" => files.sort_by_key(|f| f.modified_at),
"created_at" if reverse => files.sort_by_key(|f| Reverse(f.created_at)),
"created_at" => files.sort_by_key(|f| f.created_at),
"size" if reverse => files.sort_by_key(|f| Reverse(f.size)),
"size" => files.sort_by_key(|f| f.size),
"size_desc" => files.sort_by_key(|f| Reverse(f.size)),
// `relevance` (default) — reverse is a no-op; descending
// relevance would be "least-relevant first," meaningless.
_ => files.sort_by_key(|f| Reverse(f.relevance_score)),
}
}
@@ -261,10 +281,13 @@ impl SearchService {
}
/// Creates a cache key from the search criteria using zero-allocation hashing.
fn create_cache_key(criteria: &SearchCriteriaDto, user_id: &str) -> u64 {
/// Hash just the criteria — the caller pairs the returned `u64` with
/// the `Uuid` user_id to form the composite cache key `(Uuid, u64)`.
/// Split from the fused hash so `invalidate_for_user` can predicate
/// on the user side of the tuple without decoding the criteria.
fn create_cache_key(criteria: &SearchCriteriaDto) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
criteria.hash(&mut hasher);
user_id.hash(&mut hasher);
hasher.finish()
}
@@ -304,6 +327,16 @@ impl SearchService {
blob_hash: file.content_hash,
snippet: None,
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
// Phase 1-plus: carry the FileDto fields the old enrich
// shape dropped. Populates the normalised
// `SearchResourcesDto` items with a complete `FileDto` so
// the frontend `ResourceList` renders favorites / share
// badges / provenance consistently with other listings.
etag: file.etag,
created_by: file.created_by,
updated_by: file.updated_by,
is_favorite: file.is_favorite,
is_shared: file.is_shared,
}
}
@@ -329,6 +362,13 @@ impl SearchService {
modified_at: folder.modified_at,
is_root: folder.is_root,
relevance_score: relevance,
// Phase 1-plus (see sibling `enrich_file`): carry the
// FolderDto fields the old enrich shape dropped.
etag: folder.etag,
created_by: folder.created_by,
updated_by: folder.updated_by,
is_favorite: folder.is_favorite,
is_shared: folder.is_shared,
}
}
@@ -502,7 +542,7 @@ impl SearchService {
added += 1;
}
if added > 0 {
sort_enriched_files(enriched_files, &criteria.sort_by);
sort_enriched_files(enriched_files, &criteria.sort_by, criteria.reverse);
}
Ok(added)
}
@@ -628,13 +668,11 @@ impl SearchUseCase for SearchService {
criteria: SearchCriteriaDto,
user_id: Uuid,
) -> Result<Arc<SearchResultsDto>> {
// Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the
// hasher sees the identical byte sequence, so the u64 key is unchanged,
// but the per-request heap `String` is gone (the fn doc even claims
// "zero-allocation hashing"). See benches/ROUND19.md §M5.
let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf);
let cache_key = Self::create_cache_key(&criteria, user_id_str);
// Composite key: `(user_id, criteria_hash)`. Pairs the identity of
// the caller with the hash of the request so `invalidate_for_user`
// can drop just this user's entries when their favorites / shares
// change (see the `search_cache` field doc for the "why").
let cache_key = (user_id, Self::create_cache_key(&criteria));
// Single-flight: collapse N identical concurrent searches into ONE
// execution. `try_get_with` serves the cached result on a hit and, on a
@@ -651,18 +689,12 @@ impl SearchUseCase for SearchService {
// For non-recursive searches, use efficient database-level pagination
// This avoids loading all files into memory
if !criteria.recursive {
// The content-index lookup (drive resolve + Tantivy +
// ReBAC batch), the file page and the folder query are
// mutually independent — overlap them so the search pays
// ~max() instead of the serial sum (`suggest_with_perms`
// already used this shape; ROUND10 brought it here).
let (content_hits, files_page, folders_res) = tokio::join!(
// Same folders-first sequencing as the recursive branch
// below (see the block comment there for the rationale
// — SQL applies file offset+limit, so folder_count has
// to be known before the file query is issued).
let (content_hits, folders_res) = tokio::join!(
self.lookup_content_hits(&criteria, user_id),
self.file_repository.search_files_paginated(
criteria.folder_id.as_deref(),
&criteria,
user_id,
),
self.folder_repository.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
@@ -670,74 +702,120 @@ impl SearchUseCase for SearchService {
false,
),
);
let (files, total_file_count) = files_page?;
let folders = folders_res?;
let (folders, folder_flags) = folders_res?;
let folder_count = folders.len();
let folders_before_page = criteria.offset.min(folder_count);
let folders_on_page = (folder_count - folders_before_page).min(criteria.limit);
let file_offset = criteria.offset - folders_before_page;
let file_limit_needed = criteria.limit - folders_on_page;
let file_limit_probe = file_limit_needed.max(1);
let mut file_criteria = criteria.clone();
file_criteria.offset = file_offset;
file_criteria.limit = file_limit_probe;
let (files, file_flags, total_file_count) = self
.file_repository
.search_files_paginated(
criteria.folder_id.as_deref(),
&file_criteria,
user_id,
)
.await?;
// Convert to DTOs and enrich with metadata — one fused
// pass, no intermediate Vec<FileDto> materialization.
// `file_flags` is aligned 1:1 with `files` (in-SQL
// per-row EXISTS on favorites + role_grants) so a
// simple parallel zip plumbs the caller-scoped
// booleans onto the FileDto before enrichment.
let mut enriched_files: Vec<SearchFileResultDto> = files
.into_iter()
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
.zip(file_flags)
.map(|(f, (is_fav, is_shr))| {
let mut dto = FileDto::from(f);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
Self::enrich_file(dto, &query_lower)
})
.collect();
// For folders, apply sorting and pagination in memory (usually fewer folders)
// For folders, apply sorting and pagination in memory (usually fewer folders).
// Same parallel-zip shape as the file branch above —
// `folder_flags` is aligned 1:1 by index.
let mut enriched_folders: Vec<SearchFolderResultDto> = folders
.into_iter()
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
.zip(folder_flags)
.map(|(f, (is_fav, is_shr))| {
let mut dto = FolderDto::from(f);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
Self::enrich_folder(dto, &query_lower)
})
.collect();
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
match criteria.sort_by.as_str() {
"name" => {
// Sort folders (cached_key avoids O(N log N) temporary String allocations).
// 5-dimension model (`relevance | name | size | updated_at | created_at`)
// × the boolean `reverse` — matches the file-side `sort_enriched_files`
// + the SQL match in `file_blob_read_repository`. `size` isn't
// meaningful for folders (no size column), so it falls through
// to the relevance default.
match (criteria.sort_by.as_str(), criteria.reverse) {
("name", false) => {
enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase());
}
"name_desc" => {
("name", true) => {
enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase()));
}
"date" => {
("updated_at", false) => {
enriched_folders.sort_by_key(|f| f.modified_at);
}
"date_desc" => {
("updated_at", true) => {
enriched_folders.sort_by_key(|f| Reverse(f.modified_at));
}
("created_at", false) => {
enriched_folders.sort_by_key(|f| f.created_at);
}
("created_at", true) => {
enriched_folders.sort_by_key(|f| Reverse(f.created_at));
}
// `relevance` (default) + `size` (N/A for folders) +
// anything unrecognised — all land on relevance-desc.
_ => {
enriched_folders.sort_by_key(|f| Reverse(f.relevance_score));
}
}
// Blend in content-discovered files before the pagination math.
let added = self
// Blend in content-discovered files, then truncate to
// the exact page size — see the recursive branch's
// block comment for why the truncate is required.
//
// Note: `total_count` is intentionally the pure SQL
// name-match count (plus folders) — NOT inflated by
// `added` content-hit rows. `added` counts hits that
// aren't already in the *current* SQL slice, which is
// per-page (different SQL rows on each page produce
// different dedup outcomes and a different `added`).
// Including it made the client-visible `total`
// flicker as the user paginated (2026-07-26 report:
// 4184 → 4186 across scope + page toggles for a
// stable dataset). Content-hits still bubble into
// each page's `items`; they just don't move the
// grand total.
let _added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
enriched_files.truncate(file_limit_needed);
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
// Combine and paginate (folders first, then files)
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
// Move the page out of the owned vecs instead of
// deep-cloning the slice — the source is dropped right
// after (benches/ROUND11.md §11: −300 allocs per page).
let paginated_folders: Vec<_> = enriched_folders
.into_iter()
.skip(folder_start)
.take(folder_end - folder_start)
.collect();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files: Vec<_> = enriched_files
.into_iter()
.skip(file_start)
.take(file_end - file_start)
.skip(folders_before_page)
.take(folders_on_page)
.collect();
let paginated_files = enriched_files;
let elapsed_ms = start.elapsed().as_millis() as u64;
@@ -757,16 +835,30 @@ impl SearchUseCase for SearchService {
// ── Recursive search via ltree (single SQL query per entity type) ──
// Uses PostgreSQL ltree GiST index to find all files and folders
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
// approach that could saturate the connection pool. The content
// lookup, subtree file query and folder query overlap (`join!`),
// same as the non-recursive branch.
let (content_hits, files_page, folders_res) = tokio::join!(
// approach that could saturate the connection pool.
//
// ── Correct pagination across a folders-then-files list ──
// Pre-fix the service ran (content, file-page, folder-page)
// in one `tokio::join!` with the SAME `criteria.offset/limit`
// going to the file SQL, then re-paginated in memory using
// ABSOLUTE offsets. That was doubly wrong: SQL already
// applied `[offset, offset+limit)` and the in-memory slice
// then tried to skip `offset` MORE rows — for any query
// with few folders this dropped whole pages (2026-07-26:
// 4002-file query returned 50 rows on page 1 then `items:[]`
// on page 2 with a valid `next_cursor`).
//
// Fix: fold folders + content lookup first (they're both
// cheap and folder_count is what tells us how many files
// to skip). Then run the file SQL with `offset` shifted by
// `folder_count` and `limit` reduced by whatever folders
// fit on the current page — so SQL returns EXACTLY the
// file slice that belongs here, no in-memory re-slicing.
// The min-1 probe below preserves `COUNT(*) OVER()` even
// when folders fill the whole page (LIMIT 0 → 0 rows →
// total_count column projects nowhere → false zero).
let (content_hits, folders_res) = tokio::join!(
self.lookup_content_hits(&criteria, user_id),
self.file_repository.search_files_in_subtree(
criteria.folder_id.as_deref(),
&criteria,
user_id,
),
self.folder_repository.search_folders(
criteria.folder_id.as_deref(),
criteria.name_contains.as_deref(),
@@ -774,19 +866,50 @@ impl SearchUseCase for SearchService {
true,
),
);
let (found_files, total_file_count) = files_page?;
let found_folders: Vec<Folder> = folders_res?;
let (found_folders, folder_flags): (Vec<Folder>, Vec<(bool, bool)>) = folders_res?;
let folder_count = found_folders.len();
let folders_before_page = criteria.offset.min(folder_count);
let folders_on_page = (folder_count - folders_before_page).min(criteria.limit);
let file_offset = criteria.offset - folders_before_page;
let file_limit_needed = criteria.limit - folders_on_page;
// Probe with LIMIT ≥ 1 so `COUNT(*) OVER()` has a row to
// project onto; the extra row (if any) is truncated below.
let file_limit_probe = file_limit_needed.max(1);
let mut file_criteria = criteria.clone();
file_criteria.offset = file_offset;
file_criteria.limit = file_limit_probe;
let (found_files, file_flags, total_file_count) = self
.file_repository
.search_files_in_subtree(criteria.folder_id.as_deref(), &file_criteria, user_id)
.await?;
// ── Convert to DTOs and enrich with server-computed metadata ──
// Fused single pass: no intermediate DTO Vec materialization.
// Same shape as the non-recursive branch above — parallel
// zip of `found_files` with the in-SQL caller_flags.
let mut enriched_files: Vec<SearchFileResultDto> = found_files
.into_iter()
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
.zip(file_flags)
.map(|(f, (is_fav, is_shr))| {
let mut dto = FileDto::from(f);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
Self::enrich_file(dto, &query_lower)
})
.collect();
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
.into_iter()
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
.zip(folder_flags)
.map(|(f, (is_fav, is_shr))| {
let mut dto = FolderDto::from(f);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
Self::enrich_folder(dto, &query_lower)
})
.collect();
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
@@ -808,38 +931,35 @@ impl SearchUseCase for SearchService {
}
}
// Blend in content-discovered files before the pagination math.
let added = self
// Blend in content-discovered files. `merge_content_hits`
// pushes candidates from the Tantivy index onto the tail
// and re-sorts by the criteria; the SQL limit above only
// bounded the name-match set, so truncate after merging
// to the exact page size we intended to return.
//
// `total_count` is the pure SQL name-match count + folder
// count — see the non-recursive branch's block comment
// for why `added` is deliberately excluded (per-page
// dedup outcome, would flicker the client-visible
// total across pages).
let _added = self
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
.await?;
let total_file_count = total_file_count + added;
enriched_files.truncate(file_limit_needed);
// ── Pagination (folders first, then files) ──
let folder_count = enriched_folders.len();
let total_count = total_file_count + folder_count;
let start_idx = criteria.offset.min(total_count);
let end_idx = (criteria.offset + criteria.limit).min(total_count);
let folder_start = start_idx.min(folder_count);
let folder_end = end_idx.min(folder_count);
// Move the page out instead of deep-cloning the slice — the
// recursive branch's vecs can hold the whole subtree match
// set, all dropped right after (benches/ROUND11.md §11).
// Fetches were pre-sliced: enriched_files is already the
// exact file page (SQL applied file_offset + file_limit),
// and folders_before_page / folders_on_page tell us which
// slice of `enriched_folders` belongs here. No in-memory
// absolute-offset math — see the block comment above.
let paginated_folders: Vec<_> = enriched_folders
.into_iter()
.skip(folder_start)
.take(folder_end - folder_start)
.collect();
let file_start = start_idx.saturating_sub(folder_count);
let file_end = end_idx
.saturating_sub(folder_count)
.min(enriched_files.len());
let paginated_files: Vec<_> = enriched_files
.into_iter()
.skip(file_start)
.take(file_end - file_start)
.skip(folders_before_page)
.take(folders_on_page)
.collect();
let paginated_files = enriched_files;
let elapsed_ms = start.elapsed().as_millis() as u64;
@@ -889,6 +1009,41 @@ impl SearchUseCase for SearchService {
}
}
impl SearchService {
/// Drop every cached search page for a single user. Called by the
/// favorites / share services after a mutation that changes what
/// `is_favorite` / `is_shared` would return for one of the caller's
/// files — without this the caller would see a stale flag for up
/// to `cache_ttl_secs` (Ed's 2026-07-26 report).
///
/// `invalidate_entries_if` needs `.support_invalidation_closures()`
/// on the cache builder — set in `build_search_results_cache`. This
/// is scoped (predicate matches `k.0 == user_id` on the composite
/// `(Uuid, u64)` key), so a per-user favorite toggle does NOT
/// cold-start every other tenant's cache the way `invalidate_all`
/// does on the admin cache-flush endpoint.
pub async fn invalidate_for_user(&self, user_id: Uuid) {
// moka registers the predicate and returns a `PredicateId` — we
// don't need the id (we're not planning to unregister). Errors
// here are non-critical: worst case the caller sees stale
// is_favorite / is_shared for TTL seconds, exactly the state
// before this fix. Log-and-swallow keeps the mutation path
// reliable even under moka pressure.
if let Err(e) = self
.search_cache
.invalidate_entries_if(move |k, _| k.0 == user_id)
{
tracing::warn!(
target: "oxicloud::search",
error = %e,
%user_id,
"search cache invalidate_entries_if failed — user will see \
stale is_favorite / is_shared until TTL expires",
);
}
}
}
// ─── Stub for testing ────────────────────────────────────────────────────
impl SearchService {
@@ -960,6 +1115,11 @@ mod tests {
blob_hash: String::new(),
snippet: None,
match_source: None,
etag: String::new(),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
@@ -967,7 +1127,7 @@ mod tests {
fn entry_weight_counts_every_owned_string_plus_overheads() {
// Empty page: entry overhead + sort_by ("relevance" = 9 bytes).
let empty = Arc::new(SearchResultsDto::empty());
let base = search_results_entry_weight(&0, &empty) as usize;
let base = search_results_entry_weight(&(Uuid::nil(), 0), &empty) as usize;
assert_eq!(base, 256 + 9);
// One file row: base + row overhead + its owned string bytes
@@ -981,7 +1141,7 @@ mod tests {
0,
"relevance".to_string(),
));
let w = search_results_entry_weight(&0, &one_file) as usize;
let w = search_results_entry_weight(&(Uuid::nil(), 0), &one_file) as usize;
assert_eq!(w, base + 200 + 7 + 7 + 8 + 10);
// Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17).
@@ -997,6 +1157,11 @@ mod tests {
modified_at: 0,
is_root: false,
relevance_score: 50,
etag: String::new(),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}],
100,
0,
@@ -1004,7 +1169,7 @@ mod tests {
0,
"relevance".to_string(),
));
let w = search_results_entry_weight(&0, &one_folder) as usize;
let w = search_results_entry_weight(&(Uuid::nil(), 0), &one_folder) as usize;
assert_eq!(w, base + 200 + 2 + 4 + 5 + 6);
}
@@ -1025,12 +1190,12 @@ mod tests {
"relevance".to_string(),
))
};
let per_entry = search_results_entry_weight(&0, &entry(0)) as u64;
let per_entry = search_results_entry_weight(&(Uuid::nil(), 0), &entry(0)) as u64;
let budget = per_entry * 2 + per_entry / 2;
let cache = build_search_results_cache(300, budget);
for i in 0..20u64 {
cache.insert(i, entry(i as usize)).await;
cache.insert((Uuid::nil(), i), entry(i as usize)).await;
}
cache.run_pending_tasks().await;
@@ -1051,17 +1216,20 @@ mod tests {
dto("b-content.txt", 30, 10, 200),
dto("a-name.txt", 80, 99, 100),
];
sort_enriched_files(&mut files, "relevance");
sort_enriched_files(&mut files, "relevance", false);
assert_eq!(
files[0].name, "a-name.txt",
"name match must outrank content match"
);
sort_enriched_files(&mut files, "size_desc");
// 5-dimension sort model (2026-07-26): direction is a separate
// boolean, `_desc` suffixes retired. `size + reverse=true` = old
// `size_desc`, `updated_at + reverse=false` = old `date`, etc.
sort_enriched_files(&mut files, "size", true);
assert_eq!(files[0].name, "a-name.txt");
sort_enriched_files(&mut files, "date");
sort_enriched_files(&mut files, "updated_at", false);
assert_eq!(files[0].name, "a-name.txt");
sort_enriched_files(&mut files, "name_desc");
sort_enriched_files(&mut files, "name", true);
assert_eq!(files[0].name, "b-content.txt");
}
}
+35 -2
View File
@@ -4,6 +4,7 @@ use thiserror::Error;
use tokio::sync::Semaphore;
use uuid::Uuid;
use crate::application::services::search_service::SearchService;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
@@ -98,6 +99,16 @@ pub struct ShareService {
/// Bounds the number of in-flight Argon2 password hashes to avoid
/// saturating the blocking thread pool and consuming excessive RAM.
hash_semaphore: Arc<Semaphore>,
/// Optional search-cache invalidator. Every share create/delete flips
/// what `is_shared` returns on the calling user's cached search
/// result pages; without this hook the sharer sees a stale share
/// badge for up to the search cache's 5-minute TTL. `None` when
/// search is disabled (`OXICLOUD_ENABLE_SEARCH=false`).
///
/// Only the CALLER's cache is invalidated — recipients of a share
/// still get stale-until-TTL for now (would need a per-resource
/// invalidation index; deferred).
search: Option<Arc<SearchService>>,
}
impl ShareService {
@@ -110,6 +121,7 @@ impl ShareService {
drive_repository: Arc<DrivePgRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
authorization: Arc<PgAclEngine>,
search: Option<Arc<SearchService>>,
) -> Self {
Self {
base_url: config.base_url(),
@@ -121,6 +133,7 @@ impl ShareService {
password_hasher,
authorization,
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
search,
}
}
@@ -342,6 +355,13 @@ impl ShareUseCase for ShareService {
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Sharer's search cache no longer reflects `is_shared` truthfully
// for the affected resource — flush their entries. Recipients are
// still stale-until-TTL (see the struct field comment).
if let Some(search) = &self.search {
search.invalidate_for_user(user_id).await;
}
// Return DTO with the requested expires_at (grant subquery on the share
// row would return NULL at this point since INSERT ran before the grant).
let mut response = ShareDto::from_entity(&saved_share, &self.base_url);
@@ -445,6 +465,12 @@ impl ShareUseCase for ShareService {
self.share_repository
.delete_share_for_user(id, requester_id)
.await?;
// Sharer's search cache no longer reflects `is_shared` truthfully
// for the affected resource — flush their entries. Recipients are
// still stale-until-TTL (see the struct field comment).
if let Some(search) = &self.search {
search.invalidate_for_user(requester_id).await;
}
Ok(())
}
@@ -920,8 +946,15 @@ mod tests {
_folder_id: Option<&str>,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_user_id: Uuid,
) -> Result<(Vec<crate::domain::entities::file::File>, usize), DomainError> {
Ok((Vec::new(), 0))
) -> Result<
(
Vec<crate::domain::entities::file::File>,
Vec<(bool, bool)>,
usize,
),
DomainError,
> {
Ok((Vec::new(), Vec::new(), 0))
}
async fn stream_files_in_subtree(
@@ -532,8 +532,8 @@ impl FileReadPort for MockFileRepository {
_folder_id: Option<&str>,
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
_user_id: Uuid,
) -> std::result::Result<(Vec<File>, usize), DomainError> {
Ok((Vec::new(), 0))
) -> std::result::Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
Ok((Vec::new(), Vec::new(), 0))
}
async fn stream_files_in_subtree(
+27 -5
View File
@@ -877,13 +877,17 @@ impl AppServiceFactory {
Some(service as Arc<TrashService>)
}
/// Creates the sharing service
/// Creates the sharing service. `search_service` is threaded through
/// so create/delete of a share can flush the caller's cached search
/// pages (2026-07-26 — per-user is_shared invalidation). `None` when
/// search is disabled; the flush becomes a no-op.
pub fn create_share_service(
&self,
repos: &RepositoryServices,
db_pool: &Arc<PgPool>,
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
search_service: Option<Arc<SearchService>>,
) -> Option<Arc<ShareService>> {
if !self.config.features.enable_file_sharing {
tracing::info!("File sharing service is disabled in configuration");
@@ -909,6 +913,10 @@ impl AppServiceFactory {
drive_repo.clone(),
password_hasher,
authorization.clone(),
// Optional per-user search-cache invalidator — set here so
// create/delete of a share drops the sharer's cached search
// pages (2026-07-26). `None` when search is disabled.
search_service.clone(),
));
tracing::info!("File sharing service initialized");
@@ -917,16 +925,23 @@ impl AppServiceFactory {
/// Creates the favorites service (requires database + authz engine
/// for the Read gate on `add_to_favorites` — see the post-Drive
/// AuthZ audit).
/// AuthZ audit). `search_service` is threaded through so add/remove
/// can flush the caller's cached search pages (2026-07-26 — per-user
/// is_favorite invalidation). `None` when search is disabled.
pub fn create_favorites_service(
&self,
db_pool: &Arc<PgPool>,
authorization: &Arc<PgAclEngine>,
search_service: Option<Arc<SearchService>>,
) -> Arc<FavoritesService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
);
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
let service = Arc::new(FavoritesService::new(
repo,
authorization.clone(),
search_service,
));
tracing::info!("Favorites service initialized");
service
}
@@ -1325,7 +1340,13 @@ impl AppServiceFactory {
);
// 5. Share service
let share_service = self.create_share_service(&repos, &pool, &authorization, &drive_repo);
let share_service = self.create_share_service(
&repos,
&pool,
&authorization,
&drive_repo,
apps.search_service.clone(),
);
apps.share_service = share_service.clone();
let share_browse_service = share_service.as_ref().map(|s| {
@@ -1359,7 +1380,8 @@ impl AppServiceFactory {
> = None;
{
let favs = self.create_favorites_service(&pool, &authorization);
let favs =
self.create_favorites_service(&pool, &authorization, apps.search_service.clone());
favorites_service = Some(favs.clone());
apps.favorites_service = Some(favs);
+2 -2
View File
@@ -126,8 +126,8 @@ impl FileReadPort for StubFileReadPort {
_folder_id: Option<&str>,
_criteria: &SearchCriteriaDto,
_user_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
Ok((Vec::new(), 0))
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
Ok((Vec::new(), Vec::new(), 0))
}
async fn stream_files_in_subtree(
+25 -17
View File
@@ -237,31 +237,39 @@ pub trait FolderRepository: Send + Sync + 'static {
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
///
/// Returns `(folders, caller_flags)` where `caller_flags` is a
/// parallel `Vec<(is_favorite, is_shared)>` aligned 1:1 with
/// `folders` by index. The default impl fills with `(false, false)`
/// so stubs stay trivial; the concrete PG impl computes them via
/// per-row EXISTS on `auth.user_favorites` and `storage.role_grants`
/// (matching the file-side `search_files_paginated` pattern).
async fn search_folders(
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
// Recursive with folder_id → use optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
// Fallback: load + filter in memory (stubs / mocks)
let all = self.list_folders(parent_id).await?;
match name_contains {
Some(q) if !q.is_empty() => {
let q = q.to_lowercase();
Ok(all
.into_iter()
.filter(|f| f.name().to_lowercase().contains(&q))
.collect())
let folders = if recursive && let Some(fid) = parent_id {
self.list_descendant_folders(fid, name_contains, caller_id)
.await?
} else {
// Fallback: load + filter in memory (stubs / mocks)
let all = self.list_folders(parent_id).await?;
match name_contains {
Some(q) if !q.is_empty() => {
let q = q.to_lowercase();
all.into_iter()
.filter(|f| f.name().to_lowercase().contains(&q))
.collect()
}
_ => all,
}
_ => Ok(all),
}
};
let flags = vec![(false, false); folders.len()];
Ok((folders, flags))
}
/// Return up to `limit` folders whose name contains `query` (case-insensitive).
@@ -1212,20 +1212,27 @@ impl FileReadPort for FileBlobReadRepository {
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
let offset = criteria.offset as i64;
let limit = criteria.limit as i64;
// Determine sort order
let (order_column, order_dir) = match criteria.sort_by.as_str() {
"name" => ("fi.name", "ASC"),
"name_desc" => ("fi.name", "DESC"),
"date" => ("fi.updated_at", "ASC"),
"date_desc" => ("fi.updated_at", "DESC"),
"size" => ("fi.size", "ASC"),
"size_desc" => ("fi.size", "DESC"),
_ => ("fi.name", "ASC"),
// Determine sort order. Canonical `sort_by` set (matches the wire
// `SearchResourcesQuery.order_by` 1:1 — no rename at any layer):
// `relevance | name | size | updated_at | created_at`. Direction
// comes from `criteria.reverse`; the old `_desc`-suffix pattern
// was retired 2026-07-26.
let order_column = match criteria.sort_by.as_str() {
"name" => "fi.name",
"updated_at" => "fi.updated_at",
"created_at" => "fi.created_at",
"size" => "fi.size",
// `relevance` (or anything unrecognised) has no dedicated
// column here — the recursive/non-recursive services blend
// in content-index hits and re-sort in memory. Falling back
// to name keeps the SQL page stable.
_ => "fi.name",
};
let order_dir = if criteria.reverse { "DESC" } else { "ASC" };
// ── Build dynamic WHERE + bind indices ───────────────────────────
let mut conditions: Vec<String> = vec![
@@ -1250,6 +1257,15 @@ impl FileReadPort for FileBlobReadRepository {
let limit_bind = bind_idx + 1;
let offset_bind = bind_idx + 2;
// Per-row caller flags — populated in-SQL so the search result
// is a single round-trip. Mirrors the pattern used by the
// photos-timeline listing above (see `list_photos_paginated`).
// Both EXISTS clauses hit narrow indexes (favorites is keyed
// on `(user_id, item_id, item_type)`; role_grants on
// `(resource_type, resource_id)`), each a sub-ms lookup — the
// cost across a 100-row search page is a few ms of index
// probing vs a full second round-trip for the alternative
// post-hoc batch approach.
let sql = format!(
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
@@ -1258,6 +1274,17 @@ impl FileReadPort for FileBlobReadRepository {
fi.blob_hash, \
\
fi.created_by, fi.updated_by, \
EXISTS ( \
SELECT 1 FROM auth.user_favorites uf \
WHERE uf.user_id = $1 \
AND uf.item_id = fi.id::text \
AND uf.item_type = 'file' \
) AS is_favorite, \
EXISTS ( \
SELECT 1 FROM storage.role_grants g \
WHERE g.resource_id = fi.id \
AND g.resource_type = 'file' \
) AS is_shared, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -1281,6 +1308,8 @@ impl FileReadPort for FileBlobReadRepository {
String,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
bool, // is_favorite
bool, // is_shared
i64, // total_count
),
>(&sql)
@@ -1303,20 +1332,25 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
// total_count is the same in every row; 0 when result set is empty.
let total_count = rows.first().map_or(0, |r| r.11) as usize;
let total_count = rows.first().map_or(0, |r| r.13) as usize;
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
// Pre-size both parallel Vecs (size-hint note in `hydrate`,
// ROUND20 §I1). Caller-flags stay aligned with `files` by index.
let mut files = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
let mut caller_flags = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
rows
{
files.push(
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("mapping: {e}"))
})?,
);
caller_flags.push((is_fav, is_shr));
}
Ok((files, total_count))
Ok((files, caller_flags, total_count))
}
/// Recursive subtree search using ltree — single SQL query.
@@ -1337,7 +1371,7 @@ impl FileReadPort for FileBlobReadRepository {
root_folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
// When no root folder specified, delegate to existing paginated search
let root_id = match root_folder_id {
None => {
@@ -1349,16 +1383,23 @@ impl FileReadPort for FileBlobReadRepository {
let offset = criteria.offset as i64;
let limit = criteria.limit as i64;
// Determine sort order
let (order_column, order_dir) = match criteria.sort_by.as_str() {
"name" => ("fi.name", "ASC"),
"name_desc" => ("fi.name", "DESC"),
"date" => ("fi.updated_at", "ASC"),
"date_desc" => ("fi.updated_at", "DESC"),
"size" => ("fi.size", "ASC"),
"size_desc" => ("fi.size", "DESC"),
_ => ("fi.name", "ASC"),
// Determine sort order. Canonical `sort_by` set (matches the wire
// `SearchResourcesQuery.order_by` 1:1 — no rename at any layer):
// `relevance | name | size | updated_at | created_at`. Direction
// comes from `criteria.reverse`; the old `_desc`-suffix pattern
// was retired 2026-07-26.
let order_column = match criteria.sort_by.as_str() {
"name" => "fi.name",
"updated_at" => "fi.updated_at",
"created_at" => "fi.created_at",
"size" => "fi.size",
// `relevance` (or anything unrecognised) has no dedicated
// column here — the recursive/non-recursive services blend
// in content-index hits and re-sort in memory. Falling back
// to name keeps the SQL page stable.
_ => "fi.name",
};
let order_dir = if criteria.reverse { "DESC" } else { "ASC" };
// ── Build dynamic WHERE clauses ──
let mut conditions = Vec::new();
@@ -1382,7 +1423,9 @@ impl FileReadPort for FileBlobReadRepository {
let limit_bind = bind_idx + 1;
let offset_bind = bind_idx + 2;
// ── Single query with COUNT(*) OVER() ──
// ── Single query with COUNT(*) OVER() + per-row caller flags ──
// See `search_files_paginated` above for the EXISTS-subquery
// rationale; same shape applies here.
let sql = format!(
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
fi.size, fi.mime_type, \
@@ -1391,6 +1434,17 @@ impl FileReadPort for FileBlobReadRepository {
fi.blob_hash, \
\
fi.created_by, fi.updated_by, \
EXISTS ( \
SELECT 1 FROM auth.user_favorites uf \
WHERE uf.user_id = $1 \
AND uf.item_id = fi.id::text \
AND uf.item_type = 'file' \
) AS is_favorite, \
EXISTS ( \
SELECT 1 FROM storage.role_grants g \
WHERE g.resource_id = fi.id \
AND g.resource_type = 'file' \
) AS is_shared, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -1414,6 +1468,8 @@ impl FileReadPort for FileBlobReadRepository {
String,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
bool, // is_favorite
bool, // is_shared
i64, // total_count
),
>(&sql)
@@ -1434,20 +1490,24 @@ impl FileReadPort for FileBlobReadRepository {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let total_count = rows.first().map_or(0, |r| r.11) as usize;
let total_count = rows.first().map_or(0, |r| r.13) as usize;
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
// Pre-size both Vecs, keep caller_flags aligned by index.
let mut files = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
let mut caller_flags = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
rows
{
files.push(
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
})?,
);
caller_flags.push((is_fav, is_shr));
}
Ok((files, total_count))
Ok((files, caller_flags, total_count))
}
#[allow(clippy::type_complexity)]
@@ -49,6 +49,48 @@ type FolderRow = (
Option<Uuid>,
);
/// `FolderRow` + trailing `(is_favorite, is_shared)` — the caller-scoped
/// booleans populated by per-row EXISTS in `search_folders`. Split out so
/// the many `sqlx::query_as::<_, FolderRowWithFlags>(...)` sites stay
/// terse instead of repeating a 12-element inline tuple.
type FolderRowWithFlags = (
Uuid,
String,
String,
Option<Uuid>,
Uuid,
i64,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
bool,
bool,
);
/// Return shape of `search_folders`: two parallel vecs aligned 1:1
/// (folder domain entity + `(is_favorite, is_shared)` caller flags).
/// Kept as a type alias so the SQL builder + trait implementations
/// share one name (`clippy::type_complexity`).
pub(crate) type FoldersWithFlags = (Vec<Folder>, Vec<(bool, bool)>);
/// Convert `FolderRowWithFlags` rows into the `(Vec<Folder>, caller_flags)`
/// pair `search_folders` returns. Keeps the two parallel vecs aligned
/// 1:1 by index.
fn build_folders_with_flags(
rows: Vec<FolderRowWithFlags>,
) -> Result<FoldersWithFlags, DomainError> {
let mut folders = Vec::with_capacity(rows.len());
let mut flags = Vec::with_capacity(rows.len());
for (id, name, path, pid, did, ca, ma, tma, cb, ub, is_fav, is_shr) in rows {
let folder =
FolderDbRepository::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)?;
folders.push(folder);
flags.push((is_fav, is_shr));
}
Ok((folders, flags))
}
/// Type alias for paginated folder rows (includes total_count as
/// the last element after the §14 provenance columns). Same
/// column set as [`FolderRow`] plus the trailing count.
@@ -1048,12 +1090,20 @@ impl FolderRepository for FolderDbRepository {
name_contains: Option<&str>,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder scope → existing optimised ltree scan
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
// Recursive with folder scope → existing optimised ltree scan.
// `list_descendant_folders` doesn't compute caller_flags today —
// return `(false, false)` per row until the ltree path is
// upgraded in a follow-up. Bounded UI impact: subtree-scoped
// searches rarely surface a specific folder as favorited /
// shared, and the flags path elsewhere fills the gap for the
// hot `/api/search` case.
if recursive && let Some(fid) = parent_id {
return self
let folders = self
.list_descendant_folders(fid, name_contains, caller_id)
.await;
.await?;
let flags = vec![(false, false); folders.len()];
return Ok((folders, flags));
}
// Build optional name filter — use ILIKE (case-insensitive) so the
@@ -1070,6 +1120,21 @@ impl FolderRepository for FolderDbRepository {
_ => ("", None),
};
// Per-row caller-flag EXISTS subqueries. Same pattern as
// `search_files_paginated` in the sibling file repo: narrow
// indexes make this a few extra μs per row.
const FAV_SHR_COLUMNS: &str = "EXISTS ( \
SELECT 1 FROM auth.user_favorites uf \
WHERE uf.user_id = $1 \
AND uf.item_id = fo.id::text \
AND uf.item_type = 'folder' \
) AS is_favorite, \
EXISTS ( \
SELECT 1 FROM storage.role_grants g \
WHERE g.resource_id = fo.id \
AND g.resource_type = 'folder' \
) AS is_shared";
if recursive {
// Recursive, no folder scope → ALL folders in caller's readable drives
let sql = format!(
@@ -1078,7 +1143,8 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
fo.created_by, fo.updated_by, \
{FAV_SHR_COLUMNS} \
FROM storage.folders fo \
WHERE {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
@@ -1086,7 +1152,7 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name"
);
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
let rows: Vec<FolderRowWithFlags> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(caller_id)
.bind(pattern)
@@ -1100,12 +1166,7 @@ impl FolderRepository for FolderDbRepository {
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
return rows
.into_iter()
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect();
return build_folders_with_flags(rows);
}
// Non-recursive: direct children of parent_id, restricted to drives
@@ -1117,7 +1178,8 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
fo.created_by, fo.updated_by, \
{FAV_SHR_COLUMNS} \
FROM storage.folders fo \
WHERE fo.parent_id = $2::uuid \
AND {CALLER_CAN_READ_DRIVE} \
@@ -1137,7 +1199,8 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
fo.created_by, fo.updated_by, \
{FAV_SHR_COLUMNS} \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND {CALLER_CAN_READ_DRIVE} \
@@ -1147,7 +1210,7 @@ impl FolderRepository for FolderDbRepository {
)
};
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
let rows: Vec<FolderRowWithFlags> = if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(caller_id)
@@ -1176,11 +1239,7 @@ impl FolderRepository for FolderDbRepository {
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
build_folders_with_flags(rows)
}
/// Lists all descendant folders in a subtree using ltree GiST index,
+59 -149
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use tracing::{error, info};
use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
SearchResourcesDto, SearchResourcesQuery, SearchSuggestionsDto,
};
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
@@ -39,12 +39,32 @@ impl SearchHandler {
// so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP
// verb or annotation content. All route handlers are free functions below.
// TODO: collapse after utoipa upgrade.
pub(super) async fn search_files_get_impl(
/// `GET /api/search` — wire-normalised search endpoint.
///
/// Returns the same `items[] { resource_type, resource, meta }`
/// envelope shape as every other `/*/resources` listing endpoint
/// (folders, favorites, recent, trash, shared) so the SPA's
/// `ResourceList` component consumes it as-is. Search-specific
/// enrichment (`meta.score` + optional `snippet` + `via`) sits
/// inline on each item.
///
/// Phase 1-plus wire adapter: the internal `SearchService` still
/// speaks `SearchCriteriaDto`/`SearchResultsDto`. The query is
/// translated at this boundary; the result envelope is composed
/// via `SearchResourcesDto::from_service_result`. The `is_favorite`
/// / `is_shared` fields on each `FileDto`/`FolderDto` come from
/// per-row EXISTS subqueries in the search SQL (see
/// `search_files_paginated` and `search_folders`).
///
/// The old `POST /api/search/advanced` variant was deleted in
/// the same PR — every field it accepted fits cleanly as a query
/// param.
pub(super) async fn search_resources_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<SearchParams>,
Query(query): Query<SearchResourcesQuery>,
) -> impl IntoResponse {
info!("API: File search with parameters: {:?}", params);
info!("API: File search (normalized envelope)");
let search_service = match &state.applications.search_service {
Some(service) => service,
@@ -58,25 +78,13 @@ impl SearchHandler {
}
};
let search_criteria = SearchCriteriaDto {
name_contains: params.query,
file_types: params
.type_filter
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()),
created_after: params.created_after,
created_before: params.created_before,
modified_after: params.modified_after,
modified_before: params.modified_before,
min_size: params.min_size,
max_size: params.max_size,
folder_id: params.folder_id,
recursive: params.recursive.unwrap_or(true),
limit: params.limit.unwrap_or(100).min(MAX_SEARCH_LIMIT),
offset: params.offset.unwrap_or(0),
sort_by: params.sort_by.unwrap_or_else(|| "relevance".to_string()),
};
// Cap page size — `SearchResourcesQuery::limit_clamped` already
// hits `[1, 200]`, but re-clamp against MAX_SEARCH_LIMIT for
// defence-in-depth if the constant is ever raised above 200.
let mut criteria = query.to_criteria();
criteria.limit = criteria.limit.min(MAX_SEARCH_LIMIT);
match search_service.search(search_criteria, auth_user.id).await {
match search_service.search(criteria, auth_user.id).await {
Ok(results) => {
info!(
"Search completed in {}ms — {} files, {} folders",
@@ -84,62 +92,18 @@ impl SearchHandler {
results.files.len(),
results.folders.len()
);
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Search error" })),
// Unwrap the Arc — the service caches `Arc<SearchResultsDto>`
// so consumers share the allocation. `from_service_result`
// consumes the DTO to move enriched rows into the envelope's
// `resource` slot without cloning; the Arc's shared clone
// pays one deep copy here but avoids allocating during the
// hot cache-hit path elsewhere.
let dto = SearchResourcesDto::from_service_result((*results).clone(), &query);
let rows = dto.items.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&dto,
)
.into_response()
}
}
}
/// Advanced search with full criteria in the request body.
pub(super) async fn search_files_post_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(criteria): Json<SearchCriteriaDto>,
) -> impl IntoResponse {
info!("API: Advanced file search");
let search_service = match &state.applications.search_service {
Some(service) => service,
None => {
error!("Search service not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response();
}
};
match search_service.search(criteria, auth_user.id).await {
Ok(results) => {
info!(
"Advanced search completed in {}ms — {} files, {} folders",
results.query_time_ms,
results.files.len(),
results.folders.len()
);
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
@@ -258,50 +222,6 @@ impl SearchHandler {
}
}
/// Search parameters for the GET /search endpoint
#[derive(Debug, serde::Deserialize)]
pub struct SearchParams {
/// Text to search in file and folder names
pub query: Option<String>,
/// Filter by file types (comma-separated extensions)
#[serde(rename = "type")]
pub type_filter: Option<String>,
/// Created after this timestamp
pub created_after: Option<u64>,
/// Created before this timestamp
pub created_before: Option<u64>,
/// Modified after this timestamp
pub modified_after: Option<u64>,
/// Modified before this timestamp
pub modified_before: Option<u64>,
/// Minimum file size in bytes
pub min_size: Option<u64>,
/// Maximum file size in bytes
pub max_size: Option<u64>,
/// Folder ID to limit the search scope
pub folder_id: Option<String>,
/// Recursive search in subfolders (default: true)
pub recursive: Option<bool>,
/// Result limit for pagination
pub limit: Option<usize>,
/// Offset for pagination
pub offset: Option<usize>,
/// Sort order: relevance | name | name_desc | date | date_desc | size | size_desc
pub sort_by: Option<String>,
}
/// Parameters for the GET /search/suggest endpoint
#[derive(Debug, serde::Deserialize)]
pub struct SuggestParams {
@@ -334,45 +254,35 @@ pub struct SuggestParams {
get,
path = "/api/search",
params(
("query" = Option<String>, Query, description = "Text to search in names"),
("type" = Option<String>, Query, description = "Comma-separated MIME type filter"),
("query" = Option<String>, Query, description = "Text to search in names / content"),
("limit" = Option<u32>, Query, description = "Max items per page (1–200, default 50)"),
("cursor" = Option<String>, Query, description = "Opaque cursor from a previous response"),
("order_by" = Option<String>, Query, description = "Sort dimension: relevance (default) | name | size | updated_at | created_at"),
("resource_types" = Option<String>, Query, description = "Comma-separated: file, folder (both by default)"),
("reverse" = Option<bool>, Query, description = "Reverse the sort order"),
("type" = Option<String>, Query, description = "Filter by file extensions (comma-separated)"),
("folder_id" = Option<String>, Query, description = "Restrict search to this folder"),
("recursive" = Option<bool>, Query, description = "Include sub-folders"),
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Pagination offset"),
("recursive" = Option<bool>, Query, description = "Recurse into subfolders (default true)"),
("created_after" = Option<u64>, Query, description = "Minimum creation timestamp (unix seconds)"),
("created_before" = Option<u64>, Query, description = "Maximum creation timestamp"),
("modified_after" = Option<u64>, Query, description = "Minimum modification timestamp"),
("modified_before" = Option<u64>, Query, description = "Maximum modification timestamp"),
("min_size" = Option<u64>, Query, description = "Minimum file size (bytes)"),
("max_size" = Option<u64>, Query, description = "Maximum file size (bytes)"),
),
responses(
(status = 200, description = "Search results", body = SearchResultsDto),
(status = 200, description = "Search results (cursor-paginated envelope shared with /*/resources)", body = SearchResourcesDto),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
)]
pub async fn search_files_get(
pub async fn search_resources(
state: State<Arc<AppState>>,
auth_user: AuthUser,
query: Query<SearchParams>,
query: Query<SearchResourcesQuery>,
) -> impl IntoResponse {
SearchHandler::search_files_get_impl(state, auth_user, query).await
}
#[utoipa::path(
post,
path = "/api/search/advanced",
request_body(content = SearchCriteriaDto, content_type = "application/json", description = "Search criteria"),
responses(
(status = 200, description = "Search results", body = SearchResultsDto),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
)]
pub async fn search_files_post(
state: State<Arc<AppState>>,
auth_user: AuthUser,
json: Json<SearchCriteriaDto>,
) -> impl IntoResponse {
SearchHandler::search_files_post_impl(state, auth_user, json).await
SearchHandler::search_resources_impl(state, auth_user, query).await
}
#[utoipa::path(
+12 -5
View File
@@ -34,8 +34,8 @@ use crate::application::dtos::i18n_dto::{
use crate::application::dtos::pagination::{PaginationDto, PaginationRequestDto};
use crate::application::dtos::recent_dto::{RecentItemDto, RecentResourceItemDto};
use crate::application::dtos::search_dto::{
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
SearchSuggestionItem, SearchSuggestionsDto,
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchMeta, SearchResourceItem,
SearchResourcesDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto,
};
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
use crate::application::dtos::trash_dto::{
@@ -103,8 +103,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::folder_handler::delete_folder_with_trash,
handlers::folder_handler::download_folder_zip,
// Search handlers (free functions — see search_handler.rs for why)
handlers::search_handler::search_files_get,
handlers::search_handler::search_files_post,
handlers::search_handler::search_resources,
handlers::search_handler::suggest_files,
handlers::search_handler::clear_search_cache,
// i18n handlers (free functions — see i18n_handler.rs for why)
@@ -302,7 +301,15 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
MoveToTrashRequest,
RestoreFromTrashRequest,
DeletePermanentlyRequest,
// Search schemas
// Search schemas — wire envelope shares the /*/resources
// shape (SearchResourcesDto → items[] { resource_type,
// resource, meta }). The internal SearchCriteriaDto /
// SearchResultsDto types are still emitted so external
// consumers browsing the OpenAPI doc can see the service-
// layer shape referenced by other docs.
SearchResourcesDto,
SearchResourceItem,
SearchMeta,
SearchCriteriaDto,
SearchResultsDto,
SearchFileResultDto,
+7 -6
View File
@@ -88,9 +88,7 @@ use crate::interfaces::api::handlers::folder_handler::{
use crate::interfaces::api::handlers::i18n_handler::{
get_locales, get_translations_by_locale, translate,
};
use crate::interfaces::api::handlers::search_handler::{
search_files_get, search_files_post, suggest_files,
};
use crate::interfaces::api::handlers::search_handler::{search_resources, suggest_files};
use crate::interfaces::api::handlers::trash_handler;
/// Creates root-level health check routes — mounted directly at `/`, not under `/api/`.
@@ -298,11 +296,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let search_router = if search_service.is_some() {
Router::new()
// Simple search with query parameters
.route("/", get(search_files_get))
// Cursor-paginated search with the `/*/resources` envelope
// (items + next_cursor + meta). `POST /search/advanced` was
// deleted alongside this normalization — every field it
// accepted fits fine as a query param, and it shared 100%
// of the service call with GET (no fast-vs-deep semantics).
.route("/", get(search_resources))
// Lightweight autocomplete suggestions
.route("/suggest", get(suggest_files))
// Advanced search with full criteria object
.route("/advanced", post(search_files_post))
// `DELETE /api/search/cache` used to live here as a per-user-
// reachable endpoint. It's an operator-only debug lever
// (moka `invalidate_all()` — nukes every tenant), so it
+17 -14
View File
@@ -126,7 +126,12 @@ Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.files" count >= 1
# `/api/search` was normalised to the `/*/resources` envelope in
# PR search-normalize (2026-07): items[] carry `resource_type` +
# a `resource` (File | Folder | Drive) + inline search-meta. This
# assertion checks the same anti-regression property as before
# (needle file surfaces to its owner) against the new wire shape.
jsonpath "$.items" count >= 1
body contains "{{needle_file_id}}"
@@ -140,8 +145,7 @@ Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.files" count == 0
jsonpath "$.folders" count == 0
jsonpath "$.items" count == 0
# ─────────────────────────────────────────────────────────────
@@ -236,7 +240,7 @@ delay: 2500ms
HTTP 200
[Asserts]
# Admin sees the content match — proves indexing landed.
jsonpath "$.files" count >= 1
jsonpath "$.items" count >= 1
body contains "{{canary_file_id}}"
@@ -248,23 +252,22 @@ HTTP 200
# Bob has no access to admin's drive → Tantivy's Must-clause
# filters every doc that doesn't carry one of Bob's drive_ids,
# so the file vanishes entirely.
jsonpath "$.files" count == 0
jsonpath "$.folders" count == 0
jsonpath "$.items" count == 0
body not contains "{{canary_file_id}}"
body not contains "ContentIndexCanaryXyzzy2026Drive"
# Anti-enum: every count the response surfaces must reflect the
# FILTERED set — i.e. zero when the caller has no accessible
# hits. The §11 rule is "no 'you have N hidden matches' field
# anywhere". `total_count` is a legitimate pagination count and
# is OK as long as it equals the filtered total (zero here). The
# other field names below MUST stay absent: a future field
# called `hidden_count`/`filtered`/etc. that reveals matches
# Bob can't see would be the regression.
jsonpath "$.total_count" == 0
jsonpath "$.has_more" == false
# anywhere". `total` is the visible-to-caller count (permission-
# filtered SUM); it's OK when it equals the visible total (zero
# here). Old `total_count` / `has_more` names are retired with
# the `files/folders` split. Names below MUST stay absent — a
# future field like `hidden_count`/`filtered`/etc. that reveals
# matches Bob can't see would be the regression.
jsonpath "$.total" == 0
jsonpath "$.next_cursor" not exists
jsonpath "$.hidden_count" not exists
jsonpath "$.filtered" not exists
jsonpath "$.total" not exists
# ─────────────────────────────────────────────────────────────