Compare commits

4 Commits

Author SHA1 Message Date
cjw 27942a2a72 fix(search): stop dropping the name filter for short queries
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Has been cancelled
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Has been cancelled
CI / Plugins — fixtures + runtime tests (push) Has been cancelled
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Has been cancelled
CI / Migration ordering (new migrations postdate target branch) (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Wasm — fmt + clippy (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
CI / Wasm — release tests (push) Has been cancelled
CI / Server Unit and Functionnal Tests (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
The SQL repositories gated the name ILIKE condition on name.len() >= 3
*bytes*, so a 1-2 character search (e.g. "ab") silently returned an
arbitrary page of the caller's files instead of matches — while the
suggest dropdown (which never had the gate) still found them, making the
top-bar search feel broken. The gate existed because the pg_trgm GIN
index cannot accelerate sub-trigram patterns, but wrong-but-indexed is
never acceptable: caller/folder scoping still bounds the scanned set and
result pages are LIMIT-bound.

Add a shared name_filter_active() predicate next to like_escape() (any
non-blank query filters) and use it at all 7 gate sites — 4 in
file_blob_read_repository (condition/bind pairs kept in lockstep so bind
indices stay aligned), 3 match guards in folder_db_repository. The
Tantivy >= 2 gate on the content index is deliberately left alone: it
means "too-short tokens don't enter the full-text index", not a
correctness gate.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 01:10:39 +08:00
cjw bd2c7dc45e refactor(frontend): remove the files-page filter bar; top bar owns filtering
The top bar's unified search box + filter panel (29d0c335) already drives
the same shared filter store, so the files-page SearchFilterBar had become
a second, redundant input. Remove the component and its mount; the shared
store, URL sync and the files page's in-place search mode all stay — the
top bar is now the only filter writer. Drop the orphaned locale keys
(filter.placeholder/keyword/clear_keyword) from all 16 locales and rework
the affected tests to drive the store directly, with a resetFilters() +
URL reset in beforeEach to keep the module-level store from leaking.

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

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

Backend unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 15:40:20 +08:00
35 changed files with 1527 additions and 597 deletions
+131 -14
View File
@@ -13,15 +13,22 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte'; import DrivePicker from '$lib/components/DrivePicker.svelte';
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
import TopBarFilterPanel from '$lib/components/TopBarFilterPanel.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display'; import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
import {
activeFilterCount,
filterParamsToString,
filterToSearchOptions
} from '$lib/utils/searchFilters';
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { serverConfig } from '$lib/stores/serverConfig.svelte';
import { serverStatus } from '$lib/stores/serverStatus.svelte'; import { serverStatus } from '$lib/stores/serverStatus.svelte';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { dialogs } from '$lib/stores/dialogs.svelte'; import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { preferences } from '$lib/stores/preferences.svelte'; import { preferences } from '$lib/stores/preferences.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte';
@@ -309,7 +316,9 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let searchQuery = $state(''); let searchQuery = $state('');
/** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */ /** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */
let searchActive = $state(false); let mobileSearchOpen = $state(false);
/** Top-bar filter dropdown (opens from the button inside the search input). */
let filterPanelOpen = $state(false);
let langOpen = $state(false); let langOpen = $state(false);
let aboutOpen = $state(false); let aboutOpen = $state(false);
let appVersion = $state(''); let appVersion = $state('');
@@ -348,15 +357,26 @@
const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread)); const totalUnreadBadge = $derived(totalUnread > 99 ? '99+' : String(totalUnread));
function openMobileSearch() { function openMobileSearch() {
searchActive = true; mobileSearchOpen = true;
requestAnimationFrame(() => searchInputEl?.focus()); requestAnimationFrame(() => searchInputEl?.focus());
} }
function closeMobileSearch() { function closeMobileSearch() {
searchActive = false; mobileSearchOpen = false;
filterPanelOpen = false;
clearSearch(); clearSearch();
} }
function toggleFilterPanel() {
filterPanelOpen = !filterPanelOpen;
if (filterPanelOpen) {
// The two overlays would stack (both anchor to .search-container's
// bottom) — the panel wins while it's open; suggestions resume on
// the next input event after it closes.
suggestOpen = false;
}
}
async function openAbout() { async function openAbout() {
menuOpen = false; menuOpen = false;
aboutOpen = true; aboutOpen = true;
@@ -378,6 +398,10 @@
let suggestions = $state<Suggestion[]>([]); let suggestions = $state<Suggestion[]>([]);
let suggestOpen = $state(false); let suggestOpen = $state(false);
let suggestBusy = $state(false); let suggestBusy = $state(false);
// Live count of active filter presets — drives the toggle button's badge
// and its "filters applied" highlight. The shared store is also what the
// panel (below) and the /search page mutate.
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
let suggestTimer: ReturnType<typeof setTimeout> | null = null; let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Stale-response guard (same family as the search page): the debounce // Stale-response guard (same family as the search page): the debounce
// spaces requests out but doesn't stop a SLOW earlier response from // spaces requests out but doesn't stop a SLOW earlier response from
@@ -389,16 +413,21 @@
const q = searchQuery.trim(); const q = searchQuery.trim();
if (!q) return; if (!q) return;
suggestOpen = false; suggestOpen = false;
searchActive = false; filterPanelOpen = false;
mobileSearchOpen = false;
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; these params don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
// Active filter presets ride along (`type=image&kind=file&…`) — the
// shared store is what the top-bar panel and the /search page
// mutate, so submitting from here carries exactly what the user sees.
const filterQs = filterParamsToString(resourceFilters);
if (filterQs) parts.push(filterQs);
// Carry the currently-open folder into the search URL as `?in=<uuid>` // Carry the currently-open folder into the search URL as `?in=<uuid>`
// so a hard refresh, a shared link, or a bookmark all restore the // so a hard refresh, a shared link, or a bookmark all restore the
// "This folder" scope. Trash section is always global — skip. See // "This folder" scope. Trash section is always global — skip. See
// `/search/+page.svelte` for the receiver side. // `/search/+page.svelte` for the receiver side.
//
// Built by hand instead of via `URLSearchParams` because the Svelte
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
// variant; the two params here don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
if (filesStore.currentFolder && filesStore.section !== 'trash') { if (filesStore.currentFolder && filesStore.section !== 'trash') {
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`); parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
} }
@@ -411,6 +440,8 @@
} }
function onSearchInput() { function onSearchInput() {
// The filter panel owns the dropdown area while open — don't fight it.
if (filterPanelOpen) return;
if (suggestTimer) clearTimeout(suggestTimer); if (suggestTimer) clearTimeout(suggestTimer);
const q = searchQuery.trim(); const q = searchQuery.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -428,7 +459,13 @@
suggestInflight = ctl; suggestInflight = ctl;
suggestBusy = true; suggestBusy = true;
try { try {
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal }); // Suggestions honor the active filter presets, mirroring what
// "See all results" will show on /search.
const r = await searchResources(q, {
...filterToSearchOptions(resourceFilters),
limit: 9,
signal: ctl.signal
});
if (seq !== suggestSeq) return; // superseded while awaiting if (seq !== suggestSeq) return; // superseded while awaiting
// The wire is ordered — folders first, then files — but slice // The wire is ordered — folders first, then files — but slice
// per kind explicitly so the header preview stays a folder-heavy // per kind explicitly so the header preview stays a folder-heavy
@@ -491,6 +528,22 @@
langOpen = false; langOpen = false;
} }
/**
* Window-level click: the existing close-outside-click for the menus,
* plus the same treatment for the top-bar filter panel. Anything inside
* the search container — the input, the toggle, the panel itself — keeps
* the panel open (same `closest` guard as DisplayModeControls).
*/
function onGlobalClick(e: MouseEvent) {
closeMenus();
if (
filterPanelOpen &&
!(e.target instanceof Element && e.target.closest('.search-container'))
) {
filterPanelOpen = false;
}
}
/** /**
* True when the shortcut target is a text-input surface — <input>, * True when the shortcut target is a text-input surface — <input>,
* <textarea>, or any `contenteditable` element. Used by the * <textarea>, or any `contenteditable` element. Used by the
@@ -563,7 +616,7 @@
</script> </script>
<svelte:window <svelte:window
onclick={closeMenus} onclick={onGlobalClick}
onkeydown={(e) => { onkeydown={(e) => {
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted, // First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
// the palette's own handler takes over toggling/closing. // the palette's own handler takes over toggling/closing.
@@ -597,7 +650,8 @@
} }
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (aboutOpen) aboutOpen = false; if (aboutOpen) aboutOpen = false;
else if (searchActive) closeMobileSearch(); else if (filterPanelOpen) filterPanelOpen = false;
else if (mobileSearchOpen) closeMobileSearch();
else closeMenus(); else closeMenus();
}} }}
/> />
@@ -702,7 +756,7 @@
</div> </div>
<div class="main-content"> <div class="main-content">
<div class="top-bar" class:top-bar--search-active={searchActive}> <div class="top-bar" class:top-bar--search-active={mobileSearchOpen}>
<button <button
class="sidebar-toggle" class="sidebar-toggle"
aria-label={t('nav.toggle', 'Toggle navigation menu')} aria-label={t('nav.toggle', 'Toggle navigation menu')}
@@ -760,6 +814,24 @@
<Icon name="times" /> <Icon name="times" />
</button> </button>
{/if} {/if}
<!-- Filter toggle sits inside the input's right edge (between the
clear × and the submit button) and opens the shared filter
panel — the same state the files page's filter bar edits. -->
<button
class="search-filter-btn"
class:search-filter-btn--active={filterPanelOpen || filterActiveCount > 0}
type="button"
aria-expanded={filterPanelOpen}
title={t('filter.advanced', 'Filters')}
aria-label={t('filter.advanced', 'Filters')}
data-testid="appshell-filter-toggle-btn"
onclick={toggleFilterPanel}
>
<Icon name="sliders-h" />
{#if filterActiveCount > 0}
<span class="search-filter-btn__badge">{filterActiveCount}</span>
{/if}
</button>
<button <button
class="search-button" class="search-button"
type="submit" type="submit"
@@ -770,6 +842,10 @@
<Icon name="search" /> <Icon name="search" />
</button> </button>
{#if filterPanelOpen}
<TopBarFilterPanel value={resourceFilters} onclose={() => (filterPanelOpen = false)} />
{/if}
{#if suggestOpen} {#if suggestOpen}
<ul class="suggest"> <ul class="suggest">
{#each suggestions as s (s.kind + s.item.id)} {#each suggestions as s (s.kind + s.item.id)}
@@ -1223,7 +1299,7 @@
/* Clear (×) button sits left of the submit button inside the search field. */ /* Clear (×) button sits left of the submit button inside the search field. */
.search-clear { .search-clear {
position: absolute; position: absolute;
right: 44px; right: 72px;
display: grid; display: grid;
place-items: center; place-items: center;
width: 28px; width: 28px;
@@ -1240,6 +1316,47 @@
color: var(--color-text); color: var(--color-text);
} }
/* Filter toggle inside the input's right edge: submit button at 6px,
clear × at 72px, this one between them at 44px. The badge counts the
active presets, mirroring the files page's filter-bar toggle. */
.search-filter-btn {
position: absolute;
right: 44px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.search-filter-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.search-filter-btn--active {
color: var(--color-accent);
}
.search-filter-btn__badge {
position: absolute;
top: -2px;
right: -3px;
min-width: 13px;
height: 13px;
padding: 0 2px;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 9px;
line-height: 13px;
text-align: center;
}
.suggest { .suggest {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
@@ -88,3 +88,49 @@ it('submits a search and routes to /search', async () => {
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn')); await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report')); await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report'));
}); });
it('opens the filter panel, and its presets ride along on submit', async () => {
render(AppShell, { props: { children } });
// Panel opens from the toggle inside the search input…
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
// …and picking a preset lights the toggle's badge (shared store).
await fireEvent.change(screen.getByTestId('filter-type-select'), {
target: { value: 'image' }
});
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent).toContain('1');
// Submitting carries the preset into the search URL.
const input = screen.getByTestId('appshell-search-input');
await fireEvent.input(input, { target: { value: 'report' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=report&type=image'));
// Submit closes the panel; reopen it and "Clear filters" resets the
// presets — the badge drops and the next submit is param-free.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
expect(screen.getByTestId('appshell-filter-toggle-btn').textContent!.includes('1')).toBe(false);
await fireEvent.input(input, { target: { value: 'again' } });
await fireEvent.click(screen.getByTestId('appshell-search-submit-btn'));
await waitFor(() => expect(goto).toHaveBeenCalledWith('/search?q=again'));
// Leave the shared store clean for the other tests in this file.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
await fireEvent.click(screen.getByTestId('appshell-filter-clear-btn'));
});
it('closes the filter panel on Escape and on outside click', async () => {
render(AppShell, { props: { children } });
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('appshell-filter-done-btn'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Outside click (window-level handler) reopens-then-closes too.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.click(screen.getByTestId('shell-child'));
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
// Escape closes it as well.
await fireEvent.click(screen.getByTestId('appshell-filter-toggle-btn'));
expect(screen.getByTestId('appshell-filter-panel')).toBeTruthy();
await fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByTestId('appshell-filter-panel')).toBeNull();
});
@@ -0,0 +1,130 @@
<!--
The active filter presets as dismissible chips, for surfaces whose filter
editing lives elsewhere (the top bar's panel) — /search after its in-page
selects were retired. Without these the page would show filtered results
with no visible way to see or undo why.
`value` is the shared state proxy, mutated in place — deliberately a plain
prop, not `$bindable`: there is exactly one state object (the store) and
nobody reassigns it, so a two-way binding contract would be noise.
-->
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
activeFilters,
clearFilterDimension,
filterDimensionLabelKey,
filterValueLabelKey,
type ResourceFilterState
} from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
}
let { value }: Props = $props();
const chips = $derived(activeFilters(value));
function clearAll() {
for (const { key } of activeFilters(value)) clearFilterDimension(value, key);
// A lingering recursive=0 would survive a "clear everything" otherwise.
value.recursive = true;
}
</script>
{#if chips.length > 0}
<div
class="fchips"
data-testid="filter-chips"
role="list"
aria-label={t('filter.advanced', 'Filters')}
>
{#each chips as chip (chip.key)}
<span class="fchips__chip" role="listitem" data-testid={`filter-chip-${chip.key}`}>
<span class="fchips__dim">{t(filterDimensionLabelKey(chip.key), chip.key)}</span>
<span class="fchips__val">{t(filterValueLabelKey(chip.key, chip.value), chip.value)}</span>
<button
class="fchips__dismiss"
type="button"
aria-label={t('common.clear', 'Clear')}
data-testid={`filter-chip-${chip.key}-dismiss-btn`}
onclick={() => clearFilterDimension(value, chip.key)}
>
<Icon name="times" />
</button>
</span>
{/each}
<button
class="fchips__clear-all"
type="button"
data-testid="filter-clear-all-btn"
onclick={clearAll}
>
{t('search.clear_filters', 'Clear filters')}
</button>
</div>
{/if}
<style>
.fchips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.fchips__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.3rem 0.15rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl, 999px);
background: var(--color-bg-surface);
font-size: var(--text-sm);
}
.fchips__dim {
color: var(--color-text-secondary);
}
.fchips__val {
color: var(--color-text);
font-weight: 500;
}
.fchips__dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.fchips__dismiss:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.fchips__clear-all {
padding: 0.15rem 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-accent);
font-size: var(--text-sm);
cursor: pointer;
}
.fchips__clear-all:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,178 @@
<!--
The filter dimensions themselves (type / size / modified / created / kind /
recursive), bound to the shared `ResourceFilterState`.
Extracted so the top bar's filter panel renders the same fields off the
same vocabulary the /search page chips name — the option lists used to
live in several copies. The vocabularies (extensions, byte bounds, date
presets) stay in `$lib/utils/searchFilters`; only the i18n labels live
here.
-->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte';
import {
type DateKey,
type KindKey,
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters';
interface Props {
/** Bindable filter state — the caller owns it (usually the shared store). */
value: ResourceFilterState;
/** `inline` wraps in a row (files page); `panel` uses a grid (top bar). */
layout?: 'inline' | 'panel';
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let { value = $bindable(), layout = 'inline', hideRecursive = false }: Props = $props();
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const KINDS: { v: KindKey; l: string }[] = [
{ v: 'all', l: t('filter.kind.all', 'Files and folders') },
{ v: 'file', l: t('filter.kind.file', 'Files only') },
{ v: 'folder', l: t('filter.kind.folder', 'Folders only') }
];
</script>
<div
class="filter-fields"
class:filter-fields--panel={layout === 'panel'}
data-testid="filter-fields"
>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.type_label', 'Type')}</span>
<select data-testid="filter-type-select" bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.kind_label', 'Items')}</span>
<select data-testid="filter-kind-select" bind:value={value.kind}>
{#each KINDS as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('search.size_label', 'Size')}</span>
<select data-testid="filter-size-select" bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.modified_label', 'Modified')}</span>
<select data-testid="filter-date-select" bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="filter-fields__field">
<span class="filter-fields__label">{t('filter.created_label', 'Created')}</span>
<select data-testid="filter-created-select" bind:value={value.created}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="filter-fields__check">
<input
type="checkbox"
data-testid="filter-recursive-checkbox"
bind:checked={value.recursive}
/>
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</div>
<style>
.filter-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
/* Panel shape: two even columns, so five selects plus the toggle do not
run off the width of the top bar's search slot. */
.filter-fields--panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.filter-fields__field {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.filter-fields__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.filter-fields__field select {
flex: 1;
min-width: 0;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
max-width: 9rem;
}
.filter-fields__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
@media (width <= 768px) {
.filter-fields--panel {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -1,354 +0,0 @@
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
defaultFilterState,
type DateKey,
type ResourceFilterState,
type SizeKey,
type TypeKey
} from '$lib/utils/searchFilters';
interface Props {
/** Bindable full filter state (keyword + toggles + presets). */
value?: ResourceFilterState;
/** Advanced section (type/size/date/recursive) expanded. */
expanded?: boolean;
placeholder?: string;
/** Debounce for the keyword input (ms). */
debounceMs?: number;
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let {
value = $bindable(defaultFilterState()),
expanded = $bindable(false),
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
debounceMs = 300,
hideRecursive = false
}: Props = $props();
// Option label lists reuse the /search page's i18n keys — the vocabularies
// themselves (extensions, byte bounds) live in the shared searchFilters util.
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
// Keyword buffer: typing updates the buffer immediately (responsive input)
// and pushes into `value.query` debounced, so a keystroke doesn't fire a
// backend search per character. `lastPushed` disambiguates our own pushes
// from external writes (e.g. the page's clear-filter Escape path), which
// flow back into the buffer via the sync effect below.
let keyword = $state(value.query);
let lastPushed = value.query;
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// External `value.query` change → adopt it into the input buffer.
const external = value.query;
if (external !== lastPushed) {
if (timer) {
clearTimeout(timer);
timer = null;
}
keyword = external;
lastPushed = external;
}
});
$effect(() => {
// No reactive deps — teardown-only, clearing a pending debounce on destroy.
return () => {
if (timer) clearTimeout(timer);
};
});
function pushKeyword(v: string) {
if (timer) {
clearTimeout(timer);
timer = null;
}
value.query = v;
lastPushed = v;
}
function handleInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
keyword = v;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
pushKeyword(v);
}, debounceMs);
}
function onInputKeydown(e: KeyboardEvent) {
// Escape clears the keyword locally and never reaches the page-level
// handler (which would otherwise also clear the selection / filters).
if (e.key === 'Escape') {
e.stopPropagation();
clearKeyword();
} else if (e.key === 'Enter') {
// Enter flushes the debounce for an immediate search.
e.preventDefault();
pushKeyword(keyword);
}
}
function clearKeyword() {
keyword = '';
pushKeyword('');
}
const activeCount = $derived(
(value.type !== 'all' ? 1 : 0) + (value.size !== 'all' ? 1 : 0) + (value.date !== 'all' ? 1 : 0)
);
</script>
<div class="sfb" data-testid="search-filter-bar">
<div class="sfb__row">
<div class="sfb__input-wrap">
<span class="sfb__magnifier"><Icon name="search" /></span>
<input
class="sfb__input"
type="search"
{placeholder}
aria-label={t('filter.keyword', 'Keyword')}
data-testid="filter-keyword-input"
value={keyword}
oninput={handleInput}
onkeydown={onInputKeydown}
/>
{#if keyword.length > 0}
<button
class="sfb__clear"
type="button"
aria-label={t('filter.clear_keyword', 'Clear search')}
data-testid="filter-clear-keyword-btn"
onclick={clearKeyword}
>
<Icon name="times" />
</button>
{/if}
</div>
<button
class="sfb__toggle"
class:sfb__toggle--active={activeCount > 0}
type="button"
aria-expanded={expanded}
aria-label={t('filter.advanced', 'Filters')}
title={t('filter.advanced', 'Filters')}
data-testid="filter-advanced-toggle-btn"
onclick={() => (expanded = !expanded)}
>
<Icon name="sliders-h" />
{#if activeCount > 0}
<span class="sfb__badge">{activeCount}</span>
{/if}
</button>
</div>
{#if expanded}
<div class="sfb__advanced" data-testid="filter-advanced-row">
<label class="sfb__field">
<span class="sfb__label">{t('search.type_label', 'Type')}</span>
<select bind:value={value.type}>
{#each TYPES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.size_label', 'Size')}</span>
<select bind:value={value.size}>
{#each SIZES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
<label class="sfb__field">
<span class="sfb__label">{t('search.date_label', 'Date')}</span>
<select bind:value={value.date}>
{#each DATES as opt (opt.v)}
<option value={opt.v}>{opt.l}</option>
{/each}
</select>
</label>
{#if !hideRecursive}
<label class="sfb__check">
<input type="checkbox" bind:checked={value.recursive} />
<span>{t('filter.recursive', 'Include subfolders')}</span>
</label>
{/if}
</div>
{/if}
</div>
<style>
.sfb {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 100%;
}
.sfb__row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.sfb__input-wrap {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.sfb__magnifier {
position: absolute;
left: 0.6rem;
color: var(--color-text-secondary);
pointer-events: none;
}
.sfb__input {
width: 100%;
padding: 0.45rem 2rem 0.45rem 2.1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.sfb__input:focus {
outline: none;
border-color: var(--color-accent);
}
.sfb__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.sfb__clear {
position: absolute;
right: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.sfb__clear:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.sfb__toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
flex-shrink: 0;
}
.sfb__toggle:hover {
background: var(--color-bg-hover);
}
.sfb__toggle--active {
border-color: var(--color-accent);
color: var(--color-accent);
}
.sfb__badge {
position: absolute;
top: -0.4rem;
right: -0.4rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: var(--text-xs, 0.7rem);
line-height: 1rem;
text-align: center;
}
.sfb__advanced {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.sfb__field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.sfb__label {
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.sfb__field select {
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
max-width: 9rem;
}
.sfb__check {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
</style>
@@ -0,0 +1,122 @@
<!--
The top bar's filter dropdown — the panel that opens from the button inside
the search input. A shell around the shared `FilterFields` plus a
clear-presets footer; positioning assumes it is rendered inside
`.search-container` (position: relative), same stacking trick as the
suggestions list.
Mutates the shared state proxy in place — plain prop, not `$bindable` (see
FilterChips for the rationale).
-->
<script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { activeFilterCount, type ResourceFilterState } from '$lib/utils/searchFilters';
interface Props {
value: ResourceFilterState;
/** Dismisses the panel (the clear button keeps it open on the files page). */
onclose: () => void;
}
let { value, onclose }: Props = $props();
const activeCount = $derived(activeFilterCount(value));
function clearPresets() {
for (const dim of ['type', 'size', 'date', 'created', 'kind'] as const) {
value[dim] = 'all';
}
value.recursive = true;
}
</script>
<div
class="tfp"
data-testid="appshell-filter-panel"
role="group"
aria-label={t('filter.advanced', 'Filters')}
>
<FilterFields layout="panel" {value} />
<div class="tfp__footer">
<button
class="tfp__clear"
type="button"
data-testid="appshell-filter-clear-btn"
disabled={activeCount === 0 && value.recursive}
onclick={clearPresets}
>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
<button
class="tfp__done"
type="button"
data-testid="appshell-filter-done-btn"
onclick={onclose}
>
{t('common.done', 'Done')}
</button>
</div>
</div>
<style>
.tfp {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, var(--radius-md));
background: var(--color-bg-surface);
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
}
.tfp__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.tfp__clear,
.tfp__done {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: none;
border-radius: var(--radius-sm);
background: none;
font-size: var(--text-sm);
cursor: pointer;
}
.tfp__clear {
color: var(--color-accent);
}
.tfp__clear:disabled {
color: var(--color-text-secondary);
cursor: default;
}
.tfp__clear:not(:disabled):hover {
background: var(--color-bg-hover);
}
.tfp__done {
background: var(--color-accent);
color: var(--color-on-accent);
}
.tfp__done:hover {
filter: brightness(1.05);
}
</style>
@@ -0,0 +1,60 @@
/**
* Two-way sync between the shared filter store and the current page's URL.
*
* The store is the single source of truth; the URL is its projection. One
* `$effect` serves both directions, disambiguated by tracking the last URL
* search string we processed:
*
* - URL changed (navigation, Back/Forward, a shared link) → the store adopts
* the URL's filter params via `hydrateFilters`.
* - Store changed (a select moved, keyword debounced in) → the new state is
* written back with `goto(..., { replaceState: true })` so refresh,
* bookmarks and Back all see it — without adding a history entry per edit.
*
* Convergence: after a write, `page.url` catches up to what we wrote, the
* `lastSeenSearch` guard recognizes it as our own echo and the store is left
* alone — the loop settles in one round-trip instead of ping-ponging.
*
* `includeQuery` decides who owns `?q=`: true on the files page (the filter
* bar's keyword belongs in the URL there), false on /search (the top-bar
* search box writes `?q=` itself; a second writer would fight it).
*/
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { hydrateFilters, resourceFilters } from '$lib/stores/filter.svelte';
import { filterSearchOver, filtersFromParams } from '$lib/utils/searchFilters';
export function useFilterUrlSync(includeQuery: boolean): void {
let lastSeenSearch: string | null = null;
$effect(() => {
const current = page.url;
if (current.search !== lastSeenSearch) {
// External URL change → URL wins. `untrack` because `hydrateFilters`
// mutates the store we read below; the mutation schedules this
// effect's next run, where `lastSeenSearch` already matches and the
// adoption is skipped.
lastSeenSearch = current.search;
untrack(() => hydrateFilters(filtersFromParams(current.searchParams)));
}
// Store → URL: overwrite only the filter params so the surface's own
// params (`?file=`, `in`, `scope`) survive untouched. The desired
// search string is composed in `searchFilters` — a throwaway `new URL`
// copy here would trip svelte/prefer-svelte-reactivity.
const desired = filterSearchOver(current.searchParams, resourceFilters, includeQuery);
if (desired !== current.search) {
lastSeenSearch = desired;
// Same-origin path/search/hash built from page.url; resolve() only
// accepts a route string, so it can't type a dynamic URL.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(`${current.pathname}${desired}${current.hash}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared resource-filter state for the search-backed surfaces.
*
* One module-level `$state` object is THE filter state: the top bar's
* search box and filter panel, and the /search chips all read and mutate
* this same proxy, and each surface projects it into its URL
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
* never reassign, which is also what keeps every holder looking at the same
* proxy.
*/
import {
clearFilterState,
defaultFilterState,
type ResourceFilterState
} from '$lib/utils/searchFilters';
export const resourceFilters: ResourceFilterState = $state(defaultFilterState());
/**
* Adopt `next` field-by-field (never reassign — the proxy identity is the
* contract every surface binds to).
*/
export function hydrateFilters(next: ResourceFilterState): void {
resourceFilters.query = next.query;
resourceFilters.recursive = next.recursive;
resourceFilters.type = next.type;
resourceFilters.size = next.size;
resourceFilters.date = next.date;
resourceFilters.created = next.created;
resourceFilters.kind = next.kind;
}
/** Reset every dimension (keyword included) in place. */
export function resetFilters(): void {
clearFilterState(resourceFilters);
}
+3 -1
View File
@@ -112,7 +112,9 @@
.search-container input { .search-container input {
width: 100%; width: 100%;
padding: var(--space-3) 50px var(--space-3) var(--space-11); /* Right padding reserves the three in-field controls: submit (6px),
filter toggle (44px), clear × (72px). */
padding: var(--space-3) 104px var(--space-3) var(--space-11);
border-radius: var(--radius-2xl); border-radius: var(--radius-2xl);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
background-color: var(--color-bg-input); background-color: var(--color-bg-input);
+197 -1
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
activeFilterCount,
activeFilters,
applyFilterParams,
clearFilterDimension,
clearFilterState, clearFilterState,
dateBound, dateBound,
defaultFilterState, defaultFilterState,
filterParamEntries,
filterParamsToString,
filterSearchOver,
filtersFromParams,
filterToSearchOptions, filterToSearchOptions,
filterValueLabelKey,
isFilterActive, isFilterActive,
sizeBounds, sizeBounds,
TYPE_EXT TYPE_EXT
@@ -58,6 +67,8 @@ describe('isFilterActive', () => {
expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), type: 'image' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), size: 'small' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true); expect(isFilterActive({ ...defaultFilterState(), date: 'week' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), created: 'week' })).toBe(true);
expect(isFilterActive({ ...defaultFilterState(), kind: 'folder' })).toBe(true);
}); });
it('ignores the recursive toggle', () => { it('ignores the recursive toggle', () => {
@@ -65,6 +76,51 @@ describe('isFilterActive', () => {
}); });
}); });
describe('activeFilters', () => {
it('is empty for the default state', () => {
expect(activeFilters(defaultFilterState())).toEqual([]);
expect(activeFilterCount(defaultFilterState())).toBe(0);
});
it('lists live dimensions in URL-key order', () => {
const f = {
...defaultFilterState(),
kind: 'file' as const,
type: 'image' as const,
date: 'week' as const
};
expect(activeFilters(f)).toEqual([
{ key: 'type', value: 'image' },
{ key: 'date', value: 'week' },
{ key: 'kind', value: 'file' }
]);
expect(activeFilterCount(f)).toBe(3);
});
it('does not count the keyword', () => {
expect(activeFilterCount({ ...defaultFilterState(), query: 'report' })).toBe(0);
});
});
describe('clearFilterDimension', () => {
it('resets only the named dimension', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'folder' as const };
clearFilterDimension(f, 'kind');
expect(f.kind).toBe('all');
expect(f.type).toBe('image');
});
});
describe('filterValueLabelKey', () => {
it('names each dimension from its own i18n family', () => {
expect(filterValueLabelKey('type', 'image')).toBe('search.type.image');
expect(filterValueLabelKey('size', 'large')).toBe('search.size.large');
expect(filterValueLabelKey('date', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('created', 'week')).toBe('search.date.week');
expect(filterValueLabelKey('kind', 'folder')).toBe('filter.kind.folder');
});
});
describe('clearFilterState', () => { describe('clearFilterState', () => {
it('resets every field in place', () => { it('resets every field in place', () => {
const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const }; const f = { ...defaultFilterState(), query: 'a', recursive: false, type: 'video' as const };
@@ -80,6 +136,8 @@ describe('filterToSearchOptions', () => {
minSize: undefined, minSize: undefined,
maxSize: undefined, maxSize: undefined,
modifiedAfter: undefined, modifiedAfter: undefined,
createdAfter: undefined,
resourceTypes: undefined,
recursive: true recursive: true
}); });
}); });
@@ -90,12 +148,150 @@ describe('filterToSearchOptions', () => {
recursive: false, recursive: false,
type: 'archive', type: 'archive',
size: 'medium', size: 'medium',
date: 'month' date: 'month',
created: 'day',
kind: 'folder'
}); });
expect(opts.fileTypes).toEqual(TYPE_EXT.archive); expect(opts.fileTypes).toEqual(TYPE_EXT.archive);
expect(opts.minSize).toBe(MB); expect(opts.minSize).toBe(MB);
expect(opts.maxSize).toBe(100 * MB); expect(opts.maxSize).toBe(100 * MB);
expect(opts.modifiedAfter).toBe(dateBound('month')); expect(opts.modifiedAfter).toBe(dateBound('month'));
expect(opts.createdAfter).toBe(dateBound('day'));
expect(opts.resourceTypes).toEqual(['folder']);
expect(opts.recursive).toBe(false); expect(opts.recursive).toBe(false);
}); });
}); });
describe('filtersFromParams', () => {
it('returns the defaults for an empty query string', () => {
expect(filtersFromParams(new URLSearchParams(''))).toEqual(defaultFilterState());
});
it('reads every dimension', () => {
const f = filtersFromParams(
new URLSearchParams('q=report&type=image&size=large&date=week&created=day&kind=folder')
);
expect(f).toEqual({
query: 'report',
recursive: true,
type: 'image',
size: 'large',
date: 'week',
created: 'day',
kind: 'folder'
});
});
it('falls back to the default for unknown values instead of erroring', () => {
const f = filtersFromParams(new URLSearchParams('type=pdf&size=huge&kind=folderz'));
expect(f.type).toBe('all');
expect(f.size).toBe('all');
expect(f.kind).toBe('all');
});
it('treats recursive=0 as off and anything else as on', () => {
expect(filtersFromParams(new URLSearchParams('recursive=0')).recursive).toBe(false);
expect(filtersFromParams(new URLSearchParams('recursive=1')).recursive).toBe(true);
});
it('ignores params that belong to the surface', () => {
const f = filtersFromParams(new URLSearchParams('in=abc&scope=all&file=xyz'));
expect(f).toEqual(defaultFilterState());
});
});
describe('applyFilterParams', () => {
it('writes only the non-default dimensions', () => {
const f = { ...defaultFilterState(), type: 'image' as const, kind: 'file' as const };
const p = new URLSearchParams('');
applyFilterParams(p, f);
expect(p.toString()).toBe('type=image&kind=file');
});
it('keeps the surface-owned params around it', () => {
const f = { ...defaultFilterState(), size: 'small' as const, recursive: false };
const p = new URLSearchParams('in=abc&scope=all');
applyFilterParams(p, f);
expect(p.get('in')).toBe('abc');
expect(p.get('scope')).toBe('all');
expect(p.get('size')).toBe('small');
expect(p.get('recursive')).toBe('0');
});
it('drops a dimension that went back to its default', () => {
const p = new URLSearchParams('type=image&kind=folder');
applyFilterParams(p, defaultFilterState());
expect(p.toString()).toBe('');
});
it('leaves the keyword alone unless asked', () => {
const p = new URLSearchParams('q=report');
applyFilterParams(p, defaultFilterState());
expect(p.get('q')).toBe('report');
const p2 = new URLSearchParams('q=report');
applyFilterParams(p2, { ...defaultFilterState(), query: 'plan' }, true);
expect(p2.get('q')).toBe('plan');
});
it('round-trips through filtersFromParams', () => {
const f = {
query: 'report',
recursive: false,
type: 'video' as const,
size: 'medium' as const,
date: 'month' as const,
created: 'year' as const,
kind: 'file' as const
};
const p = new URLSearchParams('');
applyFilterParams(p, f, true);
expect(filtersFromParams(p)).toEqual(f);
});
});
describe('filterParamsToString', () => {
it('omits the keyword by default and encodes values', () => {
expect(filterParamsToString({ ...defaultFilterState(), query: 'a b' })).toBe('');
expect(
filterParamsToString({ ...defaultFilterState(), query: 'a b', type: 'image' }, true)
).toBe('q=a%20b&type=image');
});
});
describe('filterParamEntries', () => {
it('lists every live dimension as a param pair', () => {
const f = {
...defaultFilterState(),
type: 'image' as const,
size: 'large' as const,
date: 'week' as const,
created: 'month' as const,
kind: 'folder' as const,
recursive: false
};
expect(filterParamEntries(f)).toEqual([
['type', 'image'],
['size', 'large'],
['date', 'week'],
['created', 'month'],
['kind', 'folder'],
['recursive', '0']
]);
});
});
describe('filterSearchOver', () => {
it('overwrites filter params onto a base and keeps foreign params', () => {
const base = new URLSearchParams('?file=abc&in=dir1&type=video');
expect(filterSearchOver(base, { ...defaultFilterState(), type: 'image' })).toBe(
'?file=abc&in=dir1&type=image'
);
});
it('collapses an all-defaults result to an empty search string', () => {
const base = new URLSearchParams('?type=video&q=x');
// includeQuery=true — with false, `?q=` belongs to the search box and
// survives the overwrite by design.
expect(filterSearchOver(base, defaultFilterState(), true)).toBe('');
});
});
+192 -11
View File
@@ -1,15 +1,22 @@
// Shared resource-filter model for search-backed list views. // Shared resource-filter model for search-backed list views.
// //
// Extracted from the /search page so the files page's filter bar and // One source of truth for the preset vocabularies (type / size / date /
// /search's filter selects share one source of truth for the preset // created / kind), their mapping onto `SearchOptions`, and their
// vocabularies (type / size / date) and their mapping onto // serialization in and out of the URL. The files page's filter bar, the
// `SearchOptions`. Pure functions only — no runes here, so the module is // top bar's filter panel and the /search page all bind the same state
// unit-testable without component scaffolding. // object, and the URL is its projection — see `filtersFromParams` /
// `filterParamsInto`. Pure functions only — no runes here, so the module
// is unit-testable without component scaffolding.
import type { SearchOptions } from '$lib/api/endpoints/search'; import type { SearchOptions } from '$lib/api/endpoints/search';
export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; export type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
export type SizeKey = 'all' | 'small' | 'medium' | 'large'; export type SizeKey = 'all' | 'small' | 'medium' | 'large';
export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; export type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
/** Files, folders, or both — maps to the backend's `resource_types`. */
export type KindKey = 'all' | 'file' | 'folder';
/** The filter dimensions carried in the URL, in URL-key order. */
export type FilterDimension = 'type' | 'size' | 'date' | 'created' | 'kind';
/** Full filter state for a search-backed resource list. */ /** Full filter state for a search-backed resource list. */
export interface ResourceFilterState { export interface ResourceFilterState {
@@ -19,10 +26,21 @@ export interface ResourceFilterState {
type: TypeKey; type: TypeKey;
size: SizeKey; size: SizeKey;
date: DateKey; date: DateKey;
/** Created-time preset, independent of the modified-time one. */
created: DateKey;
kind: KindKey;
} }
export function defaultFilterState(): ResourceFilterState { export function defaultFilterState(): ResourceFilterState {
return { query: '', recursive: true, type: 'all', size: 'all', date: 'all' }; return {
query: '',
recursive: true,
type: 'all',
size: 'all',
date: 'all',
created: 'all',
kind: 'all'
};
} }
export const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = { export const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = {
@@ -66,7 +84,52 @@ export function dateBound(k: DateKey): number | undefined {
/** True when any filter dimension would change the result set. */ /** True when any filter dimension would change the result set. */
export function isFilterActive(f: ResourceFilterState): boolean { export function isFilterActive(f: ResourceFilterState): boolean {
return f.query.trim() !== '' || f.type !== 'all' || f.size !== 'all' || f.date !== 'all'; return f.query.trim() !== '' || activeFilterCount(f) > 0;
}
/**
* Number of active preset dimensions (the keyword is counted separately —
* it has its own clear affordance). Drives the filter button's badge.
*/
export function activeFilterCount(f: ResourceFilterState): number {
return activeFilters(f).length;
}
/** The dimensions currently differing from their default, URL-key ordered. */
export function activeFilters(f: ResourceFilterState): { key: FilterDimension; value: string }[] {
const out: { key: FilterDimension; value: string }[] = [];
if (f.type !== 'all') out.push({ key: 'type', value: f.type });
if (f.size !== 'all') out.push({ key: 'size', value: f.size });
if (f.date !== 'all') out.push({ key: 'date', value: f.date });
if (f.created !== 'all') out.push({ key: 'created', value: f.created });
if (f.kind !== 'all') out.push({ key: 'kind', value: f.kind });
return out;
}
/**
* i18n key naming a dimension's current value, so chips and labels read
* from one vocabulary. `created` reuses the modified-time preset labels
* ("Past week" reads the same for either timestamp) and `kind` gets its
* own family.
*/
export function filterValueLabelKey(key: FilterDimension, value: string): string {
if (key === 'created') return `search.date.${value}`;
if (key === 'kind') return `filter.kind.${value}`;
return `search.${key}.${value}`;
}
/** i18n key naming a dimension itself (the chip's "Type:" prefix). */
export function filterDimensionLabelKey(key: FilterDimension): string {
if (key === 'created') return 'filter.created_label';
if (key === 'kind') return 'filter.kind_label';
if (key === 'date') return 'filter.modified_label';
return `search.${key}_label`;
}
/** Reset one dimension to its default (the chip's dismiss action). */
export function clearFilterDimension(f: ResourceFilterState, key: FilterDimension): void {
if (key === 'kind') f.kind = 'all';
else f[key] = 'all';
} }
/** Reset every dimension in place (runes-friendly — mutates the $state proxy). */ /** Reset every dimension in place (runes-friendly — mutates the $state proxy). */
@@ -76,21 +139,139 @@ export function clearFilterState(f: ResourceFilterState): void {
f.type = 'all'; f.type = 'all';
f.size = 'all'; f.size = 'all';
f.date = 'all'; f.date = 'all';
f.created = 'all';
f.kind = 'all';
} }
/** /**
* Map the filter state onto the search-wire options. Scope (folderId) and * Map the filter state onto the search-wire options. Scope (folderId) and
* sorting stay the caller's concern — they differ per surface. The date * sorting stay the caller's concern — they differ per surface.
* preset maps to `modifiedAfter` (its labels read "Past N", which matches
* modified-time semantics); created-time bounds are a possible follow-up.
*/ */
export function filterToSearchOptions( export function filterToSearchOptions(
f: ResourceFilterState f: ResourceFilterState
): Pick<SearchOptions, 'fileTypes' | 'minSize' | 'maxSize' | 'modifiedAfter' | 'recursive'> { ): Pick<
SearchOptions,
| 'fileTypes'
| 'minSize'
| 'maxSize'
| 'modifiedAfter'
| 'createdAfter'
| 'resourceTypes'
| 'recursive'
> {
return { return {
fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type], fileTypes: f.type === 'all' ? undefined : TYPE_EXT[f.type],
...sizeBounds(f.size), ...sizeBounds(f.size),
modifiedAfter: dateBound(f.date), modifiedAfter: dateBound(f.date),
createdAfter: dateBound(f.created),
resourceTypes: f.kind === 'all' ? undefined : [f.kind],
recursive: f.recursive recursive: f.recursive
}; };
} }
// ---------------------------------------------------------------------------
// URL projection
//
// The URL carries the UI preset keys (`type=image`), never the expanded wire
// values (extension lists, byte ranges) — the URL stays readable and keeps
// working when a vocabulary grows. Only non-default dimensions are written,
// and an unrecognized value degrades to the default rather than erroring.
// ---------------------------------------------------------------------------
const TYPE_KEYS: readonly TypeKey[] = ['all', 'image', 'video', 'document', 'audio', 'archive'];
const SIZE_KEYS: readonly SizeKey[] = ['all', 'small', 'medium', 'large'];
const DATE_KEYS: readonly DateKey[] = ['all', 'day', 'week', 'month', 'year'];
const KIND_KEYS: readonly KindKey[] = ['all', 'file', 'folder'];
/** Every param this module owns, so a write can clear the stale ones first. */
const FILTER_PARAM_KEYS = ['q', 'type', 'size', 'date', 'created', 'kind', 'recursive'] as const;
function pickOne<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
return raw !== null && (allowed as readonly string[]).includes(raw) ? (raw as T) : fallback;
}
/**
* Read the filter dimensions out of URL params. Every other param (`in`,
* `scope`, `file`, …) belongs to the surface and is left untouched; missing
* or unknown values fall back to the default for that dimension.
*/
export function filtersFromParams(params: URLSearchParams): ResourceFilterState {
return {
query: params.get('q')?.trim() ?? '',
recursive: params.get('recursive') !== '0',
type: pickOne(params.get('type'), TYPE_KEYS, 'all'),
size: pickOne(params.get('size'), SIZE_KEYS, 'all'),
date: pickOne(params.get('date'), DATE_KEYS, 'all'),
created: pickOne(params.get('created'), DATE_KEYS, 'all'),
kind: pickOne(params.get('kind'), KIND_KEYS, 'all')
};
}
/**
* The query-string entries for a filter state, defaults omitted.
*
* `q` is opt-in: on the files page the keyword belongs to the filter bar, but
* on /search the search box owns `?q=` and already writes it — emitting it
* from the state there would put two writers on one param.
*/
export function filterParamEntries(
f: ResourceFilterState,
includeQuery = false
): [string, string][] {
const out: [string, string][] = [];
const q = f.query.trim();
if (includeQuery && q) out.push(['q', q]);
if (f.type !== 'all') out.push(['type', f.type]);
if (f.size !== 'all') out.push(['size', f.size]);
if (f.date !== 'all') out.push(['date', f.date]);
if (f.created !== 'all') out.push(['created', f.created]);
if (f.kind !== 'all') out.push(['kind', f.kind]);
// `recursive` defaults to true, so only the override is worth carrying.
if (!f.recursive) out.push(['recursive', '0']);
return out;
}
/** The entries as a query string (no leading `?`), for hand-built targets. */
export function filterParamsToString(f: ResourceFilterState, includeQuery = false): string {
return filterParamEntries(f, includeQuery)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
}
/**
* Overwrite the filter params of `params` **in place** — the shape the pages
* need, since they write into a copy of `page.url` so the surface's own
* params (`?file=`, `in`, `scope`) survive untouched.
*/
export function applyFilterParams(
params: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): void {
for (const key of FILTER_PARAM_KEYS) {
// `?q=` is the search box's when the state does not own it — deleting a
// param we do not write would wipe the query the page is showing.
if (key === 'q' && !includeQuery) continue;
params.delete(key);
}
for (const [k, v] of filterParamEntries(f, includeQuery)) params.set(k, v);
}
/**
* The `url.search` string `base` carries once the filter params of `f` are
* overwritten onto it — everything else (`?file=`, `in`, `scope`) survives.
* An all-defaults result collapses to `''` so callers can compare the value
* directly against `page.url.search`. Composed here rather than at the call
* site so reactive modules can build the string without holding a mutable
* `URL` copy (which `svelte/prefer-svelte-reactivity` treats as state).
*/
export function filterSearchOver(
base: URLSearchParams,
f: ResourceFilterState,
includeQuery = false
): string {
const params = new URLSearchParams(base);
applyFilterParams(params, f, includeQuery);
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
@@ -62,19 +62,19 @@
import { replaceSet } from '$lib/utils/sets'; import { replaceSet } from '$lib/utils/sets';
import { mapLimit } from '$lib/utils/mapLimit'; import { mapLimit } from '$lib/utils/mapLimit';
import { import {
defaultFilterState,
clearFilterState, clearFilterState,
filterParamsToString,
isFilterActive, isFilterActive,
filterToSearchOptions, filterToSearchOptions
type ResourceFilterState
} from '$lib/utils/searchFilters'; } from '$lib/utils/searchFilters';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { import {
useResourceActions, useResourceActions,
batchZipName, batchZipName,
type ActionTarget type ActionTarget
} from '$lib/composables/useResourceActions.svelte'; } from '$lib/composables/useResourceActions.svelte';
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
// Message-bus logger. Users can tune with // Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug') // oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -278,7 +278,11 @@
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
const target = last ?? home; const target = last ?? home;
if (target) { if (target) {
await goto(resolve(`/files/${target}`), { replaceState: true }); // `folderTarget` keeps any deep-linked filter params
// (`/files?type=image`) alive through the canonicalization.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(folderTarget(target), { replaceState: true });
return; return;
} }
} }
@@ -446,7 +450,7 @@
} }
// ── Filter / search mode ───────────────────────────────────────────────── // ── Filter / search mode ─────────────────────────────────────────────────
// While any SearchFilterBar dimension is active the listing switches from // While any filter dimension is active the listing switches from
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources` // the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
// with folder_id = currentId). The two data paths keep independent cursors // with folder_id = currentId). The two data paths keep independent cursors
// and stale guards; entering/leaving the mode neutralizes the other path's // and stale guards; entering/leaving the mode neutralizes the other path's
@@ -458,8 +462,25 @@
// searches (type/size/date, no keyword) work; its Tantivy content index // searches (type/size/date, no keyword) work; its Tantivy content index
// additionally requires ≥2 chars before it engages, so empty queries stay // additionally requires ≥2 chars before it engages, so empty queries stay
// name/filter-driven. // name/filter-driven.
let filter = $state<ResourceFilterState>(defaultFilterState()); //
// `filter` is the module-level shared store (`$lib/stores/filter.svelte`)
// — the same object the top bar's filter panel mutates, so both surfaces
// always agree. `useFilterUrlSync(true)` mirrors it to/from this page's
// URL (`?q=…&type=…&recursive=0`), which is what makes a filtered view
// refresh-proof and Back-button-restoreable. Local alias kept so the
// search-mode code below reads unchanged.
const filter = resourceFilters;
useFilterUrlSync(true);
const searchActive = $derived(isFilterActive(filter)); const searchActive = $derived(isFilterActive(filter));
/** Folder-entry URL that carries the active filter params along (the
* filter used to live in component state and survived folder navigation;
* URL state needs the params forwarded explicitly). */
function folderTarget(id: string): string {
const base = resolve(`/files/${id}`);
const qs = filterParamsToString(filter, true);
return qs ? `${base}?${qs}` : base;
}
let searchItems = $state<Array<FileItem | FolderItem>>([]); let searchItems = $state<Array<FileItem | FolderItem>>([]);
let searchCursor = $state<string | undefined>(undefined); let searchCursor = $state<string | undefined>(undefined);
let searchSeq = 0; let searchSeq = 0;
@@ -606,8 +627,11 @@
function openFolder(folder: FolderItem) { function openFolder(folder: FolderItem) {
// Canonical single-id URL. Legacy `/files/A/B/C` still resolves // Canonical single-id URL. Legacy `/files/A/B/C` still resolves
// (canonicalize-on-load rewrites it inside `load()`), but new // (canonicalize-on-load rewrites it inside `load()`), but new
// navigation lands directly on `/files/{id}`. // navigation lands directly on `/files/{id}`. Active filter params
goto(resolve(`/files/${folder.id}`)); // ride along (`folderTarget`) so filtering survives folder entry.
// folderTarget embeds resolve(); the rule can't see through it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(folder.id));
} }
async function onNewFolder() { async function onNewFolder() {
@@ -1931,12 +1955,16 @@
// inputs change: any filter dimension, the folder it's scoped to // inputs change: any filter dimension, the folder it's scoped to
// (`currentId`, resolved by `load()` above), or the sort dimension. // (`currentId`, resolved by `load()` above), or the sort dimension.
// Inactive (plain folder listing) is the no-op fast path. `filter` is // Inactive (plain folder listing) is the no-op fast path. `filter` is
// a `$state` proxy — the field reads are what register the deps. // the shared store's `$state` proxy — the field reads are what register
// the deps (and `useFilterUrlSync` above runs first in the same flush,
// so URL hydration lands before this fires).
$effect(() => { $effect(() => {
void filter.query; void filter.query;
void filter.type; void filter.type;
void filter.size; void filter.size;
void filter.date; void filter.date;
void filter.created;
void filter.kind;
void filter.recursive; void filter.recursive;
void currentId; void currentId;
void sortField; void sortField;
@@ -1983,13 +2011,8 @@
<ReadOnlyBanner driveName={currentDrive.name} /> <ReadOnlyBanner driveName={currentDrive.name} />
{/if} {/if}
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the <!-- Filtering is driven from the top bar (search box + filter panel),
current folder (recursive toggle inside). While any dimension is which mutates the shared store this page's search mode reads. -->
active the listing below switches from the folder page to the
search results; clearing it returns to the plain folder view. -->
<div class="files-filter-row">
<SearchFilterBar bind:value={filter} />
</div>
<!-- Hidden upload inputs stay mounted even while the batch bar is shown. <!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the Kept OUTSIDE ResourceList so the split-button dropdown in the
@@ -2302,7 +2325,8 @@
const id = ctxTarget!.id; const id = ctxTarget!.id;
closeContext(); closeContext();
// Canonical single-id URL — see `openFolder` above. // Canonical single-id URL — see `openFolder` above.
goto(resolve(`/files/${id}`)); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(folderTarget(id));
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button }}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
> >
<button <button
@@ -2513,9 +2537,4 @@
rendered near-invisible here). Mirrors the user-menu logout red. */ rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt); color: var(--color-danger-alt);
} }
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style> </style>
+58 -16
View File
@@ -85,6 +85,7 @@ import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUplo
import { addFavoritesBatch } from '$lib/api/endpoints/favorites'; import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
import { searchResources } from '$lib/api/endpoints/search'; import { searchResources } from '$lib/api/endpoints/search';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters, resetFilters } from '$lib/stores/filter.svelte';
import FilesPage from './[...path]/+page.svelte'; import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>; const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
@@ -151,6 +152,12 @@ beforeEach(() => {
// `/files/<drive-root>` via goto (see the external-user test), so the // `/files/<drive-root>` via goto (see the external-user test), so the
// listing-oriented tests target a folder directly. // listing-oriented tests target a folder directly.
pageState.params.path = 'home'; pageState.params.path = 'home';
// Same for the URL: the filter store hydrates from it, so a deep-link
// URL left over from an earlier test would leak presets forward.
pageState.url = new URL('http://localhost/files');
// The filter store is module-level shared state — clear whatever a
// previous test's keyword/preset mutation or URL hydration left there.
resetFilters();
// List view renders the select-all header + per-row checkboxes; grid hides them. // List view renders the select-all header + per-row checkboxes; grid hides them.
filesStore.viewMode = 'list'; filesStore.viewMode = 'list';
}); });
@@ -291,15 +298,14 @@ it('runs a scoped recursive search when the filter keyword is set', async () =>
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 }); m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage); render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers(); // The keyword arrives through the shared filter store — the top-bar
await fireEvent.input(screen.getByTestId('filter-keyword-input'), { // search box and filter panel are its writers on this page.
target: { value: 'hello' } resourceFilters.query = 'hello';
}); await waitFor(() =>
await vi.advanceTimersByTimeAsync(400); expect(searchResources).toHaveBeenCalledWith(
vi.useRealTimers(); 'hello',
expect(searchResources).toHaveBeenCalledWith( expect.objectContaining({ folderId: 'home', recursive: true })
'hello', )
expect.objectContaining({ folderId: 'home', recursive: true })
); );
}); });
@@ -312,12 +318,48 @@ it('shows the search result count while the filter is active', async () => {
}); });
render(FilesPage); render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers(); resourceFilters.query = 'found';
await fireEvent.input(screen.getByTestId('filter-keyword-input'), { // Sync on the search having fired (not just the store write): from this
target: { value: 'found' } // point the mocked response is already on the microtask queue, so the
}); // text wait below observes the post-search DOM deterministically.
await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(searchResources).toHaveBeenCalled());
vi.useRealTimers();
const stat = await screen.findByTestId('files-folder-stat'); const stat = await screen.findByTestId('files-folder-stat');
expect(stat.textContent).toContain('1 results'); // The stat element exists in plain folder mode too (it shows the folder
// counts there), so wait for the search landing to swap its text.
await waitFor(() => expect(stat.textContent).toContain('1 results'));
});
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
withListing();
pageState.url = new URL('http://localhost/files/home?type=image&kind=folder');
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
folderId: 'home',
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
resourceTypes: ['folder']
});
});
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
withListing();
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
m(goto).mockClear();
resourceFilters.query = 'hello';
// One replaceState write for the keyword — and nothing else: the sync
// effect must recognize its own write and settle, not ping-pong.
await waitFor(() => expect(goto).toHaveBeenCalledTimes(1));
// The sync composable passes a path/search/hash string (a URL instance
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
const written = m(goto).mock.calls[0][0] as string;
expect(written).toContain('q=hello');
// Give the effect a tick to misbehave before asserting it settled —
// a second write would mean the URL→store adoption re-triggered the
// store→URL direction.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(goto).toHaveBeenCalledTimes(1);
}); });
+48 -122
View File
@@ -22,17 +22,20 @@
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types'; import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess'; import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import { TYPE_EXT, dateBound, sizeBounds } from '$lib/utils/searchFilters'; import { filterToSearchOptions } from '$lib/utils/searchFilters';
import { useFilterUrlSync } from '$lib/composables/useFilterUrlSync.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { replaceSet } from '$lib/utils/sets'; import { replaceSet } from '$lib/utils/sets';
import { import {
useResourceActions, useResourceActions,
type ActionTarget type ActionTarget
} from '$lib/composables/useResourceActions.svelte'; } from '$lib/composables/useResourceActions.svelte';
import FilterChips from '$lib/components/FilterChips.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte'; import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters } from '$lib/stores/filter.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
@@ -125,72 +128,48 @@
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all' scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
); );
function setScope(next: 'all' | 'folder') { function setScope(next: 'all' | 'folder') {
// Build the query string by hand — Svelte's lint flags mutating a // Copy the current URL and flip only the scope params — `q=` and the
// stdlib `URLSearchParams`, and we don't need reactivity here. // filter presets already in it survive untouched (they used to be
// dropped here when the query string was rebuilt by hand).
// //
// Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved // Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved
// even when switching to "Everywhere" so "This folder" stays // even when switching to "Everywhere" so "This folder" stays
// clickable and remembers WHICH folder. The active-scope flip // clickable and remembers WHICH folder. The active-scope flip
// rides on `scope=all` instead. // rides on `scope=all` instead.
const parts: string[] = []; const url = new URL(page.url);
if (query) parts.push(`q=${encodeURIComponent(query)}`);
// Sticky `in=`: keep whatever's already in the URL, or seed it // Sticky `in=`: keep whatever's already in the URL, or seed it
// from filesStore when the user first pins "This folder" from a // from filesStore when the user first pins "This folder" from a
// fresh /search visit. // fresh /search visit.
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null); const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
if (stickyFolder) { if (stickyFolder) {
parts.push(`in=${encodeURIComponent(stickyFolder)}`); url.searchParams.set('in', stickyFolder);
} else {
url.searchParams.delete('in');
} }
if (next === 'all' && stickyFolder) { if (next === 'all' && stickyFolder) {
// Only meaningful when there's a folder to override — otherwise // Only meaningful when there's a folder to override — otherwise
// the URL is "everywhere by default" and the flag would be noise. // the URL is "everywhere by default" and the flag would be noise.
parts.push('scope=all'); url.searchParams.set('scope', 'all');
} else {
url.searchParams.delete('scope');
} }
const target = resolve(parts.length ? `/search?${parts.join('&')}` : '/search');
// `replaceState: true` keeps the browser back-button meaningful — // `replaceState: true` keeps the browser back-button meaningful —
// scope changes are UI state, not navigation. `keepFocus: true` // scope changes are UI state, not navigation. `keepFocus: true`
// keeps focus on whatever button the user just clicked. // keeps focus on whatever button the user just clicked.
void goto(target, { replaceState: true, keepFocus: true, noScroll: true }); // Same-origin URL object (see useFilterUrlSync); resolve() can't type it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
} }
// Filters — the preset vocabularies and their SearchOptions mapping live // Filters live in the shared store (`$lib/stores/filter.svelte`) and are
// in the shared `searchFilters` util (also consumed by the files page's // mirrored to/from this page's URL — the top bar's filter panel and the
// filter bar); only the i18n label lists stay local since they need `t()`. // files page's filter bar mutate the same object, so a filter set in
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive'; // either place is live here, and every dimension is URL-persisted
type SizeKey = 'all' | 'small' | 'medium' | 'large'; // (`?type=…&size=…&created=…&kind=…&recursive=0`) for refresh/bookmark/
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year'; // Back. The active presets render as dismissible chips in the header
let typeFilter = $state<TypeKey>('all'); // (`<FilterChips>` in the actions snippet) — the old in-page selects
let sizeFilter = $state<SizeKey>('all'); // duplicated the vocabularies and lost state on every refresh.
let dateFilter = $state<DateKey>('all'); useFilterUrlSync(false);
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
function clearFilters() {
typeFilter = 'all';
sizeFilter = 'all';
dateFilter = 'all';
}
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ── // ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
// Ed's 2026-07-26 spec: 4 options total — // Ed's 2026-07-26 spec: 4 options total —
@@ -254,16 +233,14 @@
scope === 'folder' && filesStore.section !== 'trash' scope === 'folder' && filesStore.section !== 'trash'
? (effectiveFolder ?? undefined) ? (effectiveFolder ?? undefined)
: undefined; : undefined;
// TYPE_EXT / sizeBounds / dateBound come from `$lib/utils/searchFilters` // Filter presets come from the shared store (hydrated from this
// (shared with the files-page filter bar). // page's URL by `useFilterUrlSync`), mapped onto the wire by the
// util shared with the files page's filter bar.
return { return {
recursive: true, ...filterToSearchOptions(resourceFilters),
sortBy: orderByForGroup() as SortBy, sortBy: orderByForGroup() as SortBy,
reverse: reversed, reverse: reversed,
folderId, folderId
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
}; };
} }
@@ -546,13 +523,19 @@
]; ];
$effect(() => { $effect(() => {
// re-run when query, sort/direction, scope, or any filter changes // re-run when query, sort/direction, scope, or any filter changes.
// The filter store is hydrated from the URL by `useFilterUrlSync`
// (registered earlier, so it runs first in the same flush) — these
// field reads register the deps.
void groupBy; void groupBy;
void reversed; void reversed;
void scope; void scope;
void typeFilter; void resourceFilters.type;
void sizeFilter; void resourceFilters.size;
void dateFilter; void resourceFilters.date;
void resourceFilters.created;
void resourceFilters.kind;
void resourceFilters.recursive;
void run(query); void run(query);
}); });
@@ -686,47 +669,14 @@
{t('search.this_folder', 'This folder')} {t('search.this_folder', 'This folder')}
</button> </button>
</div> </div>
<select
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
data-testid="search-type-filter-select"
>
{#each TYPES as o (o.v)}<option value={o.v} data-testid={`search-type-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
data-testid="search-size-filter-select"
>
{#each SIZES as o (o.v)}<option value={o.v} data-testid={`search-size-${o.v}`}>{o.l}</option
>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
data-testid="search-date-filter-select"
>
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
>{/each}
</select>
<!-- <!--
NOTE: sort dimension + asc/desc live in ResourceList's Filter editing lives in the top bar's panel (the unified
built-in DisplayModeControls now (fed by `groupBys` + entry); this page only SHOWS the active presets, as
`bind:groupBy` + `bind:reversed` below), matching dismissible chips. NOTE: sort dimension + asc/desc live in
/favorites / /recent / /trash. The old ResourceList's built-in DisplayModeControls (fed by
`<select bind:value={sortBy}>` was removed with the `groupBys` + `bind:groupBy` + `bind:reversed` below).
`SORTS` array.
--> -->
{#if hasFilters} <FilterChips value={resourceFilters} />
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
{/snippet} {/snippet}
{#snippet breadcrumb()} {#snippet breadcrumb()}
<!-- <!--
@@ -845,20 +795,12 @@
{/if} {/if}
<style> <style>
/* Filter cluster lives inside ResourceList's action-bar snippet now, /* Scope segment lives inside ResourceList's action-bar snippet now,
but the actual DOM is scoped to THIS component's \3c style> block — but the actual DOM is scoped to THIS component's \3c style> block —
Svelte's scoped selectors still apply because these are declared Svelte's scoped selectors still apply because these are declared
with the elements they style below. with the elements they style below.
Every color/border here uses tokens; no raw values (Stylelint gate). */ Every color/border here uses tokens; no raw values (Stylelint gate). */
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.seg { .seg {
display: flex; display: flex;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -883,20 +825,4 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.clear-filters {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.clear-filters:hover {
background: var(--color-bg-hover);
}
</style> </style>
+33
View File
@@ -126,6 +126,39 @@ it('surfaces a search error', async () => {
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy()); await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
}); });
it('hydrates filter presets from the URL onto the wire options', async () => {
pageState.url = new URL(
'http://localhost/search?q=report&type=image&size=small&created=week&kind=file&recursive=0'
);
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
maxSize: 1024 * 1024,
createdAfter: expect.any(Number),
resourceTypes: ['file'],
recursive: false
});
// The live presets are visible — and dismissible — as chips.
expect(screen.getByTestId('filter-chips')).toBeTruthy();
expect(screen.getByTestId('filter-chip-type')).toBeTruthy();
expect(screen.getByTestId('filter-chip-kind')).toBeTruthy();
expect(screen.getByTestId('filter-chip-size')).toBeTruthy();
expect(screen.getByTestId('filter-chip-created')).toBeTruthy();
});
it('an unknown filter value in the URL degrades to the default', async () => {
pageState.url = new URL('http://localhost/search?q=report&type=pdf&kind=aliens');
render(SearchPage);
await waitFor(() => expect(searchResources).toHaveBeenCalled());
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
fileTypes: undefined,
resourceTypes: undefined
});
// No chips for dimensions that fell back to their defaults.
expect(screen.queryByTestId('filter-chip-type')).toBeNull();
});
it('batch-deletes the selected search results after confirmation', async () => { it('batch-deletes the selected search results after confirmation', async () => {
m(searchResources).mockResolvedValue(searchHit()); m(searchResources).mockResolvedValue(searchHit());
confirmDialog.mockResolvedValue(true); confirmDialog.mockResolvedValue(true);
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "فشل إرسال الإشعار" "shared_notificationFailed": "فشل إرسال الإشعار"
}, },
"filter": { "filter": {
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
"keyword": "الكلمة المفتاحية",
"advanced": "المرشحات", "advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية", "recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج", "results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "الاسم", "name": "الاسم",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden" "shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
}, },
"filter": { "filter": {
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
"keyword": "Suchbegriff",
"advanced": "Filter", "advanced": "Filter",
"recursive": "Unterordner einbeziehen", "recursive": "Unterordner einbeziehen",
"results_count": "{{n}} Ergebnisse", "results_count": "{{n}} Ergebnisse",
"clear_keyword": "Suche löschen" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Name", "name": "Name",
+8 -3
View File
@@ -452,12 +452,17 @@
"shared_notificationFailed": "Failed to send notification" "shared_notificationFailed": "Failed to send notification"
}, },
"filter": { "filter": {
"placeholder": "Search this folder and subfolders…",
"keyword": "Keyword",
"advanced": "Filters", "advanced": "Filters",
"recursive": "Include subfolders", "recursive": "Include subfolders",
"results_count": "{{n}} results", "results_count": "{{n}} results",
"clear_keyword": "Clear search" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Name", "name": "Name",
+8 -3
View File
@@ -417,12 +417,17 @@
"title": "Menú de usuario" "title": "Menú de usuario"
}, },
"filter": { "filter": {
"placeholder": "Buscar en esta carpeta y subcarpetas…",
"keyword": "Palabra clave",
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subcarpetas", "recursive": "Incluir subcarpetas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Borrar búsqueda" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nombre", "name": "Nombre",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود" "shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
}, },
"filter": { "filter": {
"placeholder": "جستجو در این پوشه و زیرپوشه‌ها…",
"keyword": "کلیدواژه",
"advanced": "فیلترها", "advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها", "recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه", "results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "نام", "name": "نام",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Erreur lors de l'envoi de la notification" "shared_notificationFailed": "Erreur lors de l'envoi de la notification"
}, },
"filter": { "filter": {
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
"keyword": "Mot-clé",
"advanced": "Filtres", "advanced": "Filtres",
"recursive": "Inclure les sous-dossiers", "recursive": "Inclure les sous-dossiers",
"results_count": "{{n}} résultats", "results_count": "{{n}} résultats",
"clear_keyword": "Effacer la recherche" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nom", "name": "Nom",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "सूचना भेजने में विफल" "shared_notificationFailed": "सूचना भेजने में विफल"
}, },
"filter": { "filter": {
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
"keyword": "कीवर्ड",
"advanced": "फ़िल्टर", "advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें", "recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम", "results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "नाम", "name": "नाम",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Impossibile inviare la notifica" "shared_notificationFailed": "Impossibile inviare la notifica"
}, },
"filter": { "filter": {
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
"keyword": "Parola chiave",
"advanced": "Filtri", "advanced": "Filtri",
"recursive": "Includi sottocartelle", "recursive": "Includi sottocartelle",
"results_count": "{{n}} risultati", "results_count": "{{n}} risultati",
"clear_keyword": "Cancella ricerca" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nome", "name": "Nome",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "通知の送信に失敗しました" "shared_notificationFailed": "通知の送信に失敗しました"
}, },
"filter": { "filter": {
"placeholder": "このフォルダとサブフォルダを検索…",
"keyword": "キーワード",
"advanced": "フィルター", "advanced": "フィルター",
"recursive": "サブフォルダを含める", "recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果", "results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "名前", "name": "名前",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "알림 전송에 실패했습니다" "shared_notificationFailed": "알림 전송에 실패했습니다"
}, },
"filter": { "filter": {
"placeholder": "이 폴더와 하위 폴더 검색…",
"keyword": "키워드",
"advanced": "필터", "advanced": "필터",
"recursive": "하위 폴더 포함", "recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과", "results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "이름", "name": "이름",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Notificatie verzenden mislukt" "shared_notificationFailed": "Notificatie verzenden mislukt"
}, },
"filter": { "filter": {
"placeholder": "Deze map en submappen doorzoeken…",
"keyword": "Trefwoord",
"advanced": "Filters", "advanced": "Filters",
"recursive": "Submappen opnemen", "recursive": "Submappen opnemen",
"results_count": "{{n}} resultaten", "results_count": "{{n}} resultaten",
"clear_keyword": "Zoekopdracht wissen" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Naam", "name": "Naam",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Nie udało się wysłać powiadomienia" "shared_notificationFailed": "Nie udało się wysłać powiadomienia"
}, },
"filter": { "filter": {
"placeholder": "Szukaj w tym folderze i podfolderach…",
"keyword": "Słowo kluczowe",
"advanced": "Filtry", "advanced": "Filtry",
"recursive": "Uwzględnij podfoldery", "recursive": "Uwzględnij podfoldery",
"results_count": "Wyniki: {{n}}", "results_count": "Wyniki: {{n}}",
"clear_keyword": "Wyczyść wyszukiwanie" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nazwa", "name": "Nazwa",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Falha ao enviar a notificação" "shared_notificationFailed": "Falha ao enviar a notificação"
}, },
"filter": { "filter": {
"placeholder": "Pesquisar nesta pasta e subpastas…",
"keyword": "Palavra-chave",
"advanced": "Filtros", "advanced": "Filtros",
"recursive": "Incluir subpastas", "recursive": "Incluir subpastas",
"results_count": "{{n}} resultados", "results_count": "{{n}} resultados",
"clear_keyword": "Limpar pesquisa" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Nome", "name": "Nome",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_notificationFailed": "Не удалось отправить уведомление" "shared_notificationFailed": "Не удалось отправить уведомление"
}, },
"filter": { "filter": {
"placeholder": "Поиск в этой папке и подпапках…",
"keyword": "Ключевое слово",
"advanced": "Фильтры", "advanced": "Фильтры",
"recursive": "Включая подпапки", "recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}", "results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск" "modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
"kind": {
"all": "Files and folders",
"file": "Files only",
"folder": "Folders only"
}
}, },
"files": { "files": {
"name": "Имя", "name": "Имя",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_typeFolder": "資料夾" "shared_typeFolder": "資料夾"
}, },
"filter": { "filter": {
"placeholder": "搜尋此資料夾及子資料夾…",
"keyword": "關鍵字",
"advanced": "篩選", "advanced": "篩選",
"recursive": "包含子資料夾", "recursive": "包含子資料夾",
"results_count": "{{n}} 個結果", "results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋" "modified_label": "修改時間",
"created_label": "建立時間",
"kind_label": "項目",
"kind": {
"all": "檔案與資料夾",
"file": "僅檔案",
"folder": "僅資料夾"
}
}, },
"files": { "files": {
"name": "名稱", "name": "名稱",
+8 -3
View File
@@ -417,12 +417,17 @@
"shared_typeFolder": "文件夹" "shared_typeFolder": "文件夹"
}, },
"filter": { "filter": {
"placeholder": "搜索此文件夹及子文件夹…",
"keyword": "关键词",
"advanced": "筛选", "advanced": "筛选",
"recursive": "包含子文件夹", "recursive": "包含子文件夹",
"results_count": "{{n}} 个结果", "results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索" "modified_label": "修改时间",
"created_label": "创建时间",
"kind_label": "条目",
"kind": {
"all": "文件和文件夹",
"file": "仅文件",
"folder": "仅文件夹"
}
}, },
"files": { "files": {
"name": "名称", "name": "名称",
@@ -1247,7 +1247,7 @@ impl FileReadPort for FileBlobReadRepository {
} }
if let Some(name) = &criteria.name_contains if let Some(name) = &criteria.name_contains
&& name.len() >= 3 && super::name_filter_active(name)
{ {
bind_idx += 1; bind_idx += 1;
conditions.push(format!("fi.name ILIKE ${bind_idx}")); conditions.push(format!("fi.name ILIKE ${bind_idx}"));
@@ -1319,7 +1319,7 @@ impl FileReadPort for FileBlobReadRepository {
query = query.bind(fid); query = query.bind(fid);
} }
if let Some(name) = &criteria.name_contains if let Some(name) = &criteria.name_contains
&& name.len() >= 3 && super::name_filter_active(name)
{ {
query = query.bind(super::like_escape(name)); query = query.bind(super::like_escape(name));
} }
@@ -1412,7 +1412,7 @@ impl FileReadPort for FileBlobReadRepository {
); );
if let Some(name) = &criteria.name_contains if let Some(name) = &criteria.name_contains
&& name.len() >= 3 && super::name_filter_active(name)
{ {
bind_idx += 1; bind_idx += 1;
conditions.push(format!("fi.name ILIKE ${bind_idx}")); conditions.push(format!("fi.name ILIKE ${bind_idx}"));
@@ -1477,7 +1477,7 @@ impl FileReadPort for FileBlobReadRepository {
.bind(root_id); .bind(root_id);
if let Some(name) = &criteria.name_contains if let Some(name) = &criteria.name_contains
&& name.len() >= 3 && super::name_filter_active(name)
{ {
query = query.bind(super::like_escape(name)); query = query.bind(super::like_escape(name));
} }
@@ -1147,7 +1147,7 @@ impl FolderRepository for FolderDbRepository {
// Build optional name filter — use ILIKE (case-insensitive) so the // Build optional name filter — use ILIKE (case-insensitive) so the
// GIN trigram index idx_folders_name_trgm is used instead of a seq scan. // GIN trigram index idx_folders_name_trgm is used instead of a seq scan.
let (name_clause, name_pattern) = match name_contains { let (name_clause, name_pattern) = match name_contains {
Some(name) if name.len() >= 3 => ( Some(name) if super::name_filter_active(name) => (
if recursive { if recursive {
" AND fo.name ILIKE $2" " AND fo.name ILIKE $2"
} else { } else {
@@ -1228,7 +1228,7 @@ impl FolderRepository for FolderDbRepository {
} else { } else {
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern) // Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
let name_clause_root = match name_contains { let name_clause_root = match name_contains {
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2", Some(name) if super::name_filter_active(name) => " AND fo.name ILIKE $2",
_ => "", _ => "",
}; };
format!( format!(
@@ -1295,7 +1295,7 @@ impl FolderRepository for FolderDbRepository {
caller_id: Uuid, caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> { ) -> Result<Vec<Folder>, DomainError> {
let (where_extra, name_pattern) = match name_contains { let (where_extra, name_pattern) = match name_contains {
Some(name) if name.len() >= 3 => { Some(name) if super::name_filter_active(name) => {
(" AND fo.name ILIKE $3", Some(super::like_escape(name))) (" AND fo.name ILIKE $3", Some(super::like_escape(name)))
} }
_ => ("", None), _ => ("", None),
+43
View File
@@ -77,3 +77,46 @@ pub fn like_escape(raw: &str) -> String {
.replace('_', "\\_"); .replace('_', "\\_");
format!("%{escaped}%") format!("%{escaped}%")
} }
/// Whether a search's `name_contains` value should produce an `ILIKE`
/// predicate: any non-blank query filters, including 1–2 character ones.
///
/// The historical `name.len() >= 3` bytes gate existed because the pg_trgm
/// GIN index cannot accelerate sub-trigram patterns — but gating on it here
/// *dropped the name condition entirely* for shorter queries, so a search
/// for `ab` returned an arbitrary page of the caller's files instead of
/// matches (and disagreed with `suggest_files_by_name`, which never had the
/// gate). Wrong-but-indexed is never acceptable in a storage product: the
/// caller/folder scoping in every caller of this predicate still bounds the
/// scanned set, and result pages are `LIMIT`-bound.
#[inline]
pub fn name_filter_active(name: &str) -> bool {
!name.trim().is_empty()
}
#[cfg(test)]
mod name_filter_tests {
use super::name_filter_active;
#[test]
fn one_and_two_char_queries_still_filter() {
// The old `len() >= 3` bytes gate silently dropped the name
// condition for these — this test is the regression guard.
assert!(name_filter_active("a"));
assert!(name_filter_active("ab"));
}
#[test]
fn single_cjk_char_filters() {
// 3 bytes in UTF-8: already passed the old gate, must keep passing.
assert!(name_filter_active("合"));
}
#[test]
fn blank_queries_do_not_filter() {
// Blank means "match everything" — no ILIKE predicate is emitted.
assert!(!name_filter_active(""));
assert!(!name_filter_active(" "));
assert!(!name_filter_active("\t\n"));
}
}
+73
View File
@@ -9,6 +9,79 @@
## 已完成 ## 已完成
### [2026-10-01] 修复搜索框短关键词失效(<3 字节查询静默丢名字过滤)
- **状态**: 已完成(cargo fmt ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;
`cargo test --lib` 930 通过 0 失败,含新增 3 个回归守卫测试 ✓)
- **计划**: 修复"原先的搜索框模糊搜索能力有问题"。根因:7 处 SQL 仓库把名字 `ILIKE`
条件门在 `name.len() >= 3`(**字节**)上——查询短于 3 字节时名字条件被**整体丢弃**,
`/api/search?q=ab` 返回任意一页文件而非匹配项;而 suggest 下拉(`suggest_files_by_name`)
没有此门槛,所以表现为"下拉能找到、结果页找不到"。门槛初衷是 pg_trgm GIN 索引
无法加速 <3 字符的 pattern,但"错误但有索引"不可接受(韧性优先):调用方的
caller/folder 作用域仍约束扫描集,结果页有 LIMIT。
- **修法**: `pg/mod.rs` 新增共享判定 `name_filter_active`(非空白即过滤,trim 后判空)
+ 3 个单测(1/2 字符、CJK 单字 3 字节、空白不过滤);7 处门槛全部换用该判定:
- `src/infrastructure/repositories/pg/file_blob_read_repository.rs` — 4 处
(search_files_paginated 条件+bind、search_files_in_subtree 条件+bind;
条件与 bind 成对替换,参数序号不错位)
- `src/infrastructure/repositories/pg/folder_db_repository.rs` — 3 处
(search_folders、根目录臂、list_descendant_folders 的 match guard)
- 刻意不动:`search_service.rs` L404 Tantivy 内容索引的 `>= 2` 门槛(语义是
"太短的词不进全文索引",非正确性门槛);`search_dto.rs` 无校验,保持原样
- **改动文件**(均为上游文件,局部替换):
- `src/infrastructure/repositories/pg/mod.rs` — `name_filter_active` + `mod name_filter_tests`
- `src/infrastructure/repositories/pg/file_blob_read_repository.rs` — 4 处判定替换
- `src/infrastructure/repositories/pg/folder_db_repository.rs` — 3 处判定替换
- **仅本地文件**: 无新增(`status.md` 本身)
- **上游冲突风险**: 低 — 纯判定函数替换,无 SQL/结构改动;若上游也修此 bug 会天然收敛
- **已知边界(未处理,记为后续)**: 词序模糊("report 2026" 匹配 "2026 report.docx")
与分词匹配不在本次范围——`ILIKE %…%` 是子串语义,需要 tsvector/pg_trgm 相似度
或分词方案,属功能增强而非缺陷修复
### [2026-10-01] 移除文件页搜索过滤栏(顶栏统一入口)
- **状态**: 已完成(`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败)
- **计划**: 顶栏搜索框与筛选面板(`29d0c335`)已提供同一套筛选能力后,文件页自己的
`SearchFilterBar` 成为重复的第二个输入框,按用户决定移除。共享 store、URL 同步、
文件页的就地搜索模式**全部保留**——顶栏(搜索框 + 面板)现在是唯一的筛选写入方。
- **移除后的能力分布**: 预设筛选(类型/大小/修改/创建/种类/递归)在顶栏面板改共享 store →
文件页就地过滤;关键词在顶栏搜索框提交 → 跳 `/search?q=…&in=<当前文件夹>`(范围跟随);
`?q=` 深链仍直接在文件页水合生效。
- **改动文件**:
- 删除(仅本地): `frontend/src/lib/components/SearchFilterBar.svelte`
- `frontend/src/routes/files/[...path]/+page.svelte` — 删组件导入与挂载(`.files-filter-row`),
搜索模式/store/URL 同步不动;仅注释措辞更新("SearchFilterBar dimension"→"filter dimension")
- `frontend/src/routes/files/page.test.ts` — 4 个测试改为直接写共享 store 驱动(`resourceFilters.query = …`,
与顶栏同一写入路径),不再依赖已删除组件的 testid;beforeEach 补 `resetFilters()` + URL 复位防串扰
- `frontend/static/locales/*.json`(16 个)— 删孤儿 key `filter.placeholder`/`filter.keyword`/`filter.clear_keyword`
(其余 `filter.*` 被顶栏面板/FilterFields/FilterChips/文件页结果计数继续使用,保留)
- 注释清理: `filter.svelte.ts`、`FilterFields.svelte`、`AppShell.svelte`(2 处)去掉对已删组件的引用
- **仅本地文件**: 无新增(`status.md` 本身)
- **上游冲突风险**: 低 — 净效果是 -323 行;SearchFilterBar 本就是仅本地组件,删除后与上游的冲突面
比之前更小。locales 各文件仅 3 行删除。
### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定)
- **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。
- **改动文件**:
- 新增(仅本地):
- `frontend/src/lib/stores/filter.svelte.ts` — 共享筛选 store(模块级 `$state`,唯一真值)
- `frontend/src/lib/composables/useFilterUrlSync.svelte.ts` — store↔URL 双向同步(单 `$effect` + `lastSeenSearch` 回声抑制,一轮收敛不成环)
- `frontend/src/lib/components/FilterFields.svelte` — 5 个 select(类型/大小/修改/创建/条目种类)+ 递归开关,词表与 i18n 标签集中于此(inline/panel 两种布局)
- `frontend/src/lib/components/TopBarFilterPanel.svelte` — 顶栏下拉面板壳(清除/完成按钮,激活计数驱动 badge)
- `frontend/src/lib/components/FilterChips.svelte` — `/search` 可撤销 chips(按维度撤销 + 清除全部)
- 仅本地改造:
- `frontend/src/lib/utils/searchFilters.ts` — 新维度 `kind`/`created` + URL 序列化(`filtersFromParams`/`filterParamEntries`/`filterParamsToString`/`applyFilterParams`/`filterSearchOver`);测试 29 条
- `frontend/src/lib/components/SearchFilterBar.svelte` — 展开区改复用 `FilterFields`;`value` 为普通 prop(原地改共享代理,**故意不用 `$bindable`**,导入绑定不可再赋值)
- 上游文件:
- `frontend/src/lib/components/AppShell.svelte` — 搜索框内筛选按钮(右 44px,激活 badge)+ 面板接线;`searchActive`→`mobileSearchOpen`(CSS 类名不动);提交/建议吃筛选(`filterToSearchOptions`);Escape 链插入面板;全局点击关面板(`.search-container` 内不关)
- `frontend/src/lib/styles/ported/topbar.css` — 输入框右内边距 50→104px(预留 submit/筛选/清除三个控件)单行改动
- `frontend/src/routes/search/+page.svelte` — URL 驱动(`useFilterUrlSync(false)`)、删三个页内 select 与本地词表、接 `FilterChips`、`setScope` 只动 `in`/`scope`
- `frontend/src/routes/files/[...path]/+page.svelte` — 删本地 filter 状态改绑共享 store、`useFilterUrlSync(true)`、`folderTarget` 携带筛选参数(就地筛选/导航存活两不误)、驱动 effect 增加 `created`/`kind` 依赖
- `frontend/static/locales/*.json`(16 个)— `filter` 块新增 `modified_label`/`created_label`/`kind_label`/`kind.{all,file,folder}`(zh/zh-TW 真翻译,其余英文兜底;fr.json 按行拼接保持既有格式)
- 测试:`AppShell.test.ts`(+2:面板开关/预设随提交/清除)、`search/page.test.ts`(+2:URL 水合+chips、非法值回落)、`files/page.test.ts`(+2:深链水合+wire 参数、关键词写回恰好一次)、`searchFilters.test.ts`(扩到 31 条)
- **仅本地文件**: 上述 5 个新增源文件 + `status.md`
- **上游冲突风险**: 高 — `AppShell.svelte`、`topbar.css`、`routes/search/+page.svelte`、16 个 locale 均为上游活跃区。合并核对要点:① store 是唯一真值、URL 是投影(`lastSeenSearch` 回声抑制,勿改回双向各自 `goto`);② `?q=` 归属——files 页 `includeQuery=true`、`/search` 与顶栏 `false`(`applyFilterParams` 删 `q` 前先看该开关);③ `folderTarget` 三处 `goto` 带 `eslint-disable`(helper 内嵌 `resolve()`,规则看不穿);④ `filterSearchOver` 下沉在普通 `.ts` 是为避开 `.svelte.ts` 的 `prefer-svelte-reactivity`(勿移回 composable);⑤ 已知 dev-only 警告 `ownership_invalid_mutation`(共享代理按普通 prop 传入+原地改的既定设计,生产无影响)
### [2026-09-19] 缩略图路径 DB 点查缓存(find_attached_blob 进程内缓存) ### [2026-09-19] 缩略图路径 DB 点查缓存(find_attached_blob 进程内缓存)
- **状态**: 已完成(commit `d33d1932`;cargo fmt --check ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;`cargo test --lib` 927 通过 0 失败,含新增 5 个缓存契约测试 ✓) - **状态**: 已完成(commit `d33d1932`;cargo fmt --check ✓;clippy --all-features --all-targets -D warnings 0 警告 ✓;`cargo test --lib` 927 通过 0 失败,含新增 5 个缓存契约测试 ✓)
- **计划**: 给缩略图热路径(ETag 计算 `thumbnail_content_id` + tier 2b)每次请求都要打的 `find_attached_blob` DB 点查加进程内缓存(moka::future + try_get_with,正+负缓存,Err 不入缓存;写路径成功后失效;删除经 `ThumbnailRefreshHook::on_file_deleted` 搭车失效 + 60s TTL 兜底)。缓解"每次进照片墙 = 每张可见图 1-2 次 DB 点查"的负载。 - **计划**: 给缩略图热路径(ETag 计算 `thumbnail_content_id` + tier 2b)每次请求都要打的 `find_attached_blob` DB 点查加进程内缓存(moka::future + try_get_with,正+负缓存,Err 不入缓存;写路径成功后失效;删除经 `ThumbnailRefreshHook::on_file_deleted` 搭车失效 + 60s TTL 兜底)。缓解"每次进照片墙 = 每张可见图 1-2 次 DB 点查"的负载。