From a9c145b85be6c63876e339556c7e93b9809a7e64 Mon Sep 17 00:00:00 2001 From: Christopher Ng Date: Wed, 24 Jul 2024 19:06:31 -0700 Subject: [PATCH] feat: Navigate via folder tree Signed-off-by: Christopher Ng --- apps/files/src/actions/openFolderAction.ts | 19 +- apps/files/src/components/BreadCrumbs.vue | 47 ++++- .../components/FileEntry/FileEntryName.vue | 6 +- .../src/components/FilesNavigationItem.vue | 170 ++++++++++++++++++ apps/files/src/composables/useNavigation.ts | 4 +- apps/files/src/eventbus.d.ts | 3 + apps/files/src/init.ts | 2 + apps/files/src/newMenu/newFolder.ts | 1 + apps/files/src/services/FolderTree.ts | 77 ++++++++ apps/files/src/store/folderTree.ts | 100 +++++++++++ apps/files/src/views/Navigation.vue | 126 ++++--------- apps/files/src/views/folderTree.ts | 111 ++++++++++++ 12 files changed, 564 insertions(+), 102 deletions(-) create mode 100644 apps/files/src/components/FilesNavigationItem.vue create mode 100644 apps/files/src/services/FolderTree.ts create mode 100644 apps/files/src/store/folderTree.ts create mode 100644 apps/files/src/views/folderTree.ts diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts index 8719f7a93fb04..0c0b1ca2000ad 100644 --- a/apps/files/src/actions/openFolderAction.ts +++ b/apps/files/src/actions/openFolderAction.ts @@ -2,10 +2,12 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Permission, Node, FileType, View, FileAction, DefaultType } from '@nextcloud/files' +import { Permission, Node, FileType, View, FileAction, DefaultType, Folder } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import { folderTreeId, getFolderTreeViewId } from '../services/FolderTree.ts' + export const action = new FileAction({ id: 'open-folder', displayName(files: Node[]) { @@ -36,8 +38,21 @@ export const action = new FileAction({ return false } + if (view.id === folderTreeId || view.params?.isFolderTreeChild === 'true') { // Navigate into folder tree views directly + if (!(node instanceof Folder)) { + return + } + const viewId = getFolderTreeViewId(node) + window.OCP.Files.Router.goToRoute( + 'filelist', + { view: viewId, fileid: String(node.fileid) }, + { dir: node.path }, + ) + return null + } + window.OCP.Files.Router.goToRoute( - null, + 'filelist', { view: view.id, fileid: String(node.fileid) }, { dir: node.path }, ) diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index 169bc504ab90a..47ed4ec34d7d8 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -42,16 +42,19 @@ import { defineComponent } from 'vue' import { Permission } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import HomeSvg from '@mdi/svg/svg/home.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw' import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import { useNavigation } from '../composables/useNavigation' import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import { folderTreeId, getFolderTreeViewId } from '../services/FolderTree.js' import { showError } from '@nextcloud/dialogs' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' +import { useFolderTreeStore } from '../store/folderTree.ts' import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' @@ -81,6 +84,7 @@ export default defineComponent({ const draggingStore = useDragAndDropStore() const filesStore = useFilesStore() const pathsStore = usePathsStore() + const folderTreeStore = useFolderTreeStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() const { currentView } = useNavigation() @@ -89,6 +93,7 @@ export default defineComponent({ draggingStore, filesStore, pathsStore, + folderTreeStore, selectionStore, uploaderStore, @@ -109,18 +114,21 @@ export default defineComponent({ return this.dirs.map((dir: string, index: number) => { const source = this.getFileSourceFromPath(dir) const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined - const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } } return { dir, exact: true, name: this.getDirDisplayName(dir), - to, + to: this.getTo(dir, node), // disable drop on current directory disableDrop: index === this.dirs.length - 1, } }) }, + isInFolderTree() { + return this.currentView?.id === folderTreeId || this.currentView?.params?.isFolderTreeChild === 'true' + }, + isUploadInProgress(): boolean { return this.uploaderStore.queue.length !== 0 }, @@ -134,6 +142,9 @@ export default defineComponent({ // used to show the views icon for the first breadcrumb viewIcon(): string { + if (this.isInFolderTree) { + return FolderMultipleSvg + } return this.currentView?.icon ?? HomeSvg }, @@ -151,10 +162,13 @@ export default defineComponent({ return this.filesStore.getNode(source) }, getFileSourceFromPath(path: string): FileSource | null { - return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? this.folderTreeStore.getPath(path) }, getDirDisplayName(path: string): string { if (path === '/') { + if (this.isInFolderTree) { + return t('files', 'All folders') + } return this.$navigation?.active?.name || t('files', 'Home') } @@ -163,6 +177,33 @@ export default defineComponent({ return node?.displayname || basename(path) }, + getTo(dir: string, node?: Node): Record { + if (this.isInFolderTree && dir === '/') { + return { + name: 'filelist', + params: { view: folderTreeId }, + } + } + if (node === undefined) { + return { + ...this.$route, + query: { dir }, + } + } + if (this.isInFolderTree) { + return { + name: 'filelist', + params: { view: getFolderTreeViewId(node), fileid: String(node.fileid) }, + query: { dir: node.path }, + } + } + return { + ...this.$route, + params: { fileid: node.fileid }, + query: { dir: node.path }, + } + }, + onClick(to) { if (to?.query?.dir === this.$route.query.dir) { this.$emit('reload') diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index 875c0892a726c..656297d8bc3ae 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -305,8 +305,10 @@ export default defineComponent({ }) // Success 🎉 - emit('files:node:updated', this.source) - emit('files:node:renamed', this.source) + const node = this.source + node.attributes['old-name'] = oldName + emit('files:node:updated', node) + emit('files:node:renamed', node) showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) // Reset the renaming store diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue new file mode 100644 index 0000000000000..7550780395778 --- /dev/null +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts index f410aec895fa8..2fff5633e230e 100644 --- a/apps/files/src/composables/useNavigation.ts +++ b/apps/files/src/composables/useNavigation.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { View } from '@nextcloud/files' +import type { ShallowRef } from 'vue' import { getNavigation } from '@nextcloud/files' -import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue' +import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue' /** * Composable to get the currently active files view from the files navigation @@ -28,6 +29,7 @@ export function useNavigation() { */ function onUpdateViews() { views.value = navigation.views + triggerRef(views) } onMounted(() => { diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index 8d57d82c034dc..b1ff00e5285eb 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -10,6 +10,9 @@ declare module '@nextcloud/event-bus' { 'files:favorites:removed': Node 'files:favorites:added': Node 'files:node:renamed': Node + 'files:node:created': Node + 'files:node:deleted': Node + 'files:node:updated': Node } } diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 25bcc1072f0f4..9e55aa2ee4e2a 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -22,6 +22,7 @@ import registerFavoritesView from './views/favorites' import registerRecentView from './views/recent' import registerPersonalFilesView from './views/personal-files' import registerFilesView from './views/files' +import { registerFolderTreeView } from './views/folderTree.ts' import registerPreviewServiceWorker from './services/ServiceWorker.js' import { initLivePhotos } from './services/LivePhotos' @@ -48,6 +49,7 @@ registerFavoritesView() registerFilesView() registerRecentView() registerPersonalFilesView() +registerFolderTreeView() // Register preview service worker registerPreviewServiceWorker() diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts index a570fa71c6101..54fd689e10a9f 100644 --- a/apps/files/src/newMenu/newFolder.ts +++ b/apps/files/src/newMenu/newFolder.ts @@ -62,6 +62,7 @@ export const entry = { 'mount-type': context.attributes?.['mount-type'], 'owner-id': context.attributes?.['owner-id'], 'owner-display-name': context.attributes?.['owner-display-name'], + parentid: context.fileid, }, }) diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts new file mode 100644 index 0000000000000..ec8ccea887499 --- /dev/null +++ b/apps/files/src/services/FolderTree.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ContentsWithRoot, Permission } from '@nextcloud/files' + +import { CancelablePromise } from 'cancelable-promise' +import { + davRemoteURL, + davRootPath, + Folder, + registerDavProperty, +} from '@nextcloud/files' +import { getCurrentUser } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' + +import { getContents as getFiles } from './Files.ts' + +registerDavProperty('nc:parentid', { nc: 'http://nextcloud.org/ns' }) + +export const folderTreeId = 'folders' + +interface FolderTreeFolder { + fileid: number, + displayname: string, + owner: string, + path: string, + parentid: number, + mountType: string, + mime: string, + size: number, + mtime: number, + crtime: number, + permissions: Permission, +} + +export const getFolders = (): Folder[] => { + return loadState('files', 'folderTreeFolders', []) + .map(folder => new Folder({ + id: folder.fileid, + displayname: folder.displayname, + owner: folder.owner, + source: `${davRemoteURL}/files/${getCurrentUser()?.uid}${folder.path}`, + attributes: { + parentid: folder.parentid, + 'mount-type': folder.mountType, + }, + mime: folder.mime, + size: folder.size, + mtime: new Date(folder.mtime * 1000), + crtime: new Date(folder.crtime * 1000), + permissions: folder.permissions, + root: davRootPath, + })) +} + +export const getContents = (path: string): CancelablePromise => getFiles(path) + +export const getFolderTreeViewId = (folder: Folder): string => { + const mountType = folder.attributes['mount-type'] + if (mountType !== '') { // If not local mount + return `${folderTreeId}-${mountType}-${folder.fileid}` // Include mount type as fileids may conflict across storage mounts + } + return `${folderTreeId}-${folder.fileid}` +} + +export const getFolderTreeParentId = (folder: Folder): string => { + if (folder.dirname === '/') { + return folderTreeId + } + const mountType = folder.attributes['mount-type'] + if (mountType !== '') { // If not local mount + return `${folderTreeId}-${mountType}-${folder.attributes.parentid}` // Include mount type as fileids may conflict across storage mounts + } + return `${folderTreeId}-${folder.attributes.parentid}` +} diff --git a/apps/files/src/store/folderTree.ts b/apps/files/src/store/folderTree.ts new file mode 100644 index 0000000000000..d68b6282a3063 --- /dev/null +++ b/apps/files/src/store/folderTree.ts @@ -0,0 +1,100 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { FileSource } from '../types' + +import Vue from 'vue' +import { defineStore } from 'pinia' +import { Folder, Node } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' + +import { useFilesStore } from './files.ts' +import { getFolders } from '../services/FolderTree.ts' + +interface PathSource { + [path: string]: FileSource, +} + +interface FolderTreeStore { + paths: PathSource, +} + +export const useFolderTreeStore = function(...args) { + const filesStore = useFilesStore(...args) + + const store = defineStore('folderTree', { + state: () => ({ + paths: {} as PathSource, + } as FolderTreeStore), + + getters: { + getPath: (state) => { + return (path: string): null | FileSource => { + if (!state.paths[path]) { + return null + } + return state.paths[path] + } + }, + }, + + actions: { + updatePaths(folders: Folder[]) { + for (const folder of folders) { + Vue.set(this.paths, folder.path, folder.source) + } + }, + + addPath(folder: Folder) { + Vue.set(this.paths, folder.path, folder.source) + }, + + removePath(folder: Folder) { + Vue.delete(this.paths, folder.path) + }, + + updatePath(folder: Folder) { + Vue.set(this.paths, folder.path, folder.source) + }, + + onCreateNode(node: Node) { + if (!(node instanceof Folder)) { + return + } + this.addPath(node) + }, + + onDeleteNode(node: Node) { + if (!(node instanceof Folder)) { + return + } + this.removePath(node) + }, + + onUpdateNode(node: Node) { + if (!(node instanceof Folder)) { + return + } + this.updatePath(node) + }, + }, + }) + + const folderTreeStore = store(...args) + // Run initialization once + if (!folderTreeStore._initialized) { + subscribe('files:node:created', folderTreeStore.onCreateNode) + subscribe('files:node:deleted', folderTreeStore.onDeleteNode) + subscribe('files:node:updated', folderTreeStore.onUpdateNode) + + const folders = getFolders() + filesStore.updateNodes(folders) + folderTreeStore.updatePaths(folders) + + folderTreeStore._initialized = true + } + + return folderTreeStore +} diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index b69c6d5f7f2b1..696d2382a8d32 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -4,34 +4,10 @@ -->