Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(files): add keyboard shortcuts #49432

Merged
merged 8 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/files/src/actions/deleteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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')

Comment on lines +46 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels very tricky 👀

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot we explicitly set it via exec params?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It IS very very tricky! 🪄
The exec is a direct API definition. Adding another param is kinda not great as well.
And extending the api jyst for that use case is over the top imho 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option would be to have two actions, delete and deleteWithConfirmation

// If trashbin is disabled, we need to ask for confirmation
if (!isTrashbinEnabled()) {
if (!isTrashbinEnabled() || isCalledFromEventListener) {
confirm = await askConfirmation([node], view)
}

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion apps/files/src/actions/favoriteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions apps/files/src/actions/renameAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
4 changes: 2 additions & 2 deletions apps/files/src/actions/sidebarAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('Open sidebar action exec tests', () => {
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/' },
{ dir: '/', opendetails: 'true' },
true,
)
})
Expand Down Expand Up @@ -159,7 +159,7 @@ describe('Open sidebar action exec tests', () => {
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/' },
{ dir: '/', opendetails: 'true' },
true,
)
})
Expand Down
9 changes: 7 additions & 2 deletions apps/files/src/actions/sidebarAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,22 @@ export const action = new FileAction({

async exec(node: Node, view: View, dir: string) {
try {
// If the sidebar is already open for the current file, do nothing
if (window.OCA.Files.Sidebar.file === node.path) {
logger.debug('Sidebar already open for this file', { node })
return null
}
// Open sidebar and set active tab to sharing by default
window.OCA.Files.Sidebar.setActiveTab('sharing')

// TODO: migrate Sidebar to use a Node instead
await window.OCA.Files.Sidebar.open(node.path)

// Silently update current fileid
window.OCP.Files.Router.goToRoute(
window.OCP?.Files?.Router?.goToRoute(
null,
{ view: view.id, fileid: String(node.fileid) },
{ ...window.OCP.Files.Router.query, dir },
{ ...window.OCP.Files.Router.query, dir, opendetails: 'true' },
true,
)

Expand Down
22 changes: 19 additions & 3 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
<FileEntryActions v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:loading.sync="loading"
:opened.sync="openedMenu"
:source="source" />

Expand Down Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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>
113 changes: 59 additions & 54 deletions apps/files/src/components/FileEntry/FileEntryActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
Expand All @@ -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) }}
Expand All @@ -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({
Expand All @@ -111,10 +114,6 @@ export default defineComponent({
},

props: {
loading: {
type: String,
required: true,
},
opened: {
type: Boolean,
default: false,
Expand All @@ -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,
}
},

Expand All @@ -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
},
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
},

Expand All @@ -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>
Expand Down
Loading
Loading