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>
This commit is contained in:
2026-10-01 01:10:11 +08:00
parent deb6dc831b
commit bd2c7dc45e
23 changed files with 66 additions and 368 deletions
+2 -2
View File
@@ -400,7 +400,7 @@
let suggestBusy = $state(false);
// Live count of active filter presets — drives the toggle button's badge
// and its "filters applied" highlight. The shared store is also what the
// panel (below) and the files page's filter bar mutate.
// panel (below) and the /search page mutate.
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Stale-response guard (same family as the search page): the debounce
@@ -420,7 +420,7 @@
// variant; these params don't need reactivity anyway.
const parts = [`q=${encodeURIComponent(q)}`];
// Active filter presets ride along (`type=image&kind=file&…`) — the
// shared store is what the top-bar panel and the files page's bar
// 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);
@@ -2,11 +2,11 @@
The filter dimensions themselves (type / size / modified / created / kind /
recursive), bound to the shared `ResourceFilterState`.
Extracted so the files page's filter bar and the top bar's filter panel
render the same fields off the same vocabulary — the option lists used to
live in two copies (SearchFilterBar and the /search page). The vocabularies
(extensions, byte bounds, date presets) stay in `$lib/utils/searchFilters`;
only the i18n labels live here.
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';
@@ -1,266 +0,0 @@
<script lang="ts">
import FilterFields from '$lib/components/FilterFields.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import {
activeFilterCount,
defaultFilterState,
type ResourceFilterState
} from '$lib/utils/searchFilters';
interface Props {
/**
* Full filter state (keyword + toggles + presets), mutated in place —
* in practice the shared store proxy from `$lib/stores/filter.svelte`.
* Deliberately NOT `$bindable`: there is exactly one state object and
* nobody reassigns it, so a two-way binding contract would be noise.
*/
value?: ResourceFilterState;
/** Advanced section (presets + recursive) expanded. */
expanded?: boolean;
placeholder?: string;
/** Debounce for the keyword input (ms). */
debounceMs?: number;
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
hideRecursive?: boolean;
}
let {
value = defaultFilterState(),
expanded = $bindable(false),
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
debounceMs = 300,
hideRecursive = false
}: Props = $props();
// Keyword buffer: typing updates the buffer immediately (responsive input)
// and pushes into `value.query` debounced, so a keystroke doesn't fire a
// backend search per character. `lastPushed` disambiguates our own pushes
// from external writes (e.g. the page's clear-filter Escape path, or a
// URL hydration of a deep link like /files/…?q=abc), which flow back into
// the buffer via the sync effect below — including on first mount, which
// is why both start empty instead of capturing `value.query` here.
let keyword = $state('');
let lastPushed = '';
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// External `value.query` change → adopt it into the input buffer.
const external = value.query;
if (external !== lastPushed) {
if (timer) {
clearTimeout(timer);
timer = null;
}
keyword = external;
lastPushed = external;
}
});
$effect(() => {
// No reactive deps — teardown-only, clearing a pending debounce on destroy.
return () => {
if (timer) clearTimeout(timer);
};
});
function pushKeyword(v: string) {
if (timer) {
clearTimeout(timer);
timer = null;
}
value.query = v;
lastPushed = v;
}
function handleInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
keyword = v;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
pushKeyword(v);
}, debounceMs);
}
function onInputKeydown(e: KeyboardEvent) {
// Escape clears the keyword locally and never reaches the page-level
// handler (which would otherwise also clear the selection / filters).
if (e.key === 'Escape') {
e.stopPropagation();
clearKeyword();
} else if (e.key === 'Enter') {
// Enter flushes the debounce for an immediate search.
e.preventDefault();
pushKeyword(keyword);
}
}
function clearKeyword() {
keyword = '';
pushKeyword('');
}
const activeCount = $derived(activeFilterCount(value));
</script>
<div class="sfb" data-testid="search-filter-bar">
<div class="sfb__row">
<div class="sfb__input-wrap">
<span class="sfb__magnifier"><Icon name="search" /></span>
<input
class="sfb__input"
type="search"
{placeholder}
aria-label={t('filter.keyword', 'Keyword')}
data-testid="filter-keyword-input"
value={keyword}
oninput={handleInput}
onkeydown={onInputKeydown}
/>
{#if keyword.length > 0}
<button
class="sfb__clear"
type="button"
aria-label={t('filter.clear_keyword', 'Clear search')}
data-testid="filter-clear-keyword-btn"
onclick={clearKeyword}
>
<Icon name="times" />
</button>
{/if}
</div>
<button
class="sfb__toggle"
class:sfb__toggle--active={activeCount > 0}
type="button"
aria-expanded={expanded}
aria-label={t('filter.advanced', 'Filters')}
title={t('filter.advanced', 'Filters')}
data-testid="filter-advanced-toggle-btn"
onclick={() => (expanded = !expanded)}
>
<Icon name="sliders-h" />
{#if activeCount > 0}
<span class="sfb__badge">{activeCount}</span>
{/if}
</button>
</div>
{#if expanded}
<div data-testid="filter-advanced-row">
<FilterFields bind:value {hideRecursive} />
</div>
{/if}
</div>
<style>
.sfb {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 100%;
}
.sfb__row {
display: flex;
align-items: center;
gap: var(--space-1);
}
.sfb__input-wrap {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.sfb__magnifier {
position: absolute;
left: 0.6rem;
color: var(--color-text-secondary);
pointer-events: none;
}
.sfb__input {
width: 100%;
padding: 0.45rem 2rem 0.45rem 2.1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.sfb__input:focus {
outline: none;
border-color: var(--color-accent);
}
.sfb__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.sfb__clear {
position: absolute;
right: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.sfb__clear:hover {
background: var(--color-bg-hover);
color: var(--color-text);
}
.sfb__toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
flex-shrink: 0;
}
.sfb__toggle:hover {
background: var(--color-bg-hover);
}
.sfb__toggle--active {
border-color: var(--color-accent);
color: var(--color-accent);
}
.sfb__badge {
position: absolute;
top: -0.4rem;
right: -0.4rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: var(--color-on-accent);
font-size: var(--text-xs, 0.7rem);
line-height: 1rem;
text-align: center;
}
</style>
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* Shared resource-filter state for the search-backed surfaces.
*
* One module-level `$state` object is THE filter state: the files page's
* `SearchFilterBar`, the top bar's filter panel, and the /search chips all
* read and mutate this same proxy, and each surface projects it into its URL
* 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.
@@ -75,7 +75,6 @@
batchZipName,
type ActionTarget
} from '$lib/composables/useResourceActions.svelte';
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
// Message-bus logger. Users can tune with
// oxi.setLogLevel('oxi:message-bus', 'debug')
@@ -451,7 +450,7 @@
}
// ── 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`
// with folder_id = currentId). The two data paths keep independent cursors
// and stale guards; entering/leaving the mode neutralizes the other path's
@@ -2012,15 +2011,8 @@
<ReadOnlyBanner driveName={currentDrive.name} />
{/if}
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
current folder (recursive toggle inside). While any dimension is
active the listing below switches from the folder page to the
search results; clearing it returns to the plain folder view. The
bar mutates the shared store proxy in place — no bind, the state
lives in `$lib/stores/filter.svelte`. -->
<div class="files-filter-row">
<SearchFilterBar value={filter} />
</div>
<!-- Filtering is driven from the top bar (search box + filter panel),
which mutates the shared store this page's search mode reads. -->
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
Kept OUTSIDE ResourceList so the split-button dropdown in the
@@ -2545,9 +2537,4 @@
rendered near-invisible here). Mirrors the user-menu logout red. */
color: var(--color-danger-alt);
}
.files-filter-row {
padding: 0 var(--space-2);
margin-bottom: var(--space-1);
}
</style>
+32 -28
View File
@@ -85,6 +85,7 @@ import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUplo
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
import { searchResources } from '$lib/api/endpoints/search';
import { files as filesStore } from '$lib/stores/files.svelte';
import { resourceFilters, resetFilters } from '$lib/stores/filter.svelte';
import FilesPage from './[...path]/+page.svelte';
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
// listing-oriented tests target a folder directly.
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.
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 });
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
expect(searchResources).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ folderId: 'home', recursive: true })
// The keyword arrives through the shared filter store — the top-bar
// search box and filter panel are its writers on this page.
resourceFilters.query = 'hello';
await waitFor(() =>
expect(searchResources).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ folderId: 'home', recursive: true })
)
);
});
@@ -312,14 +318,15 @@ it('shows the search result count while the filter is active', async () => {
});
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'found' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
resourceFilters.query = 'found';
// Sync on the search having fired (not just the store write): from this
// point the mocked response is already on the microtask queue, so the
// text wait below observes the post-search DOM deterministically.
await waitFor(() => expect(searchResources).toHaveBeenCalled());
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 () => {
@@ -334,9 +341,6 @@ it('hydrates filter presets from a deep-linked URL and runs the scoped search',
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
resourceTypes: ['folder']
});
// The bar's own select reflects the hydrated state (expand the row first).
await fireEvent.click(screen.getByTestId('filter-advanced-toggle-btn'));
expect((screen.getByTestId('filter-type-select') as HTMLSelectElement).value).toBe('image');
});
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
@@ -345,17 +349,17 @@ it('writes an edited filter back to the URL exactly once (no sync loop)', async
render(FilesPage);
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
m(goto).mockClear();
vi.useFakeTimers();
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
target: { value: 'hello' }
});
await vi.advanceTimersByTimeAsync(400);
vi.useRealTimers();
// One replaceState write for the debounced keyword — and nothing else:
// the effect must recognize its own write and settle, not ping-pong.
expect(goto).toHaveBeenCalledTimes(1);
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);
});
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "فشل إرسال الإشعار"
},
"filter": {
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
"keyword": "الكلمة المفتاحية",
"advanced": "المرشحات",
"recursive": "تضمين المجلدات الفرعية",
"results_count": "{{n}} نتائج",
"clear_keyword": "مسح البحث",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
},
"filter": {
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
"keyword": "Suchbegriff",
"advanced": "Filter",
"recursive": "Unterordner einbeziehen",
"results_count": "{{n}} Ergebnisse",
"clear_keyword": "Suche löschen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -452,12 +452,9 @@
"shared_notificationFailed": "Failed to send notification"
},
"filter": {
"placeholder": "Search this folder and subfolders…",
"keyword": "Keyword",
"advanced": "Filters",
"recursive": "Include subfolders",
"results_count": "{{n}} results",
"clear_keyword": "Clear search",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"title": "Menú de usuario"
},
"filter": {
"placeholder": "Buscar en esta carpeta y subcarpetas…",
"keyword": "Palabra clave",
"advanced": "Filtros",
"recursive": "Incluir subcarpetas",
"results_count": "{{n}} resultados",
"clear_keyword": "Borrar búsqueda",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
},
"filter": {
"placeholder": "جستجو در این پوشه و زیرپوشه‌ها…",
"keyword": "کلیدواژه",
"advanced": "فیلترها",
"recursive": "شامل زیرپوشه‌ها",
"results_count": "{{n}} نتیجه",
"clear_keyword": "پاک کردن جستجو",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Erreur lors de l'envoi de la notification"
},
"filter": {
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
"keyword": "Mot-clé",
"advanced": "Filtres",
"recursive": "Inclure les sous-dossiers",
"results_count": "{{n}} résultats",
"clear_keyword": "Effacer la recherche",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "सूचना भेजने में विफल"
},
"filter": {
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
"keyword": "कीवर्ड",
"advanced": "फ़िल्टर",
"recursive": "सबफ़ोल्डर शामिल करें",
"results_count": "{{n}} परिणाम",
"clear_keyword": "खोज साफ़ करें",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Impossibile inviare la notifica"
},
"filter": {
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
"keyword": "Parola chiave",
"advanced": "Filtri",
"recursive": "Includi sottocartelle",
"results_count": "{{n}} risultati",
"clear_keyword": "Cancella ricerca",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "通知の送信に失敗しました"
},
"filter": {
"placeholder": "このフォルダとサブフォルダを検索…",
"keyword": "キーワード",
"advanced": "フィルター",
"recursive": "サブフォルダを含める",
"results_count": "{{n}} 件の結果",
"clear_keyword": "検索をクリア",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "알림 전송에 실패했습니다"
},
"filter": {
"placeholder": "이 폴더와 하위 폴더 검색…",
"keyword": "키워드",
"advanced": "필터",
"recursive": "하위 폴더 포함",
"results_count": "{{n}}개 결과",
"clear_keyword": "검색 지우기",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Notificatie verzenden mislukt"
},
"filter": {
"placeholder": "Deze map en submappen doorzoeken…",
"keyword": "Trefwoord",
"advanced": "Filters",
"recursive": "Submappen opnemen",
"results_count": "{{n}} resultaten",
"clear_keyword": "Zoekopdracht wissen",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Nie udało się wysłać powiadomienia"
},
"filter": {
"placeholder": "Szukaj w tym folderze i podfolderach…",
"keyword": "Słowo kluczowe",
"advanced": "Filtry",
"recursive": "Uwzględnij podfoldery",
"results_count": "Wyniki: {{n}}",
"clear_keyword": "Wyczyść wyszukiwanie",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Falha ao enviar a notificação"
},
"filter": {
"placeholder": "Pesquisar nesta pasta e subpastas…",
"keyword": "Palavra-chave",
"advanced": "Filtros",
"recursive": "Incluir subpastas",
"results_count": "{{n}} resultados",
"clear_keyword": "Limpar pesquisa",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_notificationFailed": "Не удалось отправить уведомление"
},
"filter": {
"placeholder": "Поиск в этой папке и подпапках…",
"keyword": "Ключевое слово",
"advanced": "Фильтры",
"recursive": "Включая подпапки",
"results_count": "Результатов: {{n}}",
"clear_keyword": "Очистить поиск",
"modified_label": "Modified",
"created_label": "Created",
"kind_label": "Items",
-3
View File
@@ -417,12 +417,9 @@
"shared_typeFolder": "資料夾"
},
"filter": {
"placeholder": "搜尋此資料夾及子資料夾…",
"keyword": "關鍵字",
"advanced": "篩選",
"recursive": "包含子資料夾",
"results_count": "{{n}} 個結果",
"clear_keyword": "清除搜尋",
"modified_label": "修改時間",
"created_label": "建立時間",
"kind_label": "項目",
-3
View File
@@ -417,12 +417,9 @@
"shared_typeFolder": "文件夹"
},
"filter": {
"placeholder": "搜索此文件夹及子文件夹…",
"keyword": "关键词",
"advanced": "筛选",
"recursive": "包含子文件夹",
"results_count": "{{n}} 个结果",
"clear_keyword": "清除搜索",
"modified_label": "修改时间",
"created_label": "创建时间",
"kind_label": "条目",
+21
View File
@@ -9,6 +9,27 @@
## 已完成
### [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`(仅文件/仅文件夹)与创建时间维度。后端零改动。