mirror of
https://github.com/nextcloud/server.git
synced 2026-06-10 17:23:59 -04:00
feat(files): add hotkey service and unify action handling
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
parent
376a7bba7b
commit
74b2562e6b
14 changed files with 438 additions and 80 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
161
apps/files/src/services/HotKeysService.spec.ts
Normal file
161
apps/files/src/services/HotKeysService.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
apps/files/src/services/HotKeysService.ts
Normal file
82
apps/files/src/services/HotKeysService.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
|
|||
74
apps/files/src/utils/actionUtils.ts
Normal file
74
apps/files/src/utils/actionUtils.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue