refactor: extract useDeleteActions hook from App.tsx — reduce churn surface (#193)

Extract delete/trash management logic (deleteNoteFromDisk, handleDeleteNote,
handleBulkDeletePermanently, handleEmptyTrash, trashedCount, confirmDelete state)
into a focused useDeleteActions hook. Reduces App.tsx from 733 to 672 lines.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-03-12 06:28:19 +01:00
committed by GitHub
parent 6acc802824
commit 848d3c163e
3 changed files with 345 additions and 79 deletions

View File

@@ -39,6 +39,7 @@ import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
@@ -366,75 +367,13 @@ function App() {
onBeforeAction: flushBeforeAction,
})
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)
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 deleteActions = useDeleteActions({
vaultPath: resolvedPath,
entries: vault.entries,
handleCloseTab: notes.handleCloseTab,
removeEntry: vault.removeEntry,
setToastMessage,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
@@ -561,8 +500,8 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onEmptyTrash: handleEmptyTrash,
trashedCount,
onEmptyTrash: deleteActions.handleEmptyTrash,
trashedCount: deleteActions.trashedCount,
onReopenClosedTab: notes.handleReopenClosedTab,
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
@@ -630,7 +569,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} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={handleBulkDeletePermanently} onEmptyTrash={handleEmptyTrash} 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={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -667,7 +606,7 @@ function App() {
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onDeleteNote={deleteActions.handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
@@ -716,14 +655,14 @@ function App() {
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
{confirmDelete && (
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={confirmDelete.title}
message={confirmDelete.message}
confirmLabel={confirmDelete.confirmLabel}
onConfirm={confirmDelete.onConfirm}
onCancel={() => setConfirmDelete(null)}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
</div>

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDeleteActions } from './useDeleteActions'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri')
const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
function makeEntry(path: string, trashed = false) {
return { path, trashed } as { path: string; trashed: boolean }
}
describe('useDeleteActions', () => {
let handleCloseTab: ReturnType<typeof vi.fn>
let removeEntry: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
handleCloseTab = vi.fn()
removeEntry = vi.fn()
setToastMessage = vi.fn()
mockInvokeFn.mockReset()
})
function renderDeleteActions(entries = [makeEntry('/vault/a.md'), makeEntry('/vault/t.md', true)]) {
return renderHook(() =>
useDeleteActions({
vaultPath: '/vault',
entries,
handleCloseTab,
removeEntry,
setToastMessage,
}),
)
}
// --- trashedCount ---
describe('trashedCount', () => {
it('counts trashed entries', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/b.md', true),
makeEntry('/vault/c.md', true),
])
expect(result.current.trashedCount).toBe(2)
})
it('returns 0 when no trashed entries', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
expect(result.current.trashedCount).toBe(0)
})
})
// --- deleteNoteFromDisk ---
describe('deleteNoteFromDisk', () => {
it('invokes delete_note, closes tab, removes entry, returns true', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
})
expect(ok).toBe(true)
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
expect(handleCloseTab).toHaveBeenCalledWith('/vault/a.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/a.md')
})
it('shows toast and returns false on failure', async () => {
mockInvokeFn.mockRejectedValue(new Error('disk full'))
const { result } = renderDeleteActions()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
})
expect(ok).toBe(false)
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'))
})
})
// --- handleDeleteNote ---
describe('handleDeleteNote', () => {
it('sets confirmDelete dialog state', async () => {
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
expect(result.current.confirmDelete).not.toBeNull()
expect(result.current.confirmDelete?.title).toBe('Delete permanently?')
})
it('onConfirm deletes the note and clears dialog', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
expect(setToastMessage).toHaveBeenCalledWith('Note permanently deleted')
})
})
// --- handleBulkDeletePermanently ---
describe('handleBulkDeletePermanently', () => {
it('sets confirmDelete with correct plural title', () => {
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
})
expect(result.current.confirmDelete?.title).toBe('Delete 2 notes permanently?')
})
it('sets confirmDelete with singular title for one note', () => {
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md'])
})
expect(result.current.confirmDelete?.title).toBe('Delete 1 note permanently?')
})
it('onConfirm deletes all paths and shows toast', async () => {
mockInvokeFn.mockResolvedValue(undefined)
const { result } = renderDeleteActions()
act(() => {
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
})
})
// --- handleEmptyTrash ---
describe('handleEmptyTrash', () => {
it('does nothing when trashedCount is 0', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete).toBeNull()
})
it('sets confirmDelete with trash count in message', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete?.title).toBe('Empty Trash?')
expect(result.current.confirmDelete?.confirmLabel).toBe('Empty Trash')
})
it('onConfirm invokes empty_trash, closes tabs, removes entries', async () => {
mockInvokeFn.mockResolvedValue(['/vault/t1.md', '/vault/t2.md'])
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('empty_trash', { vaultPath: '/vault' })
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t1.md')
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t2.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t1.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t2.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
})
it('shows error toast when empty_trash fails', async () => {
mockInvokeFn.mockRejectedValue(new Error('oops'))
const { result } = renderDeleteActions([makeEntry('/vault/t.md', true)])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to empty trash'))
})
})
// --- setConfirmDelete ---
describe('setConfirmDelete', () => {
it('can clear confirmDelete via setConfirmDelete(null)', async () => {
const { result } = renderDeleteActions()
await act(async () => {
await result.current.handleDeleteNote('/vault/a.md')
})
expect(result.current.confirmDelete).not.toBeNull()
act(() => {
result.current.setConfirmDelete(null)
})
expect(result.current.confirmDelete).toBeNull()
})
})
})

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
interface ConfirmDeleteState {
title: string
message: string
confirmLabel?: string
onConfirm: () => void
}
interface UseDeleteActionsInput {
vaultPath: string
entries: VaultEntry[]
handleCloseTab: (path: string) => void
removeEntry: (path: string) => void
setToastMessage: (msg: string | null) => void
}
export function useDeleteActions({
vaultPath,
entries,
handleCloseTab,
removeEntry,
setToastMessage,
}: UseDeleteActionsInput) {
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState | null>(null)
const trashedCount = useMemo(() => entries.filter(e => e.trashed).length, [entries])
const deleteNoteFromDisk = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
handleCloseTab(path)
removeEntry(path)
return true
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
return false
}
}, [handleCloseTab, removeEntry, setToastMessage])
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 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 })
for (const path of deleted) {
handleCloseTab(path)
removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
} catch (e) {
setToastMessage(`Failed to empty trash: ${e}`)
}
},
})
}, [trashedCount, vaultPath, handleCloseTab, removeEntry, setToastMessage])
return {
confirmDelete,
setConfirmDelete,
trashedCount,
deleteNoteFromDisk,
handleDeleteNote,
handleBulkDeletePermanently,
handleEmptyTrash,
}
}