diff --git a/src/App.tsx b/src/App.tsx index 5e0c2485..52a1ac20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,7 @@ import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' import type { SidebarSelection, VaultEntry } from './types' import type { NoteListItem } from './utils/ai-context' -import { filterEntries } from './utils/noteListHelpers' +import { filterEntries, type NoteListFilter } from './utils/noteListHelpers' import { openLocalFile } from './utils/url' import { flushEditorContent } from './utils/autoSave' import './App.css' @@ -70,6 +70,11 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } /** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */ function App() { const [selection, setSelection] = useState(DEFAULT_SELECTION) + const [noteListFilter, setNoteListFilter] = useState('open') + const handleSetSelection = useCallback((sel: SidebarSelection) => { + setSelection(sel) + setNoteListFilter('open') + }, []) const layout = useLayoutPanels() const [toastMessage, setToastMessage] = useState(null) const dialogs = useDialogs() @@ -78,7 +83,7 @@ function App() { // called on user interaction, never during render (refs inside the hook // guarantee the latest closure is always used). const vaultSwitcher = useVaultSwitcher({ - onSwitch: () => { setSelection(DEFAULT_SELECTION); notes.closeAllTabs() }, + onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() }, onToast: (msg) => setToastMessage(msg), }) @@ -199,7 +204,7 @@ function App() { onOpenNote: openNoteByPath, onOpenTab: openNoteByPath, onSetFilter: (filterType) => { - setSelection({ kind: 'sectionGroup', type: filterType }) + handleSetSelection({ kind: 'sectionGroup', type: filterType }) }, onVaultChanged: () => { vault.reloadVault() }, }) @@ -441,7 +446,7 @@ function App() { onToggleRawEditor: () => rawToggleRef.current(), onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, - onSelect: setSelection, onCloseTab: notes.handleCloseTab, + onSelect: handleSetSelection, onCloseTab: notes.handleCloseTab, onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, onGoBack: handleGoBack, onGoForward: handleGoForward, @@ -451,7 +456,7 @@ function App() { onCreateTheme: async () => { const path = await themeManager.createTheme() const freshEntries = await vault.reloadVault() - setSelection({ kind: 'sectionGroup', type: 'Theme' }) + handleSetSelection({ kind: 'sectionGroup', type: 'Theme' }) if (path) { const entry = freshEntries.find(e => e.path === path) if (entry) notes.handleSelectNote(entry) @@ -484,6 +489,8 @@ function App() { const ae = vault.entries.find(e => e.path === notes.activeTabPath) return !!(ae?.icon && isEmoji(ae.icon)) })(), + noteListFilter, + onSetNoteListFilter: setNoteListFilter, }) const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null @@ -517,7 +524,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -528,7 +535,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - + )} @@ -600,7 +607,7 @@ function App() { /> )} - setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> setToastMessage(null)} /> diff --git a/src/components/BulkActionBar.test.tsx b/src/components/BulkActionBar.test.tsx index 9a5b4e1d..1667eb00 100644 --- a/src/components/BulkActionBar.test.tsx +++ b/src/components/BulkActionBar.test.tsx @@ -21,11 +21,11 @@ describe('BulkActionBar', () => { expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument() }) - it('shows Restore and Delete permanently in trash view', () => { + it('shows Restore, Archive, and Delete permanently in trash view', () => { render() expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument() + expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument() expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument() - expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument() expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument() }) @@ -47,4 +47,20 @@ describe('BulkActionBar', () => { render() expect(screen.getByText('5 selected')).toBeInTheDocument() }) + + it('shows Unarchive and Trash buttons in archived view', () => { + render() + expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument() + expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument() + expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument() + expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument() + expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument() + }) + + it('calls onUnarchive when Unarchive button clicked in archived view', () => { + const onUnarchive = vi.fn() + render() + fireEvent.click(screen.getByTestId('bulk-unarchive-btn')) + expect(onUnarchive).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index e603584d..82437116 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -4,17 +4,61 @@ import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react' interface BulkActionBarProps { count: number isTrashView: boolean + isArchivedView?: boolean onArchive: () => void onTrash: () => void onRestore: () => void onDeletePermanently: () => void + onUnarchive?: () => void onClear: () => void } const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const -function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore, onDeletePermanently, onClear }: BulkActionBarProps) { +function renderTrashActions(onRestore: () => void, onArchive: () => void, onDeletePermanently: () => void) { + return ( + <> + + + + + ) +} + +function renderArchivedActions(onUnarchive: (() => void) | undefined, onTrash: () => void) { + return ( + <> + + + + ) +} + +function renderDefaultActions(onArchive: () => void, onTrash: () => void) { + return ( + <> + + + + ) +} + +function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onTrash, onRestore, onDeletePermanently, onUnarchive, onClear }: BulkActionBarProps) { return (
- {isTrashView ? ( - <> - - - - ) : ( - <> - - - - )} + {isTrashView ? renderTrashActions(onRestore, onArchive, onDeletePermanently) + : isArchivedView ? renderArchivedActions(onUnarchive, onTrash) + : renderDefaultActions(onArchive, onTrash)} + ))} +
+ ) +} + +export const FilterPills = memo(FilterPillsInner) diff --git a/src/components/note-list/NoteListViews.tsx b/src/components/note-list/NoteListViews.tsx index a3f07a43..9e09c5a7 100644 --- a/src/components/note-list/NoteListViews.tsx +++ b/src/components/note-list/NoteListViews.tsx @@ -11,10 +11,11 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: { return } -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 }) { - 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 diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts index 48cc0f71..a4a82d25 100644 --- a/src/components/note-list/noteListHooks.ts +++ b/src/components/note-list/noteListHooks.ts @@ -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, modifiedSuffixes: string[]) { +export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, 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; 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 modifiedSuffixes: string[] + subFilter?: NoteListFilter onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => 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>(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(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index bcde476b..7a069749 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -5,6 +5,7 @@ import type { CommandAction } from './useCommandRegistry' import { useKeyboardNavigation } from './useKeyboardNavigation' import { useMenuEvents } from './useMenuEvents' import type { SidebarSelection, ThemeFile, VaultEntry } from '../types' +import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' interface Tab { entry: VaultEntry; content: string } @@ -74,6 +75,8 @@ interface AppCommandsConfig { onSetNoteIcon?: () => void onRemoveNoteIcon?: () => void activeNoteHasIcon?: boolean + noteListFilter?: NoteListFilter + onSetNoteListFilter?: (filter: NoteListFilter) => void } /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ @@ -223,6 +226,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onSetNoteIcon: config.onSetNoteIcon, onRemoveNoteIcon: config.onRemoveNoteIcon, activeNoteHasIcon: config.activeNoteHasIcon, + selection: config.selection, + noteListFilter: config.noteListFilter, + onSetNoteListFilter: config.onSetNoteListFilter, }) useKeyboardNavigation({ diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 3968eec6..7c64f2e3 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import type { SidebarSelection, ThemeFile, VaultEntry } from '../types' +import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings' @@ -71,6 +72,10 @@ interface CommandRegistryConfig { onRestoreDefaultThemes?: () => void isGettingStartedHidden?: boolean vaultCount?: number + /** Current selection — used to scope filter pill commands to section group views. */ + selection?: SidebarSelection + noteListFilter?: NoteListFilter + onSetNoteListFilter?: (filter: NoteListFilter) => void } const PLURAL_OVERRIDES: Record = { @@ -212,8 +217,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, + selection, noteListFilter, onSetNoteListFilter, } = config + const isSectionGroup = selection?.kind === 'sectionGroup' const hasActiveNote = activeTabPath !== null const activeEntry = useMemo( @@ -292,6 +299,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction // Type-aware: "New [Type]" and "List [Type]" ...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect), + + // Note list filter pills (scoped to section group views) + { id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') }, + { id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') }, + { id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') }, ] return cmds @@ -310,5 +322,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onEmptyTrash, trashedCount, onReindexVault, onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, + isSectionGroup, noteListFilter, onSetNoteListFilter, ]) } diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 2862279f..a4751d8c 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -1,5 +1,7 @@ import type { VaultEntry, SidebarSelection } from '../types' +export type NoteListFilter = 'open' | 'archived' | 'trashed' + export interface RelationshipGroup { label: string entries: VaultEntry[] @@ -313,10 +315,17 @@ export function buildRelationshipGroups( const isActive = (e: VaultEntry) => !e.archived && !e.trashed -function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] { +function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] { + if (subFilter === 'archived') return entries.filter((e) => e.archived && !e.trashed) + if (subFilter === 'trashed') return entries.filter((e) => e.trashed) + return entries.filter(isActive) +} + +function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] { if (selection.kind === 'entity') return [] if (selection.kind === 'sectionGroup') { - return entries.filter((e) => e.isA === selection.type && isActive(e)) + const typeEntries = entries.filter((e) => e.isA === selection.type) + return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive) } if (selection.kind === 'topic') { return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e)) @@ -332,6 +341,18 @@ function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] return [] } -export function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] { - return filterByKind(entries, selection) +export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] { + return filterByKind(entries, selection, subFilter) +} + +/** Count notes per sub-filter for a given type. */ +export function countByFilter(entries: VaultEntry[], type: string): Record { + let open = 0, archived = 0, trashed = 0 + for (const e of entries) { + if (e.isA !== type) continue + if (e.trashed) trashed++ + else if (e.archived) archived++ + else open++ + } + return { open, archived, trashed } }