diff options
-rw-r--r-- | apps/files/src/actions/deleteAction.ts | 16 | ||||
-rw-r--r-- | apps/files/src/actions/favoriteAction.ts | 4 | ||||
-rw-r--r-- | apps/files/src/actions/renameAction.ts | 4 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 22 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryActions.vue | 113 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryGrid.vue | 1 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 8 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableHeaderActions.vue | 8 | ||||
-rw-r--r-- | apps/files/src/main.ts | 12 | ||||
-rw-r--r-- | apps/files/src/services/HotKeysService.spec.ts | 161 | ||||
-rw-r--r-- | apps/files/src/services/HotKeysService.ts | 82 | ||||
-rw-r--r-- | apps/files/src/store/index.ts | 9 | ||||
-rw-r--r-- | apps/files/src/store/userconfig.ts | 4 | ||||
-rw-r--r-- | apps/files/src/utils/actionUtils.ts | 74 |
14 files changed, 438 insertions, 80 deletions
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 63335db194e..8d8aa4f9deb 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -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) diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts index 1c1057a553e..b0e1e3a0817 100644 --- a/apps/files/src/actions/favoriteAction.ts +++ b/apps/files/src/actions/favoriteAction.ts @@ -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') diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts index 13ba32aaae9..e0ea784c291 100644 --- a/apps/files/src/actions/renameAction.ts +++ b/apps/files/src/actions/renameAction.ts @@ -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, diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 7af76c87c43..7541c0f0631 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -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> diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index d8d46c8f713..0bbac99bf48 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -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> diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 0b0344afb99..bf007e2be6c 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -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> diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index 08dfd402677..2d20881cde0 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -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 } diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 9f5724dc80f..16d99f974dd 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -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 diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 99fe99422a8..b4755df8eed 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -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') diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts new file mode 100644 index 00000000000..dfe9f66601b --- /dev/null +++ b/apps/files/src/services/HotKeysService.spec.ts @@ -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) + }) +}) diff --git a/apps/files/src/services/HotKeysService.ts b/apps/files/src/services/HotKeysService.ts new file mode 100644 index 00000000000..8318554ea99 --- /dev/null +++ b/apps/files/src/services/HotKeysService.ts @@ -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) +} diff --git a/apps/files/src/store/index.ts b/apps/files/src/store/index.ts index 00676b3bc8e..3ba667ffd2f 100644 --- a/apps/files/src/store/index.ts +++ b/apps/files/src/store/index.ts @@ -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 +} diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index ffe07a91bab..6fd2ad886bc 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -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), { diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts new file mode 100644 index 00000000000..730a1149229 --- /dev/null +++ b/apps/files/src/utils/actionUtils.ts @@ -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() + } +} |