mirror of
https://github.com/nextcloud/server.git
synced 2026-04-26 08:38:11 -04:00
feat(files): add opendetails param and file list up/down keyboard shortcut
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
parent
f16d047808
commit
e7001022c7
7 changed files with 234 additions and 49 deletions
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
apps/files/src/store/active.ts
Normal file
77
apps/files/src/store/active.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue