feat(files): add hotkey service and unify action handling

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
skjnldsv 2024-12-13 12:06:59 +01:00
parent 376a7bba7b
commit 74b2562e6b
14 changed files with 438 additions and 80 deletions

View file

@ -16,8 +16,10 @@ import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, display
const queue = new PQueue({ concurrency: 5 })
export const ACTION_DELETE = 'delete'
export const action = new FileAction({
id: 'delete',
id: ACTION_DELETE,
displayName,
iconSvgInline: (nodes: Node[]) => {
if (canUnshareOnly(nodes)) {
@ -41,8 +43,14 @@ export const action = new FileAction({
try {
let confirm = true
// Trick to detect if the action was called from a keyboard event
// we need to make sure the method calling have its named containing 'keydown'
// here we use `onKeydown` method from the FileEntryActions component
const callStack = new Error().stack || ''
const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown')
// If trashbin is disabled, we need to ask for confirmation
if (!isTrashbinEnabled()) {
if (!isTrashbinEnabled() || isCalledFromEventListener) {
confirm = await askConfirmation([node], view)
}
@ -79,8 +87,8 @@ export const action = new FileAction({
// Map each node to a promise that resolves with the result of exec(node)
const promises = nodes.map(node => {
// Create a promise that resolves with the result of exec(node)
const promise = new Promise<boolean>(resolve => {
// Create a promise that resolves with the result of exec(node)
const promise = new Promise<boolean>(resolve => {
queue.add(async () => {
try {
await deleteNode(node)

View file

@ -19,6 +19,8 @@ import StarSvg from '@mdi/svg/svg/star.svg?raw'
import logger from '../logger.ts'
export const ACTION_FAVORITE = 'favorite'
const queue = new PQueue({ concurrency: 5 })
// If any of the nodes is not favorited, we display the favorite action.
@ -62,7 +64,7 @@ export const favoriteNode = async (node: Node, view: View, willFavorite: boolean
}
export const action = new FileAction({
id: 'favorite',
id: ACTION_FAVORITE,
displayName(nodes: Node[]) {
return shouldFavorite(nodes)
? t('files', 'Add to favorites')

View file

@ -7,10 +7,10 @@ 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'
export const ACTION_DETAILS = 'details'
export const ACTION_RENAME = 'rename'
export const action = new FileAction({
id: 'rename',
id: ACTION_RENAME,
displayName: () => t('files', 'Rename'),
iconSvgInline: () => PencilSvg,

View file

@ -46,7 +46,6 @@
<FileEntryActions v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:loading.sync="loading"
:opened.sync="openedMenu"
:source="source" />
@ -86,7 +85,9 @@
<script lang="ts">
import { defineComponent } from 'vue'
import { formatFileSize } from '@nextcloud/files'
import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import moment from '@nextcloud/moment'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import { useNavigation } from '../composables/useNavigation.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
@ -97,11 +98,10 @@ import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import FileEntryMixin from './FileEntryMixin.ts'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import CustomElementRender from './CustomElementRender.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
@ -228,8 +228,24 @@ export default defineComponent({
},
},
created() {
useHotKey('Enter', this.triggerDefaultAction, {
stop: true,
prevent: true,
})
},
methods: {
formatFileSize,
triggerDefaultAction() {
// Don't react to the event if the file row is not active
if (!this.isActive) {
return
}
this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir)
},
},
})
</script>

View file

@ -39,7 +39,7 @@
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
@ -66,7 +66,7 @@
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@ -81,20 +81,23 @@ import type { PropType } from 'vue'
import type { FileAction, Node } from '@nextcloud/files'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent, inject } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import { useNavigation } from '../../composables/useNavigation'
import { executeAction } from '../../utils/actionUtils.ts'
import { useActiveStore } from '../../store/active.ts'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import logger from '../../logger.ts'
export default defineComponent({
@ -111,10 +114,6 @@ export default defineComponent({
},
props: {
loading: {
type: String,
required: true,
},
opened: {
type: Boolean,
default: false,
@ -132,14 +131,18 @@ export default defineComponent({
setup() {
// The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
const { currentView } = useNavigation(true)
const { directory: currentDir } = useRouteParameters()
const activeStore = useActiveStore()
const filesListWidth = useFileListWidth()
const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
activeStore,
currentDir,
currentView,
enabledFileActions,
filesListWidth,
t,
}
},
@ -150,10 +153,10 @@ export default defineComponent({
},
computed: {
currentDir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
isActive() {
return this.activeStore?.activeNode?.source === this.source.source
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
@ -241,6 +244,18 @@ export default defineComponent({
},
},
created() {
useHotKey('Escape', this.onKeyDown, {
stop: true,
prevent: true,
})
useHotKey('a', this.onKeyDown, {
stop: true,
prevent: true,
})
},
methods: {
actionDisplayName(action: FileAction) {
try {
@ -258,54 +273,29 @@ export default defineComponent({
}
},
async onActionClick(action, isSubmenu = false) {
// Skip click on loading
if (this.isLoading || this.loading !== '') {
return
isLoadingAction(action: FileAction) {
if (!this.isActive) {
return false
}
return this.activeStore?.activeAction?.id === action.id
},
async onActionClick(action, isSubmenu = false) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
return
}
let displayName = action.id
try {
displayName = action.displayName([this.source], this.currentView)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
// Make sure we set the node as active
this.activeStore.setActiveNode(this.source)
try {
// Set the loading marker
this.$emit('update:loading', action.id)
this.$set(this.source, 'status', NodeStatus.LOADING)
// Execute the action
await executeAction(action)
const success = await action.exec(this.source, this.currentView, this.currentDir)
// If the action returns null, we stay silent
if (success === null || success === undefined) {
return
}
if (success) {
showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(t('files', '"{displayName}" action failed', { displayName }))
} catch (error) {
logger.error('Error while executing action', { action, error })
showError(t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Reset the loading marker
this.$emit('update:loading', '')
this.$set(this.source, 'status', undefined)
// If that was a submenu, we just go back after the action
if (isSubmenu) {
this.openedSubmenu = null
}
// If that was a submenu, we just go back after the action
if (isSubmenu) {
this.openedSubmenu = null
}
},
@ -328,7 +318,22 @@ export default defineComponent({
})
},
t,
onKeyDown(event: KeyboardEvent) {
// Don't react to the event if the file row is not active
if (!this.isActive) {
return
}
// ESC close the action menu if opened
if (event.key === 'Escape' && this.openedMenu) {
this.openedMenu = false
}
// a open the action menu
if (event.key === 'a' && !this.openedMenu) {
this.openedMenu = true
}
},
},
})
</script>

View file

@ -58,7 +58,6 @@
<FileEntryActions ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:grid-mode="true"
:loading.sync="loading"
:opened.sync="openedMenu"
:source="source" />
</tr>

View file

@ -59,7 +59,6 @@ export default defineComponent({
data() {
return {
loading: '',
dragover: false,
gridMode: false,
}
@ -75,7 +74,7 @@ export default defineComponent({
},
isLoading() {
return this.source.status === NodeStatus.LOADING || this.loading !== ''
return this.source.status === NodeStatus.LOADING
},
/**
@ -261,9 +260,6 @@ export default defineComponent({
methods: {
resetState() {
// Reset loading state
this.loading = ''
// Reset the preview state
this.$refs?.preview?.reset?.()
@ -310,7 +306,7 @@ export default defineComponent({
return
}
// Ignore right click (button & 2) and any auxillary button expect mouse-wheel (button & 4)
// Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4)
if (Boolean(event.button & 2) || event.button > 4) {
return
}

View file

@ -148,7 +148,13 @@ export default defineComponent({
},
async onActionClick(action) {
const displayName = action.displayName(this.nodes, this.currentView)
let displayName = action.id
try {
displayName = action.displayName(this.nodes, this.currentView)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
const selectionSources = this.selectedNodes
try {
// Set loading markers

View file

@ -2,17 +2,19 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Pinia } from 'pinia'
import { getCSPNonce } from '@nextcloud/auth'
import { getNavigation } from '@nextcloud/files'
import { PiniaVuePlugin } from 'pinia'
import Vue from 'vue'
import { pinia } from './store/index.ts'
import { getPinia } from './store/index.ts'
import { registerHotkeys } from './services/HotKeysService.ts'
import FilesApp from './FilesApp.vue'
import router from './router/router'
import RouterService from './services/RouterService'
import SettingsModel from './models/Setting.js'
import SettingsService from './services/Settings.js'
import FilesApp from './FilesApp.vue'
__webpack_nonce__ = getCSPNonce()
@ -22,6 +24,7 @@ declare global {
OCP: Nextcloud.v29.OCP
// eslint-disable-next-line @typescript-eslint/no-explicit-any
OCA: Record<string, any>
_nc_files_pinia: Pinia
}
}
@ -38,6 +41,9 @@ if (!window.OCP.Files.Router) {
// Init Pinia store
Vue.use(PiniaVuePlugin)
// Init HotKeys AFTER pinia is set up
registerHotkeys()
// Init Navigation Service
// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a observer
const Navigation = Vue.observable(getNavigation())
@ -51,5 +57,5 @@ Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel })
const FilesAppVue = Vue.extend(FilesApp)
new FilesAppVue({
router: (window.OCP.Files.Router as RouterService)._router,
pinia,
pinia: getPinia(),
}).$mount('#content')

View file

@ -0,0 +1,161 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, it, vi, expect, beforeEach, beforeAll } from 'vitest'
import { File, Permission, View } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import { getPinia } from '../store/index.ts'
import { useActiveStore } from '../store/active.ts'
import { action as deleteAction } from '../actions/deleteAction.ts'
import { action as favoriteAction } from '../actions/favoriteAction.ts'
import { action as renameAction } from '../actions/renameAction.ts'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { registerHotkeys } from './HotKeysService.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { subscribe } from '@nextcloud/event-bus'
let file: File
const view = {
id: 'files',
name: 'Files',
} as View
vi.mock('../actions/sidebarAction.ts', { spy: true })
vi.mock('../actions/deleteAction.ts', { spy: true })
vi.mock('../actions/favoriteAction.ts', { spy: true })
vi.mock('../actions/renameAction.ts', { spy: true })
describe('HotKeysService testing', () => {
const activeStore = useActiveStore(getPinia())
const goToRouteMock = vi.fn()
beforeAll(() => {
registerHotkeys()
})
beforeEach(() => {
// Make sure the router is reset before each test
goToRouteMock.mockClear()
// Make sure the file is reset before each test
file = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
})
// Setting the view first as it reset the active node
activeStore.onChangedView(view)
activeStore.setActiveNode(file)
window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } }
// @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } }
})
it('Pressing d should open the sidebar once', () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD' }))
// Modifier keys should not trigger the action
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', ctrlKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', altKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', shiftKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', metaKey: true }))
expect(sidebarAction.enabled).toHaveReturnedWith(true)
expect(sidebarAction.exec).toHaveBeenCalledOnce()
})
it('Pressing F2 should rename the file', () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2' }))
// Modifier keys should not trigger the action
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', ctrlKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', altKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', shiftKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', metaKey: true }))
expect(renameAction.enabled).toHaveReturnedWith(true)
expect(renameAction.exec).toHaveBeenCalledOnce()
})
it('Pressing s should toggle favorite', () => {
vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS' }))
// Modifier keys should not trigger the action
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', altKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', shiftKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true }))
expect(favoriteAction.enabled).toHaveReturnedWith(true)
expect(favoriteAction.exec).toHaveBeenCalledOnce()
})
it('Pressing Delete should delete the file', async () => {
vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete' }))
// Modifier keys should not trigger the action
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true }))
expect(deleteAction.enabled).toHaveReturnedWith(true)
expect(deleteAction.exec).toHaveBeenCalledOnce()
})
it('Pressing alt+up should go to parent directory', () => {
expect(goToRouteMock).toHaveBeenCalledTimes(0)
window.OCP.Files.Router.query = { dir: '/foo/bar' }
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', altKey: true }))
expect(goToRouteMock).toHaveBeenCalledOnce()
expect(goToRouteMock.mock.calls[0][2].dir).toBe('/foo')
})
it('Pressing v should toggle grid view', async () => {
vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
const userConfigStore = useUserConfigStore(getPinia())
const currentGridConfig = userConfigStore.userConfig.grid_view
// Wait for the user config to be updated
// or timeout after 500ms
const waitForUserConfig = () => new Promise((resolve) => {
subscribe('files:config:updated', resolve)
setTimeout(resolve, 500)
})
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV' }))
await waitForUserConfig()
expect(userConfigStore.userConfig.grid_view).toBe(!currentGridConfig)
// Modifier keys should not trigger the action
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true }))
await waitForUserConfig()
expect(userConfigStore.userConfig.grid_view).toBe(!currentGridConfig)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true }))
await waitForUserConfig()
expect(userConfigStore.userConfig.grid_view).toBe(!currentGridConfig)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true }))
await waitForUserConfig()
expect(userConfigStore.userConfig.grid_view).toBe(!currentGridConfig)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true }))
await waitForUserConfig()
expect(userConfigStore.userConfig.grid_view).toBe(!currentGridConfig)
})
})

View file

@ -0,0 +1,82 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { dirname } from 'path'
import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import { action as deleteAction } from '../actions/deleteAction.ts'
import { action as favoriteAction } from '../actions/favoriteAction.ts'
import { action as renameAction } from '../actions/renameAction.ts'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { executeAction } from '../utils/actionUtils.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import logger from '../logger.ts'
/**
* This register the hotkeys for the Files app.
* As much as possible, we try to have all the hotkeys in one place.
* Please make sure to add tests for the hotkeys after adding a new one.
*/
export const registerHotkeys = function() {
// d opens the sidebar
useHotKey('d', () => executeAction(sidebarAction), {
stop: true,
prevent: true,
})
// F2 renames the file
useHotKey('F2', () => executeAction(renameAction), {
stop: true,
prevent: true,
})
// s toggle favorite
useHotKey('s', () => executeAction(favoriteAction), {
stop: true,
prevent: true,
})
// Delete deletes the file
useHotKey('Delete', () => executeAction(deleteAction), {
stop: true,
prevent: true,
})
// alt+up go to parent directory
useHotKey('ArrowUp', goToParentDir, {
stop: true,
prevent: true,
alt: true,
})
// v toggle grid view
useHotKey('v', toggleGridView, {
stop: true,
prevent: true,
})
logger.debug('Hotkeys registered')
}
const goToParentDir = function() {
const params = window.OCP.Files.Router?.params || {}
const query = window.OCP.Files.Router?.query || {}
const currentDir = (query?.dir || '/') as string
const parentDir = dirname(currentDir)
logger.debug('Navigating to parent directory', { parentDir })
window.OCP.Files.Router.goToRoute(
null,
{ ...params },
{ ...query, dir: parentDir },
)
}
const toggleGridView = function() {
const userConfigStore = useUserConfigStore()
const value = userConfigStore?.userConfig?.grid_view
logger.debug('Toggling grid view', { old: value, new: !value })
userConfigStore.update('grid_view', !value)
}

View file

@ -5,4 +5,11 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
export const getPinia = () => {
if (window._nc_files_pinia) {
return window._nc_files_pinia
}
window._nc_files_pinia = createPinia()
return window._nc_files_pinia
}

View file

@ -27,8 +27,6 @@ export const useUserConfigStore = function(...args) {
actions: {
/**
* Update the user config local store
* @param key
* @param value
*/
onUpdate(key: string, value: boolean) {
Vue.set(this.userConfig, key, value)
@ -36,8 +34,6 @@ export const useUserConfigStore = function(...args) {
/**
* Update the user config local store AND on server side
* @param key
* @param value
*/
async update(key: string, value: boolean) {
await axios.put(generateUrl('/apps/files/api/v1/config/' + key), {

View file

@ -0,0 +1,74 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FileAction } from '@nextcloud/files'
import { NodeStatus } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import { getPinia } from '../store'
import { useActiveStore } from '../store/active'
import logger from '../logger'
/**
* Execute an action on the current active node
*
* @param action The action to execute
*/
export const executeAction = async (action: FileAction) => {
const activeStore = useActiveStore(getPinia())
const currentDir = (window?.OCP?.Files?.Router?.query?.dir || '/') as string
const currentNode = activeStore.activeNode
const currentView = activeStore.activeView
if (!currentNode || !currentView) {
logger.error('No active node or view', { node: currentNode, view: currentView })
return
}
if (currentNode.status === NodeStatus.LOADING) {
logger.debug('Node is already loading', { node: currentNode })
return
}
if (!action.enabled!([currentNode], currentView)) {
logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView })
return
}
let displayName = action.id
try {
displayName = action.displayName([currentNode], currentView)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
try {
// Set the loading marker
Vue.set(currentNode, 'status', NodeStatus.LOADING)
activeStore.setActiveAction(action)
const success = await action.exec(currentNode, currentView, currentDir)
// If the action returns null, we stay silent
if (success === null || success === undefined) {
return
}
if (success) {
showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(t('files', '"{displayName}" action failed', { displayName }))
} catch (error) {
logger.error('Error while executing action', { action, error })
showError(t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Reset the loading marker
Vue.set(currentNode, 'status', undefined)
activeStore.clearActiveAction()
}
}