feat(files): add opendetails param and file list up/down keyboard shortcut

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
skjnldsv 2024-12-13 12:00:28 +01:00
parent f16d047808
commit e7001022c7
7 changed files with 234 additions and 49 deletions

View file

@ -130,7 +130,7 @@ describe('Open sidebar action exec tests', () => {
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/' },
{ dir: '/', opendetails: 'true' },
true,
)
})
@ -159,7 +159,7 @@ describe('Open sidebar action exec tests', () => {
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/' },
{ dir: '/', opendetails: 'true' },
true,
)
})

View file

@ -44,6 +44,11 @@ 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')
@ -51,10 +56,10 @@ export const action = new FileAction({
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,
)

View file

@ -12,7 +12,6 @@
isMtimeAvailable,
isSizeAvailable,
nodes,
fileListWidth,
}"
:scroll-to-index="scrollToIndex"
:caption="caption">
@ -58,32 +57,34 @@
</template>
<script lang="ts">
import type { Node as NcNode } from '@nextcloud/files'
import type { ComponentPublicInstance, PropType } from 'vue'
import type { Node as NcNode } from '@nextcloud/files'
import type { UserConfig } from '../types'
import { defineComponent } from 'vue'
import { getFileListHeaders, Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { defineComponent } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { getSummaryFor } from '../utils/fileUtils'
import { useActiveStore } from '../store/active.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { getSummaryFor } from '../utils/fileUtils'
import { useSelectionStore } from '../store/selection.js'
import { useUserConfigStore } from '../store/userconfig.ts'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileListFilters from './FileListFilters.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import VirtualList from './VirtualList.vue'
import logger from '../logger.ts'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FileListFilters from './FileListFilters.vue'
import logger from '../logger.ts'
import VirtualList from './VirtualList.vue'
export default defineComponent({
name: 'FilesListVirtual',
@ -113,18 +114,24 @@ export default defineComponent({
},
setup() {
const userConfigStore = useUserConfigStore()
const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
const fileListWidth = useFileListWidth()
const { fileId, openFile } = useRouteParameters()
const { fileId, openDetails, openFile } = useRouteParameters()
return {
fileId,
fileListWidth,
openDetails,
openFile,
userConfigStore,
activeStore,
selectionStore,
userConfigStore,
t,
}
},
@ -215,12 +222,20 @@ export default defineComponent({
handler() {
// wait for scrolling and updating the actions to settle
this.$nextTick(() => {
if (this.fileId) {
if (this.openFile) {
this.handleOpenFile(this.fileId)
} else {
this.unselectFile()
}
if (this.fileId && this.openFile) {
this.handleOpenFile(this.fileId)
}
})
},
immediate: true,
},
openDetails: {
handler() {
// wait for scrolling and updating the actions to settle
this.$nextTick(() => {
if (this.fileId && this.openDetails) {
this.openSidebarForFile(this.fileId)
}
})
},
@ -228,39 +243,39 @@ export default defineComponent({
},
},
created() {
useHotKey('Escape', this.unselectFile, {
stop: true,
prevent: true,
})
useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, {
stop: true,
prevent: true,
})
},
mounted() {
// Add events on parent to cover both the table and DragAndDrop notice
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
subscribe('files:sidebar:closed', this.unselectFile)
// If the file list is mounted with a fileId specified
// then we need to open the sidebar initially
if (this.fileId) {
this.openSidebarForFile(this.fileId)
}
subscribe('files:sidebar:closed', this.onSidebarClosed)
},
beforeDestroy() {
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
unsubscribe('files:sidebar:closed', this.unselectFile)
unsubscribe('files:sidebar:closed', this.onSidebarClosed)
},
methods: {
// Open the file sidebar if we have the room for it
// but don't open the sidebar for the current folder
openSidebarForFile(fileId) {
if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== fileId) {
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
}
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
}
},
@ -273,19 +288,39 @@ export default defineComponent({
const index = this.nodes.findIndex(node => node.fileid === fileId)
if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
showError(this.t('files', 'File not found'))
showError(t('files', 'File not found'))
}
this.scrollToIndex = Math.max(0, index)
}
},
/**
* Unselect the current file and clear open parameters from the URL
*/
unselectFile() {
// If the Sidebar is closed and if openFile is false, remove the file id from the URL
if (!this.openFile && OCA.Files.Sidebar.file === '') {
const query = { ...this.$route.query }
delete query.openfile
delete query.opendetails
this.activeStore.clearActiveNode()
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
query,
true,
)
},
// When sidebar is closed, we remove the openDetails parameter from the URL
onSidebarClosed() {
if (this.openDetails) {
const query = { ...this.$route.query }
delete query.opendetails
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
this.$route.query,
this.$route.params,
query,
)
}
},
@ -348,7 +383,58 @@ export default defineComponent({
}
},
t,
onKeyDown(event: KeyboardEvent) {
// Up and down arrow keys
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const columnCount = this.$refs.table?.columnCount ?? 1
const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
return
}
const nextNode = this.nodes[nextIndex]
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
}
}
// if grid mode, left and right arrow keys
if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
return
}
const nextNode = this.nodes[nextIndex]
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
}
}
},
setActiveNode(node: NcNode & { fileid: number }) {
logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
this.scrollToFile(node.fileid)
// Remove openfile and opendetails from the URL
const query = { ...this.$route.query }
delete query.openfile
delete query.opendetails
this.activeStore.setActiveNode(node)
// Silent update of the URL
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: String(node.fileid) },
query,
true,
)
},
},
})
</script>

View file

@ -37,6 +37,11 @@ export function useRouteParameters() {
() => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'),
)
const openDetails = computed<boolean>(
// if `opendetails` is set it is considered truthy, but allow to explicitly set it to 'false'
() => 'opendetails' in route.query && (typeof route.query.opendetails !== 'string' || route.query.opendetails.toLocaleLowerCase() !== 'false'),
)
return {
/** Path of currently open directory */
directory,
@ -46,5 +51,8 @@ export function useRouteParameters() {
/** Should the active node should be opened (`openFile` route param) */
openFile,
/** Should the details sidebar be shown (`openDetails` route param) */
openDetails,
}
}

View file

@ -0,0 +1,77 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ActiveStore } from '../types.ts'
import type { FileAction, Node, View } from '@nextcloud/files'
import { defineStore } from 'pinia'
import { getNavigation } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import logger from '../logger.ts'
import type { set } from 'lodash'
export const useActiveStore = function(...args) {
const store = defineStore('active', {
state: () => ({
_initialized: false,
activeNode: null,
activeView: null,
activeAction: null,
} as ActiveStore),
actions: {
setActiveNode(node: Node) {
if (!node) {
throw new Error('Use clearActiveNode to clear the active node')
}
logger.debug('Setting active node', { node })
this.activeNode = node
},
clearActiveNode() {
this.activeNode = null
},
onDeletedNode(node: Node) {
if (this.activeNode && this.activeNode.source === node.source) {
this.clearActiveNode()
}
},
setActiveAction(action: FileAction) {
this.activeAction = action
},
clearActiveAction() {
this.activeAction = null
},
onChangedView(view: View|null = null) {
logger.debug('Setting active view', { view })
this.activeView = view
this.clearActiveNode()
},
},
})
const activeStore = store(...args)
const navigation = getNavigation()
// Make sure we only register the listeners once
if (!activeStore._initialized) {
subscribe('files:node:deleted', activeStore.onDeletedNode)
activeStore._initialized = true
activeStore.onChangedView(navigation.active)
// Or you can react to changes of the current active view
navigation.addEventListener('updateActive', (event) => {
activeStore.onChangedView(event.detail)
})
}
return activeStore
}

View file

@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { DragAndDropStore, FileSource } from '../types'
import { defineStore } from 'pinia'
import Vue from 'vue'
import type { DragAndDropStore, FileSource } from '../types'
export const useDragAndDropStore = defineStore('dragging', {
state: () => ({

View file

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Node } from '@nextcloud/files'
import type { FileAction, Folder, Node, View } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
// Global definitions
@ -95,6 +95,14 @@ export interface DragAndDropStore {
dragging: FileSource[]
}
// Active node store
export interface ActiveStore {
_initialized: boolean
activeNode: Node|null
activeView: View|null
activeAction: FileAction|null
}
export interface TemplateFile {
app: string
label: string