Skip to content

Commit 60a4369

Browse files
authored
Merge pull request #57352 from nextcloud/fix/active-files
fix(files): properly handle currently active node and files action hotkeys
2 parents c9a7654 + 5597718 commit 60a4369

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+318
-774
lines changed

apps/files/src/actions/deleteUtils.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
/**
1+
/*!
22
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Node, View } from '@nextcloud/files'
6+
import type { INode, IView } from '@nextcloud/files'
77
import type { Capabilities } from '../types.ts'
88

99
import axios from '@nextcloud/axios'
@@ -17,10 +17,9 @@ import { useUserConfigStore } from '../store/userconfig.ts'
1717
export const isTrashbinEnabled = () => (getCapabilities() as Capabilities)?.files?.undelete === true
1818

1919
/**
20-
*
2120
* @param nodes
2221
*/
23-
export function canUnshareOnly(nodes: Node[]) {
22+
export function canUnshareOnly(nodes: INode[]) {
2423
return nodes.every((node) => node.attributes['is-mount-root'] === true
2524
&& node.attributes['mount-type'] === 'shared')
2625
}
@@ -29,7 +28,7 @@ export function canUnshareOnly(nodes: Node[]) {
2928
*
3029
* @param nodes
3130
*/
32-
export function canDisconnectOnly(nodes: Node[]) {
31+
export function canDisconnectOnly(nodes: INode[]) {
3332
return nodes.every((node) => node.attributes['is-mount-root'] === true
3433
&& node.attributes['mount-type'] === 'external')
3534
}
@@ -38,7 +37,7 @@ export function canDisconnectOnly(nodes: Node[]) {
3837
*
3938
* @param nodes
4039
*/
41-
export function isMixedUnshareAndDelete(nodes: Node[]) {
40+
export function isMixedUnshareAndDelete(nodes: INode[]) {
4241
if (nodes.length === 1) {
4342
return false
4443
}
@@ -52,25 +51,26 @@ export function isMixedUnshareAndDelete(nodes: Node[]) {
5251
*
5352
* @param nodes
5453
*/
55-
export function isAllFiles(nodes: Node[]) {
54+
export function isAllFiles(nodes: INode[]) {
5655
return !nodes.some((node) => node.type !== FileType.File)
5756
}
5857

5958
/**
6059
*
6160
* @param nodes
6261
*/
63-
export function isAllFolders(nodes: Node[]) {
62+
export function isAllFolders(nodes: INode[]) {
6463
return !nodes.some((node) => node.type !== FileType.Folder)
6564
}
6665

6766
/**
67+
* Get the display name for the delete action
6868
*
69-
* @param root0
70-
* @param root0.nodes
71-
* @param root0.view
69+
* @param context - The context
70+
* @param context.nodes - The nodes to delete
71+
* @param context.view - The current view
7272
*/
73-
export function displayName({ nodes, view }: { nodes: Node[], view: View }) {
73+
export function displayName({ nodes, view }: { nodes: INode[], view: IView }) {
7474
/**
7575
* If those nodes are all the root node of a
7676
* share, we can only unshare them.
@@ -143,7 +143,7 @@ export function shouldAskForConfirmation() {
143143
* @param nodes
144144
* @param view
145145
*/
146-
export async function askConfirmation(nodes: Node[], view: View) {
146+
export async function askConfirmation(nodes: INode[], view: IView) {
147147
const message = view.id === 'trashbin' || !isTrashbinEnabled()
148148
? n('files', 'You are about to permanently delete {count} item', 'You are about to permanently delete {count} items', nodes.length, { count: nodes.length })
149149
: n('files', 'You are about to delete {count} item', 'You are about to delete {count} items', nodes.length, { count: nodes.length })
@@ -170,7 +170,7 @@ export async function askConfirmation(nodes: Node[], view: View) {
170170
*
171171
* @param node
172172
*/
173-
export async function deleteNode(node: Node) {
173+
export async function deleteNode(node: INode) {
174174
await axios.delete(node.encodedSource)
175175

176176
// Let's delete even if it's moved to the trashbin

apps/files/src/actions/favoriteAction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import StarSvg from '@mdi/svg/svg/star.svg?raw'
1010
import axios from '@nextcloud/axios'
1111
import { emit } from '@nextcloud/event-bus'
1212
import { FileAction, Permission } from '@nextcloud/files'
13-
import { translate as t } from '@nextcloud/l10n'
13+
import { t } from '@nextcloud/l10n'
1414
import { encodePath } from '@nextcloud/paths'
1515
import { generateUrl } from '@nextcloud/router'
1616
import { isPublicShare } from '@nextcloud/sharing/public'

apps/files/src/actions/sidebarAction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,9 @@ export const action = new FileAction({
5454
},
5555

5656
order: -50,
57+
58+
hotkey: {
59+
key: 'D',
60+
description: t('files', 'Open the details sidebar'),
61+
},
5762
})

apps/files/src/components/FilesAppSettings/FilesAppSettingsShortcuts.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ function hotkeyToString(hotkey: IHotkeyConfig): string {
3737
if (hotkey.shift) {
3838
parts.push('Shift')
3939
}
40-
parts.push(hotkey.key)
40+
if (hotkey.key.match(/^[a-z]$/)) {
41+
parts.push(hotkey.key.toUpperCase())
42+
} else {
43+
parts.push(hotkey.key)
44+
}
4145
return parts.join(' ')
4246
}
4347
</script>
@@ -71,7 +75,6 @@ function hotkeyToString(hotkey: IHotkeyConfig): string {
7175

7276
<NcHotkeyList :label="t('files', 'View')">
7377
<NcHotkey :label="t('files', 'Toggle grid view')" hotkey="V" />
74-
<NcHotkey :label="t('files', 'Open file sidebar')" hotkey="D" />
7578
<NcHotkey :label="t('files', 'Show those shortcuts')" hotkey="?" />
7679
</NcHotkeyList>
7780
</NcAppSettingsShortcutsSection>

apps/files/src/components/FilesListVirtual.vue

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,11 @@ export default defineComponent({
301301
}
302302
303303
if (this.fileId) {
304-
this.scrollToFile(this.fileId, false)
304+
const node = this.nodes.find((node) => node.fileid === this.fileId)
305+
if (node) {
306+
this.activeStore.activeNode = node
307+
this.scrollToFile(this.fileId, false)
308+
}
305309
}
306310
},
307311
@@ -342,13 +346,7 @@ export default defineComponent({
342346
delete query.openfile
343347
delete query.opendetails
344348
345-
this.activeStore.activeNode = undefined
346-
window.OCP.Files.Router.goToRoute(
347-
null,
348-
{ ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
349-
query,
350-
true,
351-
)
349+
this.activeStore.activeNode = this.currentFolder
352350
},
353351
354352
/**
@@ -396,7 +394,7 @@ export default defineComponent({
396394
logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
397395
window.OCP.Files.Router.goToRoute(
398396
null,
399-
this.$route.params,
397+
window.OCP.Files.Router.params,
400398
{ ...this.$route.query, openfile: undefined, opendetails: '' },
401399
true, // silent update of the URL
402400
)
@@ -431,10 +429,29 @@ export default defineComponent({
431429
},
432430
433431
onKeyDown(event: KeyboardEvent) {
432+
if (this.isEmpty) {
433+
return
434+
}
435+
436+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown'
437+
&& (!this.userConfig.grid_view || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight'))
438+
) {
439+
// not an arrow key we handle
440+
return
441+
}
442+
443+
if (!this.fileId || this.fileId === this.currentFolder.fileid) {
444+
// no active node so use either first or last node
445+
const index = event.key === 'ArrowUp' || event.key === 'ArrowLeft'
446+
? this.nodes.length - 1
447+
: 0
448+
this.setActiveNode(this.nodes[index] as NcNode & { fileid: number })
449+
}
450+
451+
const index = this.nodes.findIndex((node) => node.fileid === this.fileId) ?? 0
434452
// Up and down arrow keys
435453
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
436454
const columnCount = this.$refs.table?.columnCount ?? 1
437-
const index = this.nodes.findIndex((node) => node.fileid === this.fileId) ?? 0
438455
const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
439456
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
440457
return
@@ -450,7 +467,6 @@ export default defineComponent({
450467
451468
// if grid mode, left and right arrow keys
452469
if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
453-
const index = this.nodes.findIndex((node) => node.fileid === this.fileId) ?? 0
454470
const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
455471
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
456472
return
@@ -465,24 +481,21 @@ export default defineComponent({
465481
}
466482
},
467483
468-
setActiveNode(node: NcNode & { fileid: number }) {
484+
async setActiveNode(node: NcNode & { fileid: number }) {
469485
logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
470486
this.scrollToFile(node.fileid)
471487
472488
// Remove openfile and opendetails from the URL
473489
const query = { ...this.$route.query }
474490
delete query.openfile
475491
delete query.opendetails
492+
await this.$router.replace({
493+
...this.$route,
494+
query,
495+
})
476496
497+
// set the new file as active
477498
this.activeStore.activeNode = node
478-
479-
// Silent update of the URL
480-
window.OCP.Files.Router.goToRoute(
481-
null,
482-
{ ...this.$route.params, fileid: String(node.fileid) },
483-
query,
484-
true,
485-
)
486499
},
487500
},
488501
})

apps/files/src/composables/useHotKeys.spec.ts

Lines changed: 25 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,14 @@
44
*/
55

66
import type { View } from '@nextcloud/files'
7-
import type { Mock } from 'vitest'
87
import type { Location } from 'vue-router'
98

109
import axios from '@nextcloud/axios'
11-
import { File, Folder, Permission } from '@nextcloud/files'
10+
import { File, Folder, Permission, registerFileAction } from '@nextcloud/files'
1211
import { enableAutoDestroy, mount } from '@vue/test-utils'
13-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1413
import { defineComponent, nextTick } from 'vue'
1514
import { action as deleteAction } from '../actions/deleteAction.ts'
16-
import { action as favoriteAction } from '../actions/favoriteAction.ts'
17-
import { action as renameAction } from '../actions/renameAction.ts'
18-
import { action as sidebarAction } from '../actions/sidebarAction.ts'
1915
import { useActiveStore } from '../store/active.ts'
2016
import { useFilesStore } from '../store/files.ts'
2117
import { getPinia } from '../store/index.ts'
@@ -63,10 +59,23 @@ const TestComponent = defineComponent({
6359
template: '<div />',
6460
})
6561

62+
beforeAll(() => {
63+
// @ts-expect-error mocking for tests
64+
window.OCP ??= {}
65+
// @ts-expect-error mocking for tests
66+
window.OCP.Files ??= {}
67+
// @ts-expect-error mocking for tests
68+
window.OCP.Files.Router ??= {
69+
...router,
70+
goToRoute: vi.fn(),
71+
}
72+
})
73+
6674
describe('HotKeysService testing', () => {
6775
const activeStore = useActiveStore(getPinia())
6876

6977
let initialState: HTMLInputElement
78+
let component: ReturnType<typeof mount>
7079

7180
enableAutoDestroy(afterEach)
7281

@@ -114,54 +123,15 @@ describe('HotKeysService testing', () => {
114123
})))
115124
document.body.appendChild(initialState)
116125

117-
mount(TestComponent)
118-
})
119-
120-
it('Pressing d should open the sidebar once', () => {
121-
dispatchEvent({ key: 'd', code: 'KeyD' })
122-
123-
// Modifier keys should not trigger the action
124-
dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true })
125-
dispatchEvent({ key: 'd', code: 'KeyD', altKey: true })
126-
dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true })
127-
dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true })
128-
129-
expect(sidebarAction.enabled).toHaveReturnedWith(true)
130-
expect(sidebarAction.exec).toHaveBeenCalledOnce()
131-
})
132-
133-
it('Pressing F2 should rename the file', () => {
134-
dispatchEvent({ key: 'F2', code: 'F2' })
135-
136-
// Modifier keys should not trigger the action
137-
dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true })
138-
dispatchEvent({ key: 'F2', code: 'F2', altKey: true })
139-
dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true })
140-
dispatchEvent({ key: 'F2', code: 'F2', metaKey: true })
141-
142-
expect(renameAction.enabled).toHaveReturnedWith(true)
143-
expect(renameAction.exec).toHaveBeenCalledOnce()
126+
component = mount(TestComponent)
144127
})
145128

146-
it('Pressing s should toggle favorite', () => {
147-
(favoriteAction.enabled as Mock).mockReturnValue(true);
148-
(favoriteAction.exec as Mock).mockImplementationOnce(() => Promise.resolve(null))
129+
// tests for register action handling
149130

150-
vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
151-
dispatchEvent({ key: 's', code: 'KeyS' })
152-
153-
// Modifier keys should not trigger the action
154-
dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true })
155-
dispatchEvent({ key: 's', code: 'KeyS', altKey: true })
156-
dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true })
157-
dispatchEvent({ key: 's', code: 'KeyS', metaKey: true })
158-
159-
expect(favoriteAction.exec).toHaveBeenCalledOnce()
160-
})
161-
162-
it('Pressing Delete should delete the file', async () => {
163-
// @ts-expect-error unit testing - private method access
164-
vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
131+
it('registeres actions', () => {
132+
component.destroy()
133+
registerFileAction(deleteAction)
134+
component = mount(TestComponent)
165135

166136
dispatchEvent({ key: 'Delete', code: 'Delete' })
167137

@@ -175,6 +145,8 @@ describe('HotKeysService testing', () => {
175145
expect(deleteAction.exec).toHaveBeenCalledOnce()
176146
})
177147

148+
// actions implemented by the composable
149+
178150
it('Pressing alt+up should go to parent directory', () => {
179151
expect(router.push).toHaveBeenCalledTimes(0)
180152
dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true })
@@ -197,9 +169,8 @@ describe('HotKeysService testing', () => {
197169
it.each([
198170
['ctrlKey'],
199171
['altKey'],
200-
// those meta keys are still triggering...
201-
// ['shiftKey'],
202-
// ['metaKey']
172+
['shiftKey'],
173+
['metaKey'],
203174
])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => {
204175
vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
205176

0 commit comments

Comments
 (0)