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:
97
src/App.tsx
97
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<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>
|
||||
|
||||
222
src/hooks/useDeleteActions.test.ts
Normal file
222
src/hooks/useDeleteActions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
src/hooks/useDeleteActions.ts
Normal file
105
src/hooks/useDeleteActions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user