feat(files): add delete confirmation option

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
skjnldsv 2025-07-10 17:37:21 +02:00 committed by John Molakvoæ
parent 19009b3620
commit f89660e709
7 changed files with 39 additions and 12 deletions

View file

@ -37,6 +37,12 @@ class UserConfig {
'default' => false,
'allowed' => [true, false],
],
[
// Whether to show the "confirm file deletion" warning
'key' => 'show_dialog_deletion',
'default' => false,
'allowed' => [true, false],
],
[
// Whether to show the "confirm file extension change" warning
'key' => 'show_dialog_file_extension',

View file

@ -11,6 +11,7 @@ import * as eventBus from '@nextcloud/event-bus'
import { action } from './deleteAction'
import logger from '../logger'
import { shouldAskForConfirmation } from './deleteUtils'
vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/axios')
@ -235,7 +236,6 @@ describe('Delete action execute tests', () => {
vi.spyOn(eventBus, 'emit')
const confirmMock = vi.fn()
// @ts-expect-error We only mock what needed
window.OC = { dialogs: { confirmDestructive: confirmMock } }
const file1 = new File({
@ -275,7 +275,6 @@ describe('Delete action execute tests', () => {
// Emulate the confirmation dialog to always confirm
const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true))
// @ts-expect-error We only mock what needed
window.OC = { dialogs: { confirmDestructive: confirmMock } }
const file1 = new File({
@ -339,7 +338,11 @@ describe('Delete action execute tests', () => {
expect(eventBus.emit).toHaveBeenNthCalledWith(5, 'files:node:deleted', file5)
})
test('Delete action batch trashbin disabled', async () => {
test('Delete action batch dialog enabled', async () => {
// Enable the confirmation dialog
eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true })
expect(shouldAskForConfirmation()).toBe(true)
vi.spyOn(axios, 'delete')
vi.spyOn(eventBus, 'emit')
vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
@ -350,7 +353,6 @@ describe('Delete action execute tests', () => {
// Emulate the confirmation dialog to always confirm
const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true))
// @ts-expect-error We only mock what needed
window.OC = { dialogs: { confirmDestructive: confirmMock } }
const file1 = new File({
@ -382,6 +384,8 @@ describe('Delete action execute tests', () => {
expect(eventBus.emit).toBeCalledTimes(2)
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false })
})
test('Delete fails', async () => {
@ -407,7 +411,10 @@ describe('Delete action execute tests', () => {
expect(logger.error).toBeCalledTimes(1)
})
test('Delete is cancelled', async () => {
test('Delete is cancelled with dialog enabled', async () => {
// Enable the confirmation dialog
eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true })
vi.spyOn(axios, 'delete')
vi.spyOn(eventBus, 'emit')
vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
@ -418,7 +425,6 @@ describe('Delete action execute tests', () => {
// Emulate the confirmation dialog to always confirm
const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(false))
// @ts-expect-error We only mock what needed
window.OC = { dialogs: { confirmDestructive: confirmMock } }
const file1 = new File({
@ -437,5 +443,7 @@ describe('Delete action execute tests', () => {
expect(axios.delete).toBeCalledTimes(0)
expect(eventBus.emit).toBeCalledTimes(0)
eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false })
})
})

View file

@ -13,7 +13,7 @@ import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw'
import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, isTrashbinEnabled } from './deleteUtils.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts'
import logger from '../logger.ts'
const queue = new PQueue({ concurrency: 5 })
@ -58,8 +58,7 @@ export const action = new FileAction({
const callStack = new Error().stack || ''
const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown')
// If trashbin is disabled, we need to ask for confirmation
if (!isTrashbinEnabled() || isCalledFromEventListener) {
if (shouldAskForConfirmation() || isCalledFromEventListener) {
confirm = await askConfirmation([node], view)
}
@ -81,8 +80,7 @@ export const action = new FileAction({
async execBatch(nodes: Node[], view: View): Promise<(boolean | null)[]> {
let confirm = true
// If trashbin is disabled, we need to ask for confirmation
if (!isTrashbinEnabled()) {
if (shouldAskForConfirmation()) {
confirm = await askConfirmation(nodes, view)
} else if (nodes.length >= 5 && !canUnshareOnly(nodes) && !canDisconnectOnly(nodes)) {
confirm = await askConfirmation(nodes, view)

View file

@ -10,6 +10,8 @@ import { FileType } from '@nextcloud/files'
import { getCapabilities } from '@nextcloud/capabilities'
import { n, t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import { useUserConfigStore } from '../store/userconfig'
import { getPinia } from '../store'
export const isTrashbinEnabled = () => (getCapabilities() as Capabilities)?.files?.undelete === true
@ -101,6 +103,11 @@ export const displayName = (nodes: Node[], view: View) => {
return t('files', 'Delete')
}
export const shouldAskForConfirmation = () => {
const userConfig = useUserConfigStore(getPinia())
return userConfig.userConfig.show_dialog_deletion !== false
}
export const askConfirmation = async (nodes: Node[], view: View) => {
const message = view.id === 'trashbin' || !isTrashbinEnabled()
? n('files', 'You are about to permanently delete {count} item', 'You are about to permanently delete {count} items', nodes.length, { count: nodes.length })

View file

@ -20,6 +20,7 @@ const initialUserConfig = loadState<UserConfig>('files', 'config', {
sort_favorites_first: true,
sort_folders_first: true,
show_dialog_deletion: false,
show_dialog_file_extension: true,
})

View file

@ -55,11 +55,13 @@ export interface UserConfig {
crop_image_previews: boolean
default_view: 'files' | 'personal'
grid_view: boolean
show_dialog_file_extension: boolean,
show_hidden: boolean
show_mime_column: boolean
sort_favorites_first: boolean
sort_folders_first: boolean
show_dialog_deletion: boolean
show_dialog_file_extension: boolean,
}
export interface UserConfigStore {

View file

@ -110,6 +110,11 @@
@update:checked="setConfig('show_dialog_file_extension', $event)">
{{ t('files', 'Show a warning dialog when changing a file extension.') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="switch"
:checked="userConfig.show_dialog_deletion"
@update:checked="setConfig('show_dialog_deletion', $event)">
{{ t('files', 'Show a warning dialog when deleting files.') }}
</NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<NcAppSettingsSection id="shortcuts"