diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-01-13 17:32:57 +0100 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:29 +0200 |
commit | 29a7f7f6efd2a9791fdcfb9f9f7e862bafd8da82 (patch) | |
tree | 720d2c59461777dd8a4a4d57d06738ce55066f22 /apps/files/src | |
parent | 8eb95052945c478a71d910090c7b1105f9256a4e (diff) | |
download | nextcloud-server-29a7f7f6efd2a9791fdcfb9f9f7e862bafd8da82.tar.gz nextcloud-server-29a7f7f6efd2a9791fdcfb9f9f7e862bafd8da82.zip |
feat(files_trashbin): migrate to vue
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/components/BreadCrumbs.vue | 58 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 134 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 122 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 124 | ||||
-rw-r--r-- | apps/files/src/main.js | 15 | ||||
-rw-r--r-- | apps/files/src/mixins/fileslist-row.scss | 63 | ||||
-rw-r--r-- | apps/files/src/services/Navigation.ts | 31 | ||||
-rw-r--r-- | apps/files/src/store/files.ts | 97 | ||||
-rw-r--r-- | apps/files/src/store/index.ts | 16 | ||||
-rw-r--r-- | apps/files/src/store/paths.ts | 71 | ||||
-rw-r--r-- | apps/files/src/store/selection.ts | 51 | ||||
-rw-r--r-- | apps/files/src/types.ts | 56 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 318 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 33 |
14 files changed, 1176 insertions, 13 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue new file mode 100644 index 00000000000..15fd35667ec --- /dev/null +++ b/apps/files/src/components/BreadCrumbs.vue @@ -0,0 +1,58 @@ +<template> + <NcBreadcrumbs data-cy-files-content-breadcrumbs> + <!-- Current path sections --> + <NcBreadcrumb v-for="section in sections" + :key="section.dir" + :aria-label="t('files', `Go to the '{dir}' directory`, section)" + v-bind="section" /> + </NcBreadcrumbs> +</template> + +<script> +import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' +import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' +import { basename } from 'path' + +export default { + name: 'BreadCrumbs', + + components: { + NcBreadcrumbs, + NcBreadcrumb, + }, + + props: { + path: { + type: String, + default: '/', + }, + }, + + computed: { + dirs() { + const cumulativePath = (acc) => (value) => (acc += `${value}/`) + return ['/', ...this.path.split('/').filter(Boolean).map(cumulativePath('/'))] + }, + + sections() { + return this.dirs.map(dir => { + const to = { ...this.$route, query: { dir } } + return { + dir, + to, + title: basename(dir), + } + }) + }, + }, +} +</script> + +<style lang="scss" scoped> +.breadcrumb { + // Take as much space as possible + flex: 1 1 100% !important; + width: 100%; +} + +</style> diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue new file mode 100644 index 00000000000..de340917b69 --- /dev/null +++ b/apps/files/src/components/FileEntry.vue @@ -0,0 +1,134 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <Fragment> + <td class="files-list__row-checkbox"> + <NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })" + :checked.sync="selectedFiles" + :value="fileid.toString()" + name="selectedFiles" /> + </td> + + <!-- Icon or preview --> + <td class="files-list__row-icon"> + <FolderIcon v-if="source.type === 'folder'" /> + </td> + + <!-- Link to file and --> + <td class="files-list__row-name"> + <a v-bind="linkTo"> + {{ displayName }} + </a> + </td> + </Fragment> +</template> + +<script lang="ts"> +import { Folder, File } from '@nextcloud/files' +import { Fragment } from 'vue-fragment' +import { join } from 'path' +import { translate } from '@nextcloud/l10n' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import FolderIcon from 'vue-material-design-icons/Folder.vue' + +import logger from '../logger' + +export default { + name: 'FileEntry', + + components: { + FolderIcon, + Fragment, + NcCheckboxRadioSwitch, + }, + + props: { + index: { + type: Number, + required: true, + }, + source: { + type: [File, Folder], + required: true, + }, + }, + + computed: { + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + fileid() { + return this.source.attributes.fileid + }, + displayName() { + return this.source.attributes.displayName + || this.source.basename + }, + + linkTo() { + if (this.source.type === 'folder') { + const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } } + return { + is: 'router-link', + title: this.t('files', 'Open folder {name}', { name: this.displayName }), + to, + } + } + return { + href: this.source.source, + // TODO: Use first action title ? + title: this.t('files', 'Download file {name}', { name: this.displayName }), + } + }, + + selectedFiles: { + get() { + return this.$store.state.selection.selected + }, + set(selection) { + logger.debug('Added node to selection', { selection }) + this.$store.dispatch('selection/set', selection) + }, + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param {number} fileId the file id to get + * @return {Folder|File} + */ + getNode(fileId) { + return this.$store.getters['files/getNode'](fileId) + }, + + t: translate, + }, +} +</script> + +<style scoped lang="scss"> +@import '../mixins/fileslist-row.scss' +</style> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue new file mode 100644 index 00000000000..588d86709da --- /dev/null +++ b/apps/files/src/components/FilesListHeader.vue @@ -0,0 +1,122 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <tr> + <th class="files-list__row-checkbox"> + <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" /> + </th> + + <!-- Icon or preview --> + <th class="files-list__row-icon" /> + + <!-- Link to file and --> + <th class="files-list__row-name"> + {{ t('files', 'Name') }} + </th> + </tr> +</template> + +<script lang="ts"> +import { translate } from '@nextcloud/l10n' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' + +import logger from '../logger' +import { File, Folder } from '@nextcloud/files' + +export default { + name: 'FilesListHeader', + + components: { + NcCheckboxRadioSwitch, + }, + + props: { + nodes: { + type: [File, Folder], + required: true, + }, + }, + + computed: { + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + selectAllBind() { + return { + ariaLabel: this.isNoneSelected || this.isSomeSelected + ? this.t('files', 'Select all') + : this.t('files', 'Unselect all'), + checked: this.isAllSelected, + indeterminate: this.isSomeSelected, + } + }, + + isAllSelected() { + return this.selectedFiles.length === this.nodes.length + }, + + isNoneSelected() { + return this.selectedFiles.length === 0 + }, + + isSomeSelected() { + return !this.isAllSelected && !this.isNoneSelected + }, + + selectedFiles() { + return this.$store.state.selection.selected + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param {number} fileId the file id to get + * @return {Folder|File} + */ + getNode(fileId) { + return this.$store.getters['files/getNode'](fileId) + }, + + onToggleAll(selected) { + if (selected) { + const selection = this.nodes.map(node => node.attributes.fileid.toString()) + logger.debug('Added all nodes to selection', { selection }) + this.$store.dispatch('selection/set', selection) + } else { + logger.debug('Cleared selection') + this.$store.dispatch('selection/reset') + } + }, + + t: translate, + }, +} +</script> + +<style scoped lang="scss"> +@import '../mixins/fileslist-row.scss' + +</style> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue new file mode 100644 index 00000000000..9228179a96c --- /dev/null +++ b/apps/files/src/components/FilesListVirtual.vue @@ -0,0 +1,124 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <VirtualList class="files-list" + :data-component="FileEntry" + :data-key="getFileId" + :data-sources="nodes" + :estimate-size="55" + :table-mode="true" + item-class="files-list__row" + wrap-class="files-list__body"> + <template #before> + <caption v-show="false" class="files-list__caption"> + {{ summary }} + </caption> + </template> + + <template #header> + <FilesListHeader :nodes="nodes" /> + </template> + </VirtualList> +</template> + +<script lang="ts"> +import { Folder, File } from '@nextcloud/files' +import { translate, translatePlural } from '@nextcloud/l10n' +import VirtualList from 'vue-virtual-scroll-list' + +import FileEntry from './FileEntry.vue' +import FilesListHeader from './FilesListHeader.vue' + +export default { + name: 'FilesListVirtual', + + components: { + VirtualList, + FilesListHeader, + }, + + props: { + nodes: { + type: [File, Folder], + required: true, + }, + }, + + data() { + return { + FileEntry, + } + }, + + computed: { + files() { + return this.nodes.filter(node => node.type === 'file') + }, + + summaryFile() { + const count = this.files.length + return translatePlural('files', '{count} file', '{count} files', count, { count }) + }, + summaryFolder() { + const count = this.nodes.length - this.files.length + return translatePlural('files', '{count} folder', '{count} folders', count, { count }) + }, + summary() { + return translate('files', '{summaryFile} and {summaryFolder}', this) + }, + }, + + methods: { + getFileId(node) { + return node.attributes.fileid + }, + + t: translate, + }, +} +</script> + +<style scoped lang="scss"> +.files-list { + --row-height: 55px; + --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); + --checkbox-size: 24px; + --clickable-area: 44px; + --icon-preview-size: 32px; + + display: block; + overflow: auto; + height: 100%; + + &::v-deep { + tbody, thead, tfoot { + display: flex; + flex-direction: column; + width: 100%; + } + + thead, .files-list__row { + border-bottom: 1px solid var(--color-border); + } + } +} +</style> diff --git a/apps/files/src/main.js b/apps/files/src/main.js index 3099a4c619c..3d1c88755f0 100644 --- a/apps/files/src/main.js +++ b/apps/files/src/main.js @@ -4,12 +4,15 @@ import processLegacyFilesViews from './legacy/navigationMapper.js' import Vue from 'vue' import NavigationService from './services/Navigation.ts' + import NavigationView from './views/Navigation.vue' +import FilesListView from './views/FilesList.vue' import SettingsService from './services/Settings.js' import SettingsModel from './models/Setting.js' import router from './router/router.js' +import store from './store/index.ts' // Init private and public Files namespace window.OCA.Files = window.OCA.Files ?? {} @@ -35,5 +38,17 @@ const FilesNavigationRoot = new View({ }) FilesNavigationRoot.$mount('#app-navigation-files') +// Init content list view +const ListView = Vue.extend(FilesListView) +const FilesList = new ListView({ + name: 'FilesListRoot', + propsData: { + Navigation, + }, + router, + store, +}) +FilesList.$mount('#app-content-vue') + // Init legacy files views processLegacyFilesViews() diff --git a/apps/files/src/mixins/fileslist-row.scss b/apps/files/src/mixins/fileslist-row.scss new file mode 100644 index 00000000000..9b0c3197b76 --- /dev/null +++ b/apps/files/src/mixins/fileslist-row.scss @@ -0,0 +1,63 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +td, th { + height: var(--row-height); + vertical-align: middle; + padding: 0px; + border: none; +} + +.files-list__row-checkbox { + width: var(--row-height); + &::v-deep .checkbox-radio-switch { + --icon-size: var(--checkbox-size); + + display: flex; + justify-content: center; + + label.checkbox-radio-switch__label { + margin: 0; + height: var(--clickable-area); + width: var(--clickable-area); + padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2) + } + + .checkbox-radio-switch__icon { + margin: 0 !important; + } + } +} + +.files-list__row-icon { + // Remove left padding to look nicer with the checkbox + // => ico preview size + one checkbox td padding + width: calc(var(--icon-preview-size) + var(--checkbox-padding)); + padding-right: var(--checkbox-padding); + color: var(--color-primary-element); + & > span { + justify-content: flex-start; + } + &::v-deep svg { + width: var(--icon-preview-size); + height: var(--icon-preview-size); + } +} diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index 9efed538825..01b6e701c72 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -19,19 +19,27 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ -import type Node from '@nextcloud/files/dist/files/node' +/* eslint-disable */ +import type { Folder, Node } from '@nextcloud/files' import isSvg from 'is-svg' import logger from '../logger.js' +export type ContentsWithRoot = { + folder: Folder, + contents: Node[] +} + export interface Column { /** Unique column ID */ id: string /** Translated column title */ title: string - /** Property key from Node main or additional attributes. - Will be used if no custom sort function is provided. - Sorting will be done by localCompare */ + /** + * Property key from Node main or additional attributes. + * Will be used if no custom sort function is provided. + * Sorting will be done by localCompare + */ property: string /** Special function used to sort Nodes between them */ sortFunction?: (nodeA: Node, nodeB: Node) => number; @@ -45,8 +53,15 @@ export interface Navigation { id: string /** Translated view name */ name: string - /** Method return the content of the provided path */ - getFiles: (path: string) => Node[] + /** + * Method return the content of the provided path + * This ideally should be a cancellable promise. + * promise.cancel(reason) will be called when the directory + * change and the promise is not resolved yet. + * You _must_ also return the current directory + * information alongside with its content. + */ + getContents: (path: string) => Promise<ContentsWithRoot[]> /** The view icon as an inline svg */ icon: string /** The view order */ @@ -150,8 +165,8 @@ const isValidNavigation = function(view: Navigation): boolean { * TODO: remove when support for legacy views is removed */ if (!view.legacy) { - if (!view.getFiles || typeof view.getFiles !== 'function') { - throw new Error('Navigation getFiles is required and must be a function') + if (!view.getContents || typeof view.getContents !== 'function') { + throw new Error('Navigation getContents is required and must be a function') } if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts new file mode 100644 index 00000000000..e9760e2bc85 --- /dev/null +++ b/apps/files/src/store/files.ts @@ -0,0 +1,97 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable */ +import type { Folder, Node } from '@nextcloud/files' +import Vue from 'vue' +import type { FileStore, RootStore, RootOptions, Service } from '../types' + +const state = { + files: {} as FileStore, + roots: {} as RootStore, +} + +const getters = { + /** + * Get a file or folder by id + */ + getNode: (state) => (id: number): Node|undefined => state.files[id], + + /** + * Get a list of files or folders by their IDs + * Does not return undefined values + */ + getNodes: (state) => (ids: number[]): Node[] => ids + .map(id => state.files[id]) + .filter(Boolean), + /** + * Get a file or folder by id + */ + getRoot: (state) => (service: Service): Folder|undefined => state.roots[service], +} + +const mutations = { + updateNodes: (state, nodes: Node[]) => { + nodes.forEach(node => { + if (!node.attributes.fileid) { + return + } + Vue.set(state.files, node.attributes.fileid, node) + // state.files = { + // ...state.files, + // [node.attributes.fileid]: node, + // } + }) + }, + + setRoot: (state, { service, root }: RootOptions) => { + state.roots = { + ...state.roots, + [service]: root, + } + } +} + +const actions = { + /** + * Insert valid nodes into the store. + * Roots (that does _not_ have a fileid) should + * be defined in the roots store + */ + addNodes: (context, nodes: Node[]) => { + context.commit('updateNodes', nodes) + }, + + /** + * Set the root of a service + */ + setRoot(context, { service, root }: RootOptions) { + context.commit('setRoot', { service, root }) + } +} + +export default { + namespaced: true, + state, + getters, + mutations, + actions, +} diff --git a/apps/files/src/store/index.ts b/apps/files/src/store/index.ts new file mode 100644 index 00000000000..52007fef892 --- /dev/null +++ b/apps/files/src/store/index.ts @@ -0,0 +1,16 @@ +import Vue from 'vue' +import Vuex, { Store } from 'vuex' + +import files from './files' +import paths from './paths' +import selection from './selection' + +Vue.use(Vuex) + +export default new Store({ + modules: { + files, + paths, + selection, + }, +}) diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts new file mode 100644 index 00000000000..d6b23578da7 --- /dev/null +++ b/apps/files/src/store/paths.ts @@ -0,0 +1,71 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable */ +import type { Folder } from '@nextcloud/files' +import Vue from 'vue' +import type { PathOptions, ServicePaths, ServiceStore } from '../types' + +const module = { + state: { + services: { + files: {} as ServicePaths, + } as ServiceStore, + }, + + getters: { + getPath(state: { services: ServiceStore }) { + return (service: string, path: string): number|undefined => { + if (!state.services[service]) { + return undefined + } + return state.services[service][path] + } + }, + }, + + mutations: { + addPath: (state, opts: PathOptions) => { + // If it doesn't exists, init the service state + if (!state.services[opts.service]) { + // TODO: investigate why Vue.set is not working + state.services = { + [opts.service]: {} as ServicePaths, + ...state.services + } + } + + // Now we can set the path + Vue.set(state.services[opts.service], opts.path, opts.fileid) + } + }, + + actions: { + addPath: (context, opts: PathOptions) => { + context.commit('addPath', opts) + }, + } +} + +export default { + namespaced: true, + ...module, +} diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts new file mode 100644 index 00000000000..3ec61848c98 --- /dev/null +++ b/apps/files/src/store/selection.ts @@ -0,0 +1,51 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable */ +import type { Folder } from '@nextcloud/files' +import Vue from 'vue' +import type { PathOptions, ServicePaths, ServiceStore } from '../types' + +const module = { + state: { + selected: [] as number[] + }, + + mutations: { + set: (state, selection: number[]) => { + Vue.set(state, 'selected', selection) + } + }, + + actions: { + set: (context, selection = [] as number[]) => { + context.commit('set', selection) + }, + reset(context) { + context.commit('set', []) + } + } +} + +export default { + namespaced: true, + ...module, +} diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts new file mode 100644 index 00000000000..1c7068985d8 --- /dev/null +++ b/apps/files/src/types.ts @@ -0,0 +1,56 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable */ +import type { Folder } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' + +// Global definitions +export type Service = string + +// Files store +export type FileStore = { + [id: number]: Node +} + +export type RootStore = { + [service: Service]: Folder +} + +export interface RootOptions { + root: Folder + service: Service +} + +// Paths store +export type ServicePaths = { + [path: string]: number +} + +export type ServiceStore = { + [service: Service]: ServicePaths +} + +export interface PathOptions { + service: Service + path: string + fileid: number +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue new file mode 100644 index 00000000000..adc8a3bcb0f --- /dev/null +++ b/apps/files/src/views/FilesList.vue @@ -0,0 +1,318 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <NcAppContent v-show="!currentView?.legacy" + :class="{'app-content--hidden': currentView?.legacy}" + data-cy-files-content> + <div class="files-list__header"> + <!-- Current folder breadcrumbs --> + <BreadCrumbs :path="dir" /> + + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + </div> + + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :title="t('files', 'Loading current folder')" /> + + <!-- Empty content placeholder --> + <NcEmptyContent v-else-if="!loading && isEmptyDir" + :title="t('files', 'No files in here')" + :description="t('files', 'No files or folders have been deleted yet')" + data-cy-files-content-empty> + <template #action> + <NcButton v-if="dir !== '/'" + aria-label="t('files', 'Go to the previous folder')" + type="primary" + :to="toPreviousDir"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <TrashCan /> + </template> + </NcEmptyContent> + + <!-- File list --> + <FilesListVirtual v-else :nodes="dirContents" /> + </NcAppContent> +</template> + +<script lang="ts"> +import { Folder } from '@nextcloud/files' +import { translate } from '@nextcloud/l10n' +import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import TrashCan from 'vue-material-design-icons/TrashCan.vue' + +import BreadCrumbs from '../components/BreadCrumbs.vue' +import logger from '../logger.js' +import Navigation from '../services/Navigation' +import FilesListVirtual from '../components/FilesListVirtual.vue' +import { ContentsWithRoot } from '../services/Navigation' +import { join } from 'path' + +export default { + name: 'FilesList', + + components: { + BreadCrumbs, + FilesListVirtual, + NcAppContent, + NcButton, + NcEmptyContent, + NcLoadingIcon, + TrashCan, + }, + + props: { + // eslint-disable-next-line vue/prop-name-casing + Navigation: { + type: Navigation, + required: true, + }, + }, + + data() { + return { + loading: true, + promise: null, + } + }, + + computed: { + currentViewId() { + return this.$route.params.view || 'files' + }, + + /** @return {Navigation} */ + currentView() { + return this.views.find(view => view.id === this.currentViewId) + }, + + /** @return {Navigation[]} */ + views() { + return this.Navigation.views + }, + + /** + * The current directory query. + * @return {string} + */ + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + /** + * The current folder. + * @return {Folder|undefined} + */ + currentFolder() { + if (this.dir === '/') { + return this.$store.getters['files/getRoot'](this.currentViewId) + } + const fileId = this.$store.getters['paths/getPath'](this.currentViewId, this.dir) + return this.$store.getters['files/getNode'](fileId) + }, + + /** + * The current directory contents. + * @return {Node[]} + */ + dirContents() { + return (this.currentFolder?.children || []).map(this.getNode) + }, + + /** + * The current directory is empty. + */ + isEmptyDir() { + return this.dirContents.length === 0 + }, + + /** + * We are refreshing the current directory. + * But we already have a cached version of it + * that is not empty. + */ + isRefreshing() { + return this.currentFolder !== undefined + && !this.isEmptyDir + && this.loading + }, + + /** + * Route to the previous directory. + */ + toPreviousDir() { + const dir = this.dir.split('/').slice(0, -1).join('/') || '/' + return { ...this.$route, query: { dir } } + }, + }, + + watch: { + currentView(newView, oldView) { + if (newView?.id === oldView?.id) { + return + } + + logger.debug('View changed', { newView, oldView }) + this.$store.dispatch('selection/reset') + this.fetchContent() + }, + + dir(newDir, oldDir) { + logger.debug('Directory changed', { newDir, oldDir }) + // TODO: preserve selection on browsing? + this.$store.dispatch('selection/reset') + this.fetchContent() + }, + + paths(paths) { + logger.debug('Paths changed', { paths }) + }, + + currentFolder(currentFolder) { + logger.debug('currentFolder changed', { currentFolder }) + }, + }, + + methods: { + async fetchContent() { + if (this.currentView?.legacy) { + return + } + + this.loading = true + const dir = this.dir + const currentView = this.currentView + + // If we have a cancellable promise ongoing, cancel it + if (typeof this.promise?.cancel === 'function') { + this.promise.cancel() + logger.debug('Cancelled previous ongoing fetch') + } + + // Fetch the current dir contents + /** @type {Promise<ContentsWithRoot>} */ + this.promise = currentView.getContents(dir) + try { + const { folder, contents } = await this.promise + logger.debug('Fetched contents', { dir, folder, contents }) + + // Update store + this.$store.dispatch('files/addNodes', contents) + + // Define current directory children + folder.children = contents.map(node => node.attributes.fileid) + + // If we're in the root dir, define the root + if (dir === '/') { + console.debug('files', 'Setting root', { service: currentView.id, folder }) + this.$store.dispatch('files/setRoot', { service: currentView.id, root: folder }) + } else + // Otherwise, add the folder to the store + if (folder.attributes.fileid) { + this.$store.dispatch('files/addNodes', [folder]) + this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: folder.attributes.fileid, path: dir }) + } else { + // If we're here, the view API messed up + logger.error('Invalid root folder returned', { dir, folder, currentView }) + } + + // Update paths store + const folders = contents.filter(node => node.type === 'folder') + folders.forEach(node => { + this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: node.attributes.fileid, path: join(dir, node.basename) }) + }) + } catch (error) { + logger.error('Error while fetching content', { error }) + } finally { + this.loading = false + } + + }, + + /** + * Get a cached note from the store + * + * @param {number} fileId the file id to get + * @return {Folder|File} + */ + getNode(fileId) { + return this.$store.getters['files/getNode'](fileId) + }, + + t: translate, + }, +} +</script> + +<style scoped lang="scss"> +.app-content { + // Virtual list needs to be full height and is scrollable + display: flex; + overflow: hidden; + flex-direction: column; + max-height: 100%; + + // TODO: remove after all legacy views are migrated + // Hides the legacy app-content if shown view is not legacy + &:not(&--hidden)::v-deep + #app-content { + display: none; + } +} + +$margin: 4px; +$navigationToggleSize: 50px; + +.files-list { + &__header { + display: flex; + align-content: center; + // Do not grow or shrink (vertically) + flex: 0 0; + // Align with the navigation toggle icon + margin: $margin $margin $margin $navigationToggleSize; + > * { + // Do not grow or shrink (horizontally) + // Only the breadcrumbs shrinks + flex: 0 0; + } + } + &__refresh-icon { + flex: 0 0 44px; + width: 44px; + height: 44px; + } + &__loading-icon { + margin: auto; + } +} + +</style> diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index d9fdfa7fe02..9a2e82d1bc6 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -32,13 +32,20 @@ :title="view.name" :to="generateToNavigation(view)" @update:open="onToggleExpand(view)"> + <!-- Sanitized icon as svg if provided --> + <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> + + <!-- Child views if any --> <NcAppNavigationItem v-for="child in childViews[view.id]" :key="child.id" :data-cy-files-navigation-item="child.id" :exact="true" :icon="child.iconClass" :title="child.name" - :to="generateToNavigation(child)" /> + :to="generateToNavigation(child)"> + <!-- Sanitized icon as svg if provided --> + <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> + </NcAppNavigationItem> </NcAppNavigationItem> </template> @@ -74,6 +81,7 @@ import axios from '@nextcloud/axios' import Cog from 'vue-material-design-icons/Cog.vue' import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import logger from '../logger.js' import Navigation from '../services/Navigation.ts' @@ -86,10 +94,11 @@ export default { components: { Cog, + NavigationQuota, NcAppNavigation, NcAppNavigationItem, + NcIconSvgWrapper, SettingsModal, - NavigationQuota, }, props: { @@ -151,7 +160,16 @@ export default { watch: { currentView(view, oldView) { - logger.debug('View changed', { id: view.id, view }) + // If undefined, it means we're initializing the view + // This is handled by the legacy-view:initialized event + if (view?.id === oldView?.id) { + return + } + + this.Navigation.setActive(view.id) + logger.debug('Navigation changed', { id: view.id, view }) + + // debugger this.showView(view, oldView) }, }, @@ -163,6 +181,12 @@ export default { } subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged) + + // TODO: remove this once the legacy navigation is gone + subscribe('files:legacy-view:initialized', () => { + logger.debug('Legacy view initialized', { ...this.currentView }) + this.showView(this.currentView) + }) }, methods: { @@ -174,7 +198,7 @@ export default { // Closing any opened sidebar window?.OCA?.Files?.Sidebar?.close?.() - if (view.legacy) { + if (view?.legacy) { const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer') document.querySelectorAll('#app-content .viewcontainer').forEach(el => { el.classList.add('hidden') @@ -188,7 +212,6 @@ export default { logger.debug('Triggering legacy navigation event', params) window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params)) window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params)) - } this.Navigation.setActive(view) |