summaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-01-13 17:32:57 +0100
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-06 14:49:29 +0200
commit29a7f7f6efd2a9791fdcfb9f9f7e862bafd8da82 (patch)
tree720d2c59461777dd8a4a4d57d06738ce55066f22 /apps/files/src
parent8eb95052945c478a71d910090c7b1105f9256a4e (diff)
downloadnextcloud-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.vue58
-rw-r--r--apps/files/src/components/FileEntry.vue134
-rw-r--r--apps/files/src/components/FilesListHeader.vue122
-rw-r--r--apps/files/src/components/FilesListVirtual.vue124
-rw-r--r--apps/files/src/main.js15
-rw-r--r--apps/files/src/mixins/fileslist-row.scss63
-rw-r--r--apps/files/src/services/Navigation.ts31
-rw-r--r--apps/files/src/store/files.ts97
-rw-r--r--apps/files/src/store/index.ts16
-rw-r--r--apps/files/src/store/paths.ts71
-rw-r--r--apps/files/src/store/selection.ts51
-rw-r--r--apps/files/src/types.ts56
-rw-r--r--apps/files/src/views/FilesList.vue318
-rw-r--r--apps/files/src/views/Navigation.vue33
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)