aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/files/src/actions/deleteAction.ts16
-rw-r--r--apps/files/src/actions/favoriteAction.ts4
-rw-r--r--apps/files/src/actions/renameAction.ts4
-rw-r--r--apps/files/src/components/FileEntry.vue22
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue113
-rw-r--r--apps/files/src/components/FileEntryGrid.vue1
-rw-r--r--apps/files/src/components/FileEntryMixin.ts8
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue8
-rw-r--r--apps/files/src/main.ts12
-rw-r--r--apps/files/src/services/HotKeysService.spec.ts161
-rw-r--r--apps/files/src/services/HotKeysService.ts82
-rw-r--r--apps/files/src/store/index.ts9
-rw-r--r--apps/files/src/store/userconfig.ts4
-rw-r--r--apps/files/src/utils/actionUtils.ts74
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()
+ }
+}