diff --git a/src/App.tsx b/src/App.tsx index 8e810aee..f0d9792b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; handleTrashNote: (path: string) => Promise }, + entryActions: { handleArchiveNote: (path: string) => Promise; handleTrashNote: (path: string) => Promise; handleRestoreNote: (path: string) => Promise }, 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('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' ? ( setViewMode('all')} /> ) : ( - + )} @@ -678,6 +748,16 @@ function App() { onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }} onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })} /> + {confirmDelete && ( + setConfirmDelete(null)} + /> + )} ) } diff --git a/src/components/BulkActionBar.test.tsx b/src/components/BulkActionBar.test.tsx new file mode 100644 index 00000000..9a5b4e1d --- /dev/null +++ b/src/components/BulkActionBar.test.tsx @@ -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() + 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() + 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() + 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() + fireEvent.click(screen.getByTestId('bulk-delete-btn')) + expect(onDeletePermanently).toHaveBeenCalledTimes(1) + }) + + it('shows selected count', () => { + render() + expect(screen.getByText('5 selected')).toBeInTheDocument() + }) +}) diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 406c1acb..e603584d 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -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 (
- - + {isTrashView ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + ) +}) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 67648aa6..86f1c843 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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) => 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 - + {isTrashView && trashCount > 0 && ( + + )} + {!isTrashView && ( + + )}
{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) => ( @@ -516,7 +538,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi return (
- +
{entitySelection ? ( @@ -525,7 +547,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi )}
{multiSelect.isMultiSelecting && ( - + )}
) diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index fb35bd84..d88a1650 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 1e147ddf..b11f5649 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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, ]) } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index bbf52737..6ae15c60 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -261,6 +261,8 @@ export const mockHandlers: Record 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,