From 848d3c163e99c45eebc29541f2c8be1c20a4be81 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 12 Mar 2026 06:28:19 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20useDeleteActions=20hook?= =?UTF-8?q?=20from=20App.tsx=20=E2=80=94=20reduce=20churn=20surface=20(#19?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.6 --- src/App.tsx | 97 +++---------- src/hooks/useDeleteActions.test.ts | 222 +++++++++++++++++++++++++++++ src/hooks/useDeleteActions.ts | 105 ++++++++++++++ 3 files changed, 345 insertions(+), 79 deletions(-) create mode 100644 src/hooks/useDeleteActions.test.ts create mode 100644 src/hooks/useDeleteActions.ts diff --git a/src/App.tsx b/src/App.tsx index fa9b6b45..7b945f86 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 { 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('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' ? ( setViewMode('all')} /> ) : ( - + )} @@ -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 && ( setConfirmDelete(null)} + title={deleteActions.confirmDelete.title} + message={deleteActions.confirmDelete.message} + confirmLabel={deleteActions.confirmDelete.confirmLabel} + onConfirm={deleteActions.confirmDelete.onConfirm} + onCancel={() => deleteActions.setConfirmDelete(null)} /> )} diff --git a/src/hooks/useDeleteActions.test.ts b/src/hooks/useDeleteActions.test.ts new file mode 100644 index 00000000..dd76bd5f --- /dev/null +++ b/src/hooks/useDeleteActions.test.ts @@ -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 + +function makeEntry(path: string, trashed = false) { + return { path, trashed } as { path: string; trashed: boolean } +} + +describe('useDeleteActions', () => { + let handleCloseTab: ReturnType + let removeEntry: ReturnType + let setToastMessage: ReturnType + + 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() + }) + }) +}) diff --git a/src/hooks/useDeleteActions.ts b/src/hooks/useDeleteActions.ts new file mode 100644 index 00000000..c66afe32 --- /dev/null +++ b/src/hooks/useDeleteActions.ts @@ -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(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('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, + } +}