diff options
Diffstat (limited to 'apps/files/src/actions')
-rw-r--r-- | apps/files/src/actions/deleteAction.spec.ts | 20 | ||||
-rw-r--r-- | apps/files/src/actions/deleteAction.ts | 11 | ||||
-rw-r--r-- | apps/files/src/actions/deleteUtils.ts | 7 | ||||
-rw-r--r-- | apps/files/src/actions/moveOrCopyAction.ts | 4 | ||||
-rw-r--r-- | apps/files/src/actions/openInFilesAction.spec.ts | 2 | ||||
-rw-r--r-- | apps/files/src/actions/openInFilesAction.ts | 18 | ||||
-rw-r--r-- | apps/files/src/actions/openLocallyAction.ts | 139 | ||||
-rw-r--r-- | apps/files/src/actions/renameAction.spec.ts | 22 | ||||
-rw-r--r-- | apps/files/src/actions/renameAction.ts | 20 | ||||
-rw-r--r-- | apps/files/src/actions/viewInFolderAction.ts | 9 |
10 files changed, 150 insertions, 102 deletions
diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts index 4ed625b2412..845d29962a7 100644 --- a/apps/files/src/actions/deleteAction.spec.ts +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -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 }) }) }) diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index edcc615a34b..3e9e441a63c 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -10,10 +10,10 @@ import PQueue from 'p-queue' import CloseSvg from '@mdi/svg/svg/close.svg?raw' import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw' -import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw' +import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.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) @@ -114,5 +112,6 @@ export const action = new FileAction({ return Promise.all(promises) }, + destructive: true, order: 100, }) diff --git a/apps/files/src/actions/deleteUtils.ts b/apps/files/src/actions/deleteUtils.ts index ef395bae5b7..1ca7859b6c5 100644 --- a/apps/files/src/actions/deleteUtils.ts +++ b/apps/files/src/actions/deleteUtils.ts @@ -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 }) diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts index 724b65fa515..af68120bb1b 100644 --- a/apps/files/src/actions/moveOrCopyAction.ts +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -16,8 +16,8 @@ import { openConflictPicker, hasConflict } from '@nextcloud/upload' import { basename, join } from 'path' import Vue from 'vue' -import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw' -import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' +import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' +import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils' import { getContents } from '../services/Files' diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts index e732270d4c0..3ccd15fa2d2 100644 --- a/apps/files/src/actions/openInFilesAction.spec.ts +++ b/apps/files/src/actions/openInFilesAction.spec.ts @@ -19,7 +19,7 @@ const recentView = { describe('Open in files action conditions tests', () => { test('Default values', () => { expect(action).toBeInstanceOf(FileAction) - expect(action.id).toBe('open-in-files-recent') + expect(action.id).toBe('open-in-files') expect(action.displayName([], recentView)).toBe('Open in Files') expect(action.iconSvgInline([], recentView)).toBe('') expect(action.default).toBe(DefaultType.HIDDEN) diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts index 10e19e7eace..9e10b1ac74e 100644 --- a/apps/files/src/actions/openInFilesAction.ts +++ b/apps/files/src/actions/openInFilesAction.ts @@ -2,19 +2,21 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files' -/** - * TODO: Move away from a redirect and handle - * navigation straight out of the recent view - */ +import type { Node } from '@nextcloud/files' + +import { t } from '@nextcloud/l10n' +import { FileType, FileAction, DefaultType } from '@nextcloud/files' +import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search' + export const action = new FileAction({ - id: 'open-in-files-recent', + id: 'open-in-files', displayName: () => t('files', 'Open in Files'), iconSvgInline: () => '', - enabled: (nodes, view) => view.id === 'recent', + enabled(nodes, view) { + return view.id === 'recent' || view.id === SEARCH_VIEW_ID + }, async exec(node: Node) { let dir = node.dirname diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index a80cf0cbeed..986b304210c 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -13,71 +13,6 @@ import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import IconWeb from '@mdi/svg/svg/web.svg?raw' import { isPublicShare } from '@nextcloud/sharing/public' -const confirmLocalEditDialog = ( - localEditCallback: (openingLocally: boolean) => void = () => {}, -) => { - let callbackCalled = false - - return (new DialogBuilder()) - .setName(t('files', 'Open file locally')) - .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) - .setButtons([ - { - label: t('files', 'Retry and close'), - type: 'secondary', - callback: () => { - callbackCalled = true - localEditCallback(true) - }, - }, - { - label: t('files', 'Open online'), - icon: IconWeb, - type: 'primary', - callback: () => { - callbackCalled = true - localEditCallback(false) - }, - }, - ]) - .build() - .show() - .then(() => { - // Ensure the callback is called even if the dialog is dismissed in other ways - if (!callbackCalled) { - localEditCallback(false) - } - }) -} - -const attemptOpenLocalClient = async (path: string) => { - openLocalClient(path) - confirmLocalEditDialog( - (openLocally: boolean) => { - if (!openLocally) { - window.OCA.Viewer.open({ path }) - return - } - openLocalClient(path) - }, - ) -} - -const openLocalClient = async function(path: string) { - const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' - - try { - const result = await axios.post(link, { path }) - const uid = getCurrentUser()?.uid - let url = `nc://open/${uid}@` + window.location.host + encodePath(path) - url += '?token=' + result.data.ocs.data.token - - window.open(url, '_self') - } catch (error) { - showError(t('files', 'Failed to redirect to client')) - } -} - export const action = new FileAction({ id: 'edit-locally', displayName: () => t('files', 'Open locally'), @@ -99,9 +34,81 @@ export const action = new FileAction({ }, async exec(node: Node) { - attemptOpenLocalClient(node.path) + await attemptOpenLocalClient(node.path) return null }, order: 25, }) + +/** + * Try to open the path in the Nextcloud client. + * + * If this fails a dialog is shown with 3 options: + * 1. Retry: If it fails no further dialog is shown. + * 2. Open online: The viewer is used to open the file. + * 3. Close the dialog and nothing happens (abort). + * + * @param path - The path to open + */ +async function attemptOpenLocalClient(path: string) { + await openLocalClient(path) + const result = await confirmLocalEditDialog() + if (result === 'local') { + await openLocalClient(path) + } else if (result === 'online') { + window.OCA.Viewer.open({ path }) + } +} + +/** + * Try to open a file in the Nextcloud client. + * There is no way to get notified if this action was successfull. + * + * @param path - Path to open + */ +async function openLocalClient(path: string): Promise<void> { + const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' + + try { + const result = await axios.post(link, { path }) + const uid = getCurrentUser()?.uid + let url = `nc://open/${uid}@` + window.location.host + encodePath(path) + url += '?token=' + result.data.ocs.data.token + + window.open(url, '_self') + } catch (error) { + showError(t('files', 'Failed to redirect to client')) + } +} + +/** + * Open the confirmation dialog. + */ +async function confirmLocalEditDialog(): Promise<'online'|'local'|false> { + let result: 'online'|'local'|false = false + const dialog = (new DialogBuilder()) + .setName(t('files', 'Open file locally')) + .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) + .setButtons([ + { + label: t('files', 'Retry and close'), + type: 'secondary', + callback: () => { + result = 'local' + }, + }, + { + label: t('files', 'Open online'), + icon: IconWeb, + type: 'primary', + callback: () => { + result = 'online' + }, + }, + ]) + .build() + + await dialog.show() + return result +} diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts index 954eca5820f..1f9c9209d41 100644 --- a/apps/files/src/actions/renameAction.spec.ts +++ b/apps/files/src/actions/renameAction.spec.ts @@ -3,15 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { action } from './renameAction' -import { File, Permission, View, FileAction } from '@nextcloud/files' +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' import * as eventBus from '@nextcloud/event-bus' -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi, beforeEach } from 'vitest' +import { useFilesStore } from '../store/files' +import { getPinia } from '../store/index.ts' const view = { id: 'files', name: 'Files', } as View +beforeEach(() => { + const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE }) + const files = useFilesStore(getPinia()) + files.setRoot({ service: 'files', root }) +}) + describe('Rename action conditions tests', () => { test('Default values', () => { expect(action).toBeInstanceOf(FileAction) @@ -26,7 +34,7 @@ describe('Rename action conditions tests', () => { describe('Rename action enabled tests', () => { test('Enabled for node with UPDATE permission', () => { const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', @@ -39,7 +47,7 @@ describe('Rename action enabled tests', () => { test('Disabled for node without DELETE permission', () => { const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', @@ -54,13 +62,13 @@ describe('Rename action enabled tests', () => { window.OCA = { Files: { Sidebar: {} } } const file1 = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', owner: 'admin', mime: 'text/plain', }) const file2 = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', owner: 'admin', mime: 'text/plain', @@ -76,7 +84,7 @@ describe('Rename action exec tests', () => { vi.spyOn(eventBus, 'emit') const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts index e0ea784c291..715ecb7563e 100644 --- a/apps/files/src/actions/renameAction.ts +++ b/apps/files/src/actions/renameAction.ts @@ -5,7 +5,10 @@ import { emit } from '@nextcloud/event-bus' import { Permission, type Node, FileAction, View } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import PencilSvg from '@mdi/svg/svg/pencil.svg?raw' +import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw' +import { getPinia } from '../store' +import { useFilesStore } from '../store/files' +import { dirname } from 'path' export const ACTION_RENAME = 'rename' @@ -18,12 +21,23 @@ export const action = new FileAction({ if (nodes.length === 0) { return false } + // Disable for single file shares if (view.id === 'public-file-share') { return false } - // Only enable if all nodes have the delete permission - return nodes.every((node) => Boolean(node.permissions & Permission.DELETE)) + + const node = nodes[0] + const filesStore = useFilesStore(getPinia()) + const parentNode = node.dirname === '/' + ? filesStore.getRoot(view.id) + : filesStore.getNode(dirname(node.source)) + const parentPermissions = parentNode?.permissions || Permission.NONE + + // Only enable if the node have the delete permission + // and if the parent folder allows creating files + return Boolean(node.permissions & Permission.DELETE) + && Boolean(parentPermissions & Permission.CREATE) }, async exec(node: Node) { diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts index eb145dc409f..b22393c1152 100644 --- a/apps/files/src/actions/viewInFolderAction.ts +++ b/apps/files/src/actions/viewInFolderAction.ts @@ -2,10 +2,13 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Node, FileType, Permission, View, FileAction } from '@nextcloud/files' -import { translate as t } from '@nextcloud/l10n' -import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' +import type { Node, View } from '@nextcloud/files' + import { isPublicShare } from '@nextcloud/sharing/public' +import { FileAction, FileType, Permission } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' export const action = new FileAction({ id: 'view-in-folder', |