feat: trash management — bulk restore, delete permanently, empty trash

- Add ConfirmDeleteDialog for permanent deletion confirmation
- Update BulkActionBar to show contextual actions (Restore/Delete permanently
  in trash view, Archive/Trash elsewhere)
- Add Empty Trash button in note list header when viewing trash
- Add Empty Trash command to Cmd+K palette
- Add bulk restore, bulk delete permanently, and empty trash handlers
- All permanent deletions require confirmation dialog
- Update mock handlers for batch_delete_notes and empty_trash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-12 00:06:33 +01:00
parent ce85d351d9
commit e2b2abce37
9 changed files with 362 additions and 36 deletions

View File

@@ -39,6 +39,7 @@ import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
@@ -61,7 +62,7 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function useBulkActions(
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void>; handleRestoreNote: (path: string) => Promise<void> },
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
@@ -82,7 +83,16 @@ function useBulkActions(
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash }
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
}
function useLayoutPanels() {
@@ -389,18 +399,76 @@ function App() {
onBeforeAction: flushBeforeAction,
})
const handleDeleteNote = useCallback(async (path: string) => {
const deleteNoteFromDisk = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
notes.handleCloseTab(path)
vault.removeEntry(path)
setToastMessage('Note permanently deleted')
return true
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
return false
}
}, [notes, vault, setToastMessage])
// Confirmation dialog state for permanent delete
const [confirmDelete, setConfirmDelete] = useState<{ title: string; message: string; confirmLabel?: string; onConfirm: () => void } | null>(null)
const handleDeleteNote = useCallback(async (path: string) => {
setConfirmDelete({
title: 'Delete permanently?',
message: 'This note will be permanently deleted. This cannot be undone.',
onConfirm: async () => {
setConfirmDelete(null)
const ok = await deleteNoteFromDisk(path)
if (ok) setToastMessage('Note permanently deleted')
},
})
}, [deleteNoteFromDisk, setToastMessage])
const handleBulkDeletePermanently = useCallback((paths: string[]) => {
const count = paths.length
setConfirmDelete({
title: `Delete ${count} ${count === 1 ? 'note' : 'notes'} permanently?`,
message: `${count === 1 ? 'This note' : `These ${count} notes`} will be permanently deleted. This cannot be undone.`,
onConfirm: async () => {
setConfirmDelete(null)
let ok = 0
for (const path of paths) {
if (await deleteNoteFromDisk(path)) ok++
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} permanently deleted`)
},
})
}, [deleteNoteFromDisk, setToastMessage])
const trashedCount = useMemo(() => vault.entries.filter(e => e.trashed).length, [vault.entries])
const handleEmptyTrash = useCallback(() => {
if (trashedCount === 0) return
setConfirmDelete({
title: 'Empty Trash?',
message: `Permanently delete all ${trashedCount} trashed ${trashedCount === 1 ? 'note' : 'notes'}? This cannot be undone.`,
confirmLabel: 'Empty Trash',
onConfirm: async () => {
setConfirmDelete(null)
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const deleted = await tauriInvoke<string[]>('empty_trash', { vaultPath: resolvedPath })
// Close tabs and remove entries for deleted notes
for (const path of deleted) {
notes.handleCloseTab(path)
vault.removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
} catch (e) {
setToastMessage(`Failed to empty trash: ${e}`)
}
},
})
}, [trashedCount, resolvedPath, notes, vault, setToastMessage])
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const handleCreateType = useCallback((name: string) => {
@@ -526,6 +594,8 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onEmptyTrash: handleEmptyTrash,
trashedCount,
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
onRepairVault: handleRepairVault,
@@ -592,7 +662,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={handleBulkDeletePermanently} onEmptyTrash={handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -678,6 +748,16 @@ function App() {
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
{confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={confirmDelete.title}
message={confirmDelete.message}
confirmLabel={confirmDelete.confirmLabel}
onConfirm={confirmDelete.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { BulkActionBar } from './BulkActionBar'
describe('BulkActionBar', () => {
const defaultProps = {
count: 3,
onArchive: vi.fn(),
onTrash: vi.fn(),
onRestore: vi.fn(),
onDeletePermanently: vi.fn(),
onClear: vi.fn(),
isTrashView: false,
}
it('shows Archive and Trash buttons in normal view', () => {
render(<BulkActionBar {...defaultProps} />)
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
})
it('shows Restore and Delete permanently in trash view', () => {
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
expect(screen.getByTestId('bulk-restore-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()
})
it('calls onRestore when Restore button clicked in trash view', () => {
const onRestore = vi.fn()
render(<BulkActionBar {...defaultProps} isTrashView={true} onRestore={onRestore} />)
fireEvent.click(screen.getByTestId('bulk-restore-btn'))
expect(onRestore).toHaveBeenCalledTimes(1)
})
it('calls onDeletePermanently when Delete button clicked in trash view', () => {
const onDeletePermanently = vi.fn()
render(<BulkActionBar {...defaultProps} isTrashView={true} onDeletePermanently={onDeletePermanently} />)
fireEvent.click(screen.getByTestId('bulk-delete-btn'))
expect(onDeletePermanently).toHaveBeenCalledTimes(1)
})
it('shows selected count', () => {
render(<BulkActionBar {...defaultProps} count={5} />)
expect(screen.getByText('5 selected')).toBeInTheDocument()
})
})

View File

@@ -1,14 +1,20 @@
import { memo } from 'react'
import { Archive, Trash, X } from '@phosphor-icons/react'
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
interface BulkActionBarProps {
count: number
isTrashView: boolean
onArchive: () => void
onTrash: () => void
onRestore: () => void
onDeletePermanently: () => void
onClear: () => void
}
function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBarProps) {
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) {
return (
<div
className="flex shrink-0 items-center justify-between"
@@ -24,26 +30,53 @@ function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBa
{count} selected
</span>
<div className="flex items-center gap-1">
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 }}
onClick={onArchive}
title="Archive selected notes"
data-testid="bulk-archive-btn"
>
<Archive size={14} />
Archive
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 }}
onClick={onTrash}
title="Move selected notes to trash"
data-testid="bulk-trash-btn"
>
<Trash size={14} />
Trash
</button>
{isTrashView ? (
<>
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={actionBtnStyle}
onClick={onRestore}
title="Restore selected notes"
data-testid="bulk-restore-btn"
>
<ArrowCounterClockwise size={14} />
Restore
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={destructiveBtnStyle}
onClick={onDeletePermanently}
title="Permanently delete selected notes"
data-testid="bulk-delete-btn"
>
<Trash size={14} />
Delete permanently
</button>
</>
) : (
<>
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={actionBtnStyle}
onClick={onArchive}
title="Archive selected notes"
data-testid="bulk-archive-btn"
>
<Archive size={14} />
Archive
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={destructiveBtnStyle}
onClick={onTrash}
title="Move selected notes to trash"
data-testid="bulk-trash-btn"
>
<Trash size={14} />
Trash
</button>
</>
)}
<button
className="flex items-center border-none bg-transparent cursor-pointer"
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
describe('ConfirmDeleteDialog', () => {
const onConfirm = vi.fn()
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders with title and message', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
})
it('calls onConfirm when delete button clicked', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button clicked', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByText('Cancel'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('does not render when open is false', () => {
render(
<ConfirmDeleteDialog
open={false}
title="Delete permanently?"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
})
it('uses custom confirm label when provided', () => {
render(
<ConfirmDeleteDialog
open={true}
title="Empty Trash?"
message="Delete all notes?"
confirmLabel="Empty Trash"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,49 @@
import { memo } from 'react'
import { Trash } from '@phosphor-icons/react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmDeleteDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
open,
title,
message,
confirmLabel = 'Delete permanently',
onConfirm,
onCancel,
}: ConfirmDeleteDialogProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash size={18} className="text-destructive" />
{title}
</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
})

View File

@@ -4,7 +4,7 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
@@ -35,6 +35,9 @@ interface NoteListProps {
onCreateNote: () => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
onBulkRestore?: (paths: string[]) => void
onBulkDeletePermanently?: (paths: string[]) => void
onEmptyTrash?: () => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
@@ -423,10 +426,12 @@ function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boo
// --- Header component ---
function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
isTrashView: boolean
trashCount: number
listSort: SortOption
listDirection: SortDirection
customProperties: string[]
@@ -438,6 +443,7 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
onOpenType: (entry: VaultEntry) => void
onToggleSearch: () => void
onSearchChange: (value: string) => void
onEmptyTrash?: () => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
return (
@@ -456,9 +462,21 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
<MagnifyingGlass size={16} />
</button>
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
<Plus size={16} />
</button>
{isTrashView && trashCount > 0 && (
<button
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
onClick={onEmptyTrash}
title="Empty Trash"
data-testid="empty-trash-btn"
>
<Trash size={16} />
</button>
)}
{!isTrashView && (
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
<Plus size={16} />
</button>
)}
</div>
</div>
{searchVisible && (
@@ -484,7 +502,7 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
}
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
@@ -505,7 +523,11 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
useMultiSelectKeyboard(multiSelect, isEntityView, handleBulkArchive, handleBulkTrash)
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : handleBulkArchive
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
@@ -516,7 +538,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
@@ -525,7 +547,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
)}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onClear={multiSelect.clear} />
)}
</div>
)

View File

@@ -65,6 +65,8 @@ interface AppCommandsConfig {
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
onEmptyTrash?: () => void
trashedCount?: number
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
@@ -206,6 +208,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onEmptyTrash: config.onEmptyTrash,
trashedCount: config.trashedCount,
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,

View File

@@ -20,6 +20,8 @@ interface CommandRegistryConfig {
modifiedCount: number
mcpStatus?: string
onInstallMcp?: () => void
onEmptyTrash?: () => void
trashedCount?: number
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
@@ -199,6 +201,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReindexVault,
onReloadVault,
onRepairVault,
@@ -222,6 +225,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
@@ -284,6 +288,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReindexVault, onReloadVault, onRepairVault,
])
}

View File

@@ -261,6 +261,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
purge_trash: () => [],
delete_note: (args: { path: string }) => args.path,
batch_delete_notes: (args: { paths: string[] }) => args.paths,
empty_trash: () => [],
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,