feat: add filter pills (Open/Archived/Trashed) with count badges to note list

When viewing a type in the sidebar, the note list now shows filter pills
below the header to switch between Open, Archived, and Trashed notes.
Each pill shows a count badge. Bulk actions are context-aware: Trashed
filter offers Restore/Archive/Delete permanently, Archived filter offers
Unarchive/Trash. Cmd+K commands added for switching filters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-18 03:43:23 +01:00
parent b846de15ad
commit 2b786892ee
11 changed files with 458 additions and 165 deletions

View File

@@ -0,0 +1,43 @@
import { memo } from 'react'
import type { NoteListFilter } from '../../utils/noteListHelpers'
interface FilterPillsProps {
active: NoteListFilter
counts: Record<NoteListFilter, number>
onChange: (filter: NoteListFilter) => void
}
const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'archived', label: 'Archived' },
{ value: 'trashed', label: 'Trashed' },
]
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
onClick={() => onChange(value)}
data-testid={`filter-pill-${value}`}
>
{label}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>
</button>
))}
</div>
)
}
export const FilterPills = memo(FilterPillsInner)

View File

@@ -11,10 +11,11 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
if (isArchivedView) return 'No archived notes'
return query ? 'No matching notes' : 'No notes found'
}
@@ -38,13 +39,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
deletedCount?: number; searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig,
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
buildRelationshipGroups, filterEntries,
loadSortPreferences, saveSortPreferences,
@@ -19,14 +19,14 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
return filterEntries(entries, selection)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
return filterEntries(entries, selection, subFilter)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter])
}
// --- useNoteListData ---
@@ -35,13 +35,15 @@ interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
subFilter?: NoteListFilter
}
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const searched = useMemo(() => {
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
@@ -59,7 +61,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
[isTrashView, searched],
)
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
}
// --- useNoteListSearch ---
@@ -122,11 +124,12 @@ export interface UseNoteListSortParams {
selection: SidebarSelection
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
subFilter?: NoteListFilter
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => {
@@ -154,7 +157,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}
}, [typeDocument, persistence])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'