aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/actions/deleteAction.ts65
-rw-r--r--apps/files/src/components/BreadCrumbs.vue122
-rw-r--r--apps/files/src/components/CustomElementRender.vue65
-rw-r--r--apps/files/src/components/CustomSvgIconRender.vue68
-rw-r--r--apps/files/src/components/FileEntry.vue575
-rw-r--r--apps/files/src/components/FilesListFooter.vue167
-rw-r--r--apps/files/src/components/FilesListHeader.vue228
-rw-r--r--apps/files/src/components/FilesListHeaderActions.vue215
-rw-r--r--apps/files/src/components/FilesListHeaderButton.vue145
-rw-r--r--apps/files/src/components/FilesListVirtual.vue338
-rw-r--r--apps/files/src/components/NavigationQuota.vue13
-rw-r--r--apps/files/src/components/Setting.vue2
-rw-r--r--apps/files/src/main.js25
-rw-r--r--apps/files/src/mixins/filesListWidth.ts43
-rw-r--r--apps/files/src/models/Setting.js2
-rw-r--r--apps/files/src/services/FileAction.ts184
-rw-r--r--apps/files/src/services/Navigation.ts55
-rw-r--r--apps/files/src/services/PreviewService.ts37
-rw-r--r--apps/files/src/services/ServiceWorker.js40
-rw-r--r--apps/files/src/services/Settings.js2
-rw-r--r--apps/files/src/store/actionsmenu.ts30
-rw-r--r--apps/files/src/store/files.ts103
-rw-r--r--apps/files/src/store/keyboard.ts64
-rw-r--r--apps/files/src/store/paths.ts70
-rw-r--r--apps/files/src/store/selection.ts60
-rw-r--r--apps/files/src/store/sorting.ts80
-rw-r--r--apps/files/src/store/userconfig.ts75
-rw-r--r--apps/files/src/types.ts94
-rw-r--r--apps/files/src/views/FilesList.vue360
-rw-r--r--apps/files/src/views/Navigation.cy.ts68
-rw-r--r--apps/files/src/views/Navigation.vue38
-rw-r--r--apps/files/src/views/Settings.vue38
32 files changed, 3402 insertions, 69 deletions
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
new file mode 100644
index 00000000000..087884b3362
--- /dev/null
+++ b/apps/files/src/actions/deleteAction.ts
@@ -0,0 +1,65 @@
+/**
+ * @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/>.
+ *
+ */
+import { emit } from '@nextcloud/event-bus'
+import { Permission, Node } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import TrashCan from '@mdi/svg/svg/trash-can.svg?raw'
+
+import { registerFileAction, FileAction } from '../services/FileAction.ts'
+import logger from '../logger.js'
+
+registerFileAction(new FileAction({
+ id: 'delete',
+ displayName(nodes: Node[], view) {
+ return view.id === 'trashbin'
+ ? t('files_trashbin', 'Delete permanently')
+ : t('files', 'Delete')
+ },
+ iconSvgInline: () => TrashCan,
+
+ enabled(nodes: Node[]) {
+ return nodes.length > 0 && nodes
+ .map(node => node.permissions)
+ .every(permission => (permission & Permission.DELETE) !== 0)
+ },
+
+ async exec(node: Node) {
+ try {
+ await axios.delete(node.source)
+
+ // Let's delete even if it's moved to the trashbin
+ // since it has been removed from the current view
+ // and changing the view will trigger a reload anyway.
+ emit('files:node:deleted', node)
+ return true
+ } catch (error) {
+ logger.error('Error while deleting a file', { error, source: node.source, node })
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view) {
+ return Promise.all(nodes.map(node => this.exec(node, view)))
+ },
+
+ order: 100,
+}))
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
new file mode 100644
index 00000000000..c2938c5aca2
--- /dev/null
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -0,0 +1,122 @@
+<template>
+ <NcBreadcrumbs data-cy-files-content-breadcrumbs>
+ <!-- Current path sections -->
+ <NcBreadcrumb v-for="(section, index) in sections"
+ :key="section.dir"
+ :aria-label="ariaLabel(section)"
+ :title="ariaLabel(section)"
+ v-bind="section"
+ @click.native="onClick(section.to)">
+ <template v-if="index === 0" #icon>
+ <Home :size="20" />
+ </template>
+ </NcBreadcrumb>
+ </NcBreadcrumbs>
+</template>
+
+<script>
+import { basename } from 'path'
+import Home from 'vue-material-design-icons/Home.vue'
+import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
+import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
+import Vue from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+
+export default Vue.extend({
+ name: 'BreadCrumbs',
+
+ components: {
+ Home,
+ NcBreadcrumbs,
+ NcBreadcrumb,
+ },
+
+ props: {
+ path: {
+ type: String,
+ default: '/',
+ },
+ },
+
+ setup() {
+ const filesStore = useFilesStore()
+ const pathsStore = usePathsStore()
+ return {
+ filesStore,
+ pathsStore,
+ }
+ },
+
+ computed: {
+ currentView() {
+ return this.$navigation.active
+ },
+
+ dirs() {
+ const cumulativePath = (acc) => (value) => (acc += `${value}/`)
+ // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
+ const paths = this.path.split('/').filter(Boolean).map(cumulativePath('/'))
+ // Strip away trailing slash
+ return ['/', ...paths.map(path => path.replace(/^(.+)\/$/, '$1'))]
+ },
+
+ sections() {
+ return this.dirs.map(dir => {
+ const to = { ...this.$route, query: { dir } }
+ return {
+ dir,
+ exact: true,
+ name: this.getDirDisplayName(dir),
+ to,
+ }
+ })
+ },
+ },
+
+ methods: {
+ getNodeFromId(id) {
+ return this.filesStore.getNode(id)
+ },
+ getFileIdFromPath(path) {
+ return this.pathsStore.getPath(this.currentView?.id, path)
+ },
+ getDirDisplayName(path) {
+ if (path === '/') {
+ return t('files', 'Home')
+ }
+
+ const fileId = this.getFileIdFromPath(path)
+ const node = this.getNodeFromId(fileId)
+ return node?.attributes?.displayName || basename(path)
+ },
+
+ onClick(to) {
+ if (to?.query?.dir === this.$route.query.dir) {
+ this.$emit('reload')
+ }
+ },
+
+ ariaLabel(section) {
+ if (section?.to?.query?.dir === this.$route.query.dir) {
+ return t('files', 'Reload current directory')
+ }
+ return t('files', 'Go to the "{dir}" directory', section)
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.breadcrumb {
+ // Take as much space as possible
+ flex: 1 1 100% !important;
+ width: 100%;
+
+ ::v-deep a {
+ cursor: pointer !important;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue
new file mode 100644
index 00000000000..b5bcb8daf2c
--- /dev/null
+++ b/apps/files/src/components/CustomElementRender.vue
@@ -0,0 +1,65 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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>
+ <span />
+</template>
+
+<script>
+/**
+ * This component is used to render custom
+ * elements provided by an API. Vue doesn't allow
+ * to directly render an HTMLElement, so we can do
+ * this magic here.
+ */
+export default {
+ name: 'CustomElementRender',
+ props: {
+ source: {
+ type: Object,
+ required: true,
+ },
+ currentView: {
+ type: Object,
+ required: true,
+ },
+ render: {
+ type: Function,
+ required: true,
+ },
+ },
+ computed: {
+ element() {
+ return this.render(this.source, this.currentView)
+ },
+ },
+ watch: {
+ element() {
+ this.$el.replaceWith(this.element)
+ this.$el = this.element
+ },
+ },
+ mounted() {
+ this.$el.replaceWith(this.element)
+ this.$el = this.element
+ },
+}
+</script>
diff --git a/apps/files/src/components/CustomSvgIconRender.vue b/apps/files/src/components/CustomSvgIconRender.vue
new file mode 100644
index 00000000000..4edb51806d1
--- /dev/null
+++ b/apps/files/src/components/CustomSvgIconRender.vue
@@ -0,0 +1,68 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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>
+ <span class="custom-svg-icon" />
+</template>
+
+<script>
+// eslint-disable-next-line import/named
+import { sanitize } from 'dompurify'
+
+export default {
+ name: 'CustomSvgIconRender',
+ props: {
+ svg: {
+ type: String,
+ required: true,
+ },
+ },
+ watch: {
+ svg() {
+ this.$el.innerHTML = sanitize(this.svg)
+ },
+ },
+ mounted() {
+ this.$el.innerHTML = sanitize(this.svg)
+ },
+}
+</script>
+<style lang="scss" scoped>
+.custom-svg-icon {
+ display: flex;
+ align-items: center;
+ align-self: center;
+ justify-content: center;
+ justify-self: center;
+ width: 44px;
+ height: 44px;
+ opacity: 1;
+
+ ::v-deep svg {
+ // mdi icons have a size of 24px
+ // 22px results in roughly 16px inner size
+ height: 22px;
+ width: 22px;
+ fill: currentColor;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
new file mode 100644
index 00000000000..7db22482220
--- /dev/null
+++ b/apps/files/src/components/FileEntry.vue
@@ -0,0 +1,575 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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 v-if="active"
+ :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
+ :checked="selectedFiles"
+ :value="fileid"
+ name="selectedFiles"
+ @update:checked="onSelectionChange" />
+ </td>
+
+ <!-- Link to file -->
+ <td class="files-list__row-name">
+ <a ref="name" v-bind="linkTo">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon">
+ <FolderIcon v-if="source.type === 'folder'" />
+
+ <!-- Decorative image, should not be aria documented -->
+ <span v-else-if="previewUrl && !backgroundFailed"
+ ref="previewImg"
+ class="files-list__row-icon-preview"
+ :style="{ backgroundImage }" />
+
+ <span v-else-if="mimeIconUrl"
+ class="files-list__row-icon-preview files-list__row-icon-preview--mime"
+ :style="{ backgroundImage: mimeIconUrl }" />
+
+ <FileIcon v-else />
+ </span>
+
+ <!-- File name -->
+ <span class="files-list__row-name-text">{{ displayName }}</span>
+ </a>
+ </td>
+
+ <!-- Actions -->
+ <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
+ <!-- Inline actions -->
+ <!-- TODO: implement CustomElementRender -->
+
+ <!-- Menu actions -->
+ <NcActions v-if="active"
+ ref="actionsMenu"
+ :disabled="source._loading"
+ :force-title="true"
+ :inline="enabledInlineActions.length"
+ :open.sync="openedMenu">
+ <NcActionButton v-for="action in enabledMenuActions"
+ :key="action.id"
+ :class="'files-list__row-action-' + action.id"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ action.displayName([source], currentView) }}
+ </NcActionButton>
+ </NcActions>
+ </td>
+
+ <!-- Size -->
+ <td v-if="isSizeAvailable"
+ :style="{ opacity: sizeOpacity }"
+ class="files-list__row-size">
+ <span>{{ size }}</span>
+ </td>
+
+ <!-- View columns -->
+ <td v-for="column in columns"
+ :key="column.id"
+ :class="`files-list__row-${currentView?.id}-${column.id}`"
+ class="files-list__row-column-custom">
+ <CustomElementRender v-if="active"
+ :current-view="currentView"
+ :render="column.render"
+ :source="source" />
+ </td>
+ </Fragment>
+</template>
+
+<script lang='ts'>
+import { debounce } from 'debounce'
+import { formatFileSize } from '@nextcloud/files'
+import { Fragment } from 'vue-frag'
+import { join } from 'path'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+import CancelablePromise from 'cancelable-promise'
+import FileIcon from 'vue-material-design-icons/File.vue'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import Vue from 'vue'
+
+import { getFileActions } from '../services/FileAction.ts'
+import { isCachedPreview } from '../services/PreviewService.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useKeyboardStore } from '../store/keyboard.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import CustomElementRender from './CustomElementRender.vue'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
+import logger from '../logger.js'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default Vue.extend({
+ name: 'FileEntry',
+
+ components: {
+ CustomElementRender,
+ CustomSvgIconRender,
+ FileIcon,
+ FolderIcon,
+ Fragment,
+ NcActionButton,
+ NcActions,
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ },
+
+ props: {
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ source: {
+ type: Object,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ nodes: {
+ type: Array,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const filesStore = useFilesStore()
+ const keyboardStore = useKeyboardStore()
+ const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+ return {
+ actionsMenuStore,
+ filesStore,
+ keyboardStore,
+ selectionStore,
+ userConfigStore,
+ }
+ },
+
+ data() {
+ return {
+ backgroundFailed: false,
+ backgroundImage: '',
+ loading: '',
+ }
+ },
+
+ computed: {
+ userConfig() {
+ return this.userConfigStore.userConfig
+ },
+
+ currentView() {
+ return this.$navigation.active
+ },
+
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ dir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+ },
+
+ fileid() {
+ return this.source?.fileid?.toString?.()
+ },
+ displayName() {
+ return this.source.attributes.displayName
+ || this.source.basename
+ },
+ size() {
+ const size = parseInt(this.source.size, 10) || 0
+ if (typeof size !== 'number' || size < 0) {
+ return this.t('files', 'Pending')
+ }
+ return formatFileSize(size, true)
+ },
+
+ sizeOpacity() {
+ const size = parseInt(this.source.size, 10) || 0
+ if (!size || size < 0) {
+ return 1
+ }
+
+ // Whatever theme is active, the contrast will pass WCAG AA
+ // with color main text over main background and an opacity of 0.7
+ const minOpacity = 0.7
+ const maxOpacitySize = 10 * 1024 * 1024
+ return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
+ },
+
+ 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() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.source?.fileid?.toString?.())
+ },
+
+ cropPreviews() {
+ return this.userConfig.crop_image_previews
+ },
+
+ previewUrl() {
+ try {
+ const url = new URL(window.location.origin + this.source.attributes.previewUrl)
+ // Request tiny previews
+ url.searchParams.set('x', '32')
+ url.searchParams.set('y', '32')
+ // Handle cropping
+ url.searchParams.set('a', this.cropPreviews === true ? '1' : '0')
+ return url.href
+ } catch (e) {
+ return null
+ }
+ },
+
+ mimeIconUrl() {
+ const mimeType = this.source.mime || 'application/octet-stream'
+ const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
+ if (mimeIconUrl) {
+ return `url(${mimeIconUrl})`
+ }
+ return ''
+ },
+
+ enabledActions() {
+ return actions
+ .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ enabledInlineActions() {
+ if (this.filesListWidth < 768) {
+ return []
+ }
+ return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ },
+
+ enabledMenuActions() {
+ if (this.filesListWidth < 768) {
+ return this.enabledActions
+ }
+
+ return [
+ ...this.enabledInlineActions,
+ ...this.enabledActions.filter(action => !action.inline),
+ ]
+ },
+
+ uniqueId() {
+ return this.hashCode(this.source.source)
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? this.uniqueId : null
+ },
+ },
+ },
+
+ watch: {
+ active(active, before) {
+ if (active === false && before === true) {
+ this.resetState()
+
+ // When the row is not active anymore
+ // remove the display from the row to prevent
+ // keyboard interaction with it.
+ this.$el.parentNode.style.display = 'none'
+ return
+ }
+
+ // Restore default tabindex
+ this.$el.parentNode.style.display = ''
+ },
+
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ */
+ previewUrl() {
+ this.clearImg()
+ this.debounceIfNotCached()
+ },
+ },
+
+ /**
+ * The row is mounted once and reused as we scroll.
+ */
+ mounted() {
+ // ⚠ Init the debounce function on mount and
+ // not when the module is imported to
+ // avoid sharing between recycled components
+ this.debounceGetPreview = debounce(function() {
+ this.fetchAndApplyPreview()
+ }, 150, false)
+
+ // Fetch the preview on init
+ this.debounceIfNotCached()
+
+ // Right click watcher on tr
+ this.$el.parentNode?.addEventListener?.('contextmenu', this.onRightClick)
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ async debounceIfNotCached() {
+ if (!this.previewUrl) {
+ return
+ }
+
+ // Check if we already have this preview cached
+ const isCached = await isCachedPreview(this.previewUrl)
+ if (isCached) {
+ this.backgroundImage = `url(${this.previewUrl})`
+ this.backgroundFailed = false
+ return
+ }
+
+ // We don't have this preview cached or it expired, requesting it
+ this.debounceGetPreview()
+ },
+
+ fetchAndApplyPreview() {
+ // Ignore if no preview
+ if (!this.previewUrl) {
+ return
+ }
+
+ // If any image is being processed, reset it
+ if (this.previewPromise) {
+ this.clearImg()
+ }
+
+ // Store the promise to be able to cancel it
+ this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
+ const img = new Image()
+ // If active, load the preview with higher priority
+ img.fetchpriority = this.active ? 'high' : 'auto'
+ img.onload = () => {
+ this.backgroundImage = `url(${this.previewUrl})`
+ this.backgroundFailed = false
+ resolve(img)
+ }
+ img.onerror = () => {
+ this.backgroundFailed = true
+ reject(img)
+ }
+ img.src = this.previewUrl
+
+ // Image loading has been canceled
+ onCancel(() => {
+ img.onerror = null
+ img.onload = null
+ img.src = ''
+ })
+ })
+ },
+
+ resetState() {
+ // Reset loading state
+ this.loading = ''
+
+ // Reset the preview
+ this.clearImg()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ clearImg() {
+ this.backgroundImage = ''
+ this.backgroundFailed = false
+
+ if (this.previewPromise) {
+ this.previewPromise.cancel()
+ this.previewPromise = null
+ }
+ },
+
+ hashCode(str) {
+ let hash = 0
+ for (let i = 0, len = str.length; i < len; i++) {
+ const chr = str.charCodeAt(i)
+ hash = (hash << 5) - hash + chr
+ hash |= 0 // Convert to 32bit integer
+ }
+ return hash
+ },
+
+ async onActionClick(action) {
+ const displayName = action.displayName([this.source], this.currentView)
+ try {
+ // Set the loading marker
+ this.loading = action.id
+ Vue.set(this.source, '_loading', true)
+
+ const success = await action.exec(this.source, this.currentView)
+ if (success) {
+ showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
+ return
+ }
+ showError(this.t('files', '"{displayName}" action failed', { displayName }))
+ } catch (e) {
+ logger.error('Error while executing action', { action, e })
+ showError(this.t('files', '"{displayName}" action failed', { displayName }))
+ } finally {
+ // Reset the loading marker
+ this.loading = ''
+ Vue.set(this.source, '_loading', false)
+ }
+ },
+
+ onSelectionChange(selection) {
+ const newSelectedIndex = this.index
+ const lastSelectedIndex = this.selectionStore.lastSelectedIndex
+
+ // Get the last selected and select all files in between
+ if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
+ const isAlreadySelected = this.selectedFiles.includes(this.fileid)
+
+ const start = Math.min(newSelectedIndex, lastSelectedIndex)
+ const end = Math.max(lastSelectedIndex, newSelectedIndex)
+
+ const lastSelection = this.selectionStore.lastSelection
+ const filesToSelect = this.nodes
+ .map(file => file.fileid?.toString?.())
+ .slice(start, end + 1)
+
+ // If already selected, update the new selection _without_ the current file
+ const selection = [...lastSelection, ...filesToSelect]
+ .filter(fileId => !isAlreadySelected || fileId !== this.fileid)
+
+ logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
+ // Keep previous lastSelectedIndex to be use for further shift selections
+ this.selectionStore.set(selection)
+ return
+ }
+
+ logger.debug('Updating selection', { selection })
+ this.selectionStore.set(selection)
+ this.selectionStore.setLastIndex(newSelectedIndex)
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ t: translate,
+ formatFileSize,
+ },
+})
+</script>
+
+<style scoped lang='scss'>
+/* Hover effect on tbody lines only */
+tr {
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: var(--color-background-dark);
+ }
+}
+
+/* Preview not loaded animation effect */
+.files-list__row-icon-preview:not([style*='background']) {
+ background: var(--color-loading-dark);
+ // animation: preview-gradient-fade 1.2s ease-in-out infinite;
+}
+</style>
+
+<style>
+/* @keyframes preview-gradient-fade {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ 100% {
+ opacity: 1;
+ }
+} */
+</style>
diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue
new file mode 100644
index 00000000000..80047f404fc
--- /dev/null
+++ b/apps/files/src/components/FilesListFooter.vue
@@ -0,0 +1,167 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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">
+ <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
+ </th>
+
+ <!-- Link to file -->
+ <td class="files-list__row-name">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Summary -->
+ <span>{{ summary }}</span>
+ </td>
+
+ <!-- Actions -->
+ <td class="files-list__row-actions" />
+
+ <!-- Size -->
+ <td v-if="isSizeAvailable"
+ class="files-list__column files-list__row-size">
+ <span>{{ totalSize }}</span>
+ </td>
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)">
+ <span>{{ column.summary?.(nodes, currentView) }}</span>
+ </th>
+ </tr>
+</template>
+
+<script lang="ts">
+import { formatFileSize } from '@nextcloud/files'
+import { translate } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+
+export default Vue.extend({
+ name: 'FilesListFooter',
+
+ components: {
+ },
+
+ props: {
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array,
+ required: true,
+ },
+ summary: {
+ type: String,
+ default: '',
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const pathsStore = usePathsStore()
+ const filesStore = useFilesStore()
+ return {
+ filesStore,
+ pathsStore,
+ }
+ },
+
+ computed: {
+ currentView() {
+ return this.$navigation.active
+ },
+
+ dir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+ },
+
+ currentFolder() {
+ if (!this.currentView?.id) {
+ return
+ }
+
+ if (this.dir === '/') {
+ return this.filesStore.getRoot(this.currentView.id)
+ }
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
+ return this.filesStore.getNode(fileId)
+ },
+
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ totalSize() {
+ // If we have the size already, let's use it
+ if (this.currentFolder?.size) {
+ return formatFileSize(this.currentFolder.size, true)
+ }
+
+ // Otherwise let's compute it
+ return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true)
+ },
+ },
+
+ methods: {
+ classForColumn(column) {
+ return {
+ 'files-list__row-column-custom': true,
+ [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+// Scoped row
+tr {
+ padding-bottom: 300px;
+ border-top: 1px solid var(--color-border);
+ // Prevent hover effect on the whole row
+ background-color: transparent !important;
+ border-bottom: none !important;
+}
+
+td {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+}
+
+</style>
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
new file mode 100644
index 00000000000..2edfb4aa30e
--- /dev/null
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -0,0 +1,228 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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__column files-list__row-checkbox">
+ <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
+ </th>
+
+ <!-- Actions multiple if some are selected -->
+ <FilesListHeaderActions v-if="!isNoneSelected"
+ :current-view="currentView"
+ :selected-nodes="selectedNodes" />
+
+ <!-- Columns display -->
+ <template v-else>
+ <!-- Link to file -->
+ <th class="files-list__column files-list__row-name files-list__column--sortable"
+ @click.stop.prevent="toggleSortBy('basename')">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Name -->
+ <FilesListHeaderButton :name="t('files', 'Name')" mode="basename" />
+ </th>
+
+ <!-- Actions -->
+ <th class="files-list__row-actions" />
+
+ <!-- Size -->
+ <th v-if="isSizeAvailable"
+ :class="{'files-list__column--sortable': isSizeAvailable}"
+ class="files-list__column files-list__row-size">
+ <FilesListHeaderButton :name="t('files', 'Size')" mode="size" />
+ </th>
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)">
+ <FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
+ <span v-else>
+ {{ column.title }}
+ </span>
+ </th>
+ </template>
+ </tr>
+</template>
+
+<script lang="ts">
+import { mapState } from 'pinia'
+import { translate } from '@nextcloud/l10n'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import Vue from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useSortingStore } from '../store/sorting.ts'
+import FilesListHeaderActions from './FilesListHeaderActions.vue'
+import FilesListHeaderButton from './FilesListHeaderButton.vue'
+import logger from '../logger.js'
+
+export default Vue.extend({
+ name: 'FilesListHeader',
+
+ components: {
+ FilesListHeaderButton,
+ NcCheckboxRadioSwitch,
+ FilesListHeaderActions,
+ },
+
+ provide() {
+ return {
+ toggleSortBy: this.toggleSortBy,
+ }
+ },
+
+ props: {
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ const sortingStore = useSortingStore()
+ return {
+ filesStore,
+ selectionStore,
+ sortingStore,
+ }
+ },
+
+ computed: {
+ ...mapState(useSortingStore, ['filesSortingConfig']),
+
+ currentView() {
+ return this.$navigation.active
+ },
+
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ dir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+ },
+
+ selectAllBind() {
+ const label = this.isNoneSelected || this.isSomeSelected
+ ? this.t('files', 'Select all')
+ : this.t('files', 'Unselect all')
+ return {
+ 'aria-label': label,
+ checked: this.isAllSelected,
+ indeterminate: this.isSomeSelected,
+ title: label,
+ }
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isAllSelected() {
+ return this.selectedNodes.length === this.nodes.length
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isSomeSelected() {
+ return !this.isAllSelected && !this.isNoneSelected
+ },
+
+ sortingMode() {
+ return this.sortingStore.getSortingMode(this.currentView.id)
+ || this.currentView.defaultSortKey
+ || 'basename'
+ },
+ isAscSorting() {
+ return this.sortingStore.isAscSorting(this.currentView.id) === true
+ },
+ },
+
+ methods: {
+ classForColumn(column) {
+ return {
+ 'files-list__column': true,
+ 'files-list__column--sortable': !!column.sort,
+ 'files-list__row-column-custom': true,
+ [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ }
+ },
+
+ onToggleAll(selected) {
+ if (selected) {
+ const selection = this.nodes.map(node => node.attributes.fileid.toString())
+ logger.debug('Added all nodes to selection', { selection })
+ this.selectionStore.setLastIndex(null)
+ this.selectionStore.set(selection)
+ } else {
+ logger.debug('Cleared selection')
+ this.selectionStore.reset()
+ }
+ },
+
+ toggleSortBy(key) {
+ // If we're already sorting by this key, flip the direction
+ if (this.sortingMode === key) {
+ this.sortingStore.toggleSortingDirection(this.currentView.id)
+ return
+ }
+ // else sort ASC by this new key
+ this.sortingStore.setSortingBy(key, this.currentView.id)
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__column {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+
+ &--sortable {
+ cursor: pointer;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue
new file mode 100644
index 00000000000..c9f0c66be03
--- /dev/null
+++ b/apps/files/src/components/FilesListHeaderActions.vue
@@ -0,0 +1,215 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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>
+ <th class="files-list__column files-list__row-actions-batch" colspan="2">
+ <NcActions ref="actionsMenu"
+ :disabled="!!loading || areSomeNodesLoading"
+ :force-title="true"
+ :inline="inlineActions"
+ :menu-title="inlineActions <= 1 ? t('files', 'Actions') : null"
+ :open.sync="openedMenu">
+ <NcActionButton v-for="action in enabledActions"
+ :key="action.id"
+ :class="'files-list__row-actions-batch-' + action.id"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </template>
+ {{ action.displayName(nodes, currentView) }}
+ </NcActionButton>
+ </NcActions>
+ </th>
+</template>
+
+<script lang="ts">
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import Vue from 'vue'
+
+import { getFileActions } from '../services/FileAction.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
+import logger from '../logger.js'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default Vue.extend({
+ name: 'FilesListHeaderActions',
+
+ components: {
+ CustomSvgIconRender,
+ NcActions,
+ NcActionButton,
+ NcLoadingIcon,
+ },
+
+ mixins: [
+ filesListWidthMixin,
+ ],
+
+ props: {
+ currentView: {
+ type: Object,
+ required: true,
+ },
+ selectedNodes: {
+ type: Array,
+ default: () => ([]),
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ return {
+ actionsMenuStore,
+ filesStore,
+ selectionStore,
+ }
+ },
+
+ data() {
+ return {
+ loading: null,
+ }
+ },
+
+ computed: {
+ enabledActions() {
+ return actions
+ .filter(action => action.execBatch)
+ .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ nodes() {
+ return this.selectedNodes
+ .map(fileid => this.getNode(fileid))
+ .filter(node => node)
+ },
+
+ areSomeNodesLoading() {
+ return this.nodes.some(node => node._loading)
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === 'global'
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? 'global' : null
+ },
+ },
+
+ inlineActions() {
+ if (this.filesListWidth < 512) {
+ return 0
+ }
+ if (this.filesListWidth < 768) {
+ return 1
+ }
+ if (this.filesListWidth < 1024) {
+ return 2
+ }
+ return 3
+ },
+ },
+
+ methods: {
+ /**
+ * Get a cached note from the store
+ *
+ * @param {number} fileId the file id to get
+ * @return {Folder|File}
+ */
+ getNode(fileId) {
+ return this.filesStore.getNode(fileId)
+ },
+
+ async onActionClick(action) {
+ const displayName = action.displayName(this.nodes, this.currentView)
+ const selectionIds = this.selectedNodes
+ try {
+ // Set loading markers
+ this.loading = action.id
+ this.nodes.forEach(node => {
+ Vue.set(node, '_loading', true)
+ })
+
+ // Dispatch action execution
+ const results = await action.execBatch(this.nodes, this.currentView)
+
+ // Handle potential failures
+ if (results.some(result => result !== true)) {
+ // Remove the failed ids from the selection
+ const failedIds = selectionIds
+ .filter((fileid, index) => results[index] !== true)
+ this.selectionStore.set(failedIds)
+
+ showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
+ return
+ }
+
+ // Show success message and clear selection
+ showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
+ this.selectionStore.reset()
+ } catch (e) {
+ logger.error('Error while executing action', { action, e })
+ showError(this.t('files', '"{displayName}" action failed', { displayName }))
+ } finally {
+ // Remove loading markers
+ this.loading = null
+ this.nodes.forEach(node => {
+ Vue.set(node, '_loading', false)
+ })
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__row-actions-batch {
+ flex: 1 1 100% !important;
+
+ // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+ ::v-deep .button-vue__wrapper {
+ width: 100%;
+ span.button-vue__text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue
new file mode 100644
index 00000000000..afa48465dab
--- /dev/null
+++ b/apps/files/src/components/FilesListHeaderButton.vue
@@ -0,0 +1,145 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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>
+ <NcButton :aria-label="sortAriaLabel(name)"
+ :class="{'files-list__column-sort-button--active': sortingMode === mode}"
+ class="files-list__column-sort-button"
+ type="tertiary"
+ @click.stop.prevent="toggleSortBy(mode)">
+ <!-- Sort icon before text as size is align right -->
+ <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" />
+ <MenuDown v-else slot="icon" />
+ {{ name }}
+ </NcButton>
+</template>
+
+<script lang="ts">
+import { mapState } from 'pinia'
+import { translate } from '@nextcloud/l10n'
+import MenuDown from 'vue-material-design-icons/MenuDown.vue'
+import MenuUp from 'vue-material-design-icons/MenuUp.vue'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import Vue from 'vue'
+
+import { useSortingStore } from '../store/sorting.ts'
+
+export default Vue.extend({
+ name: 'FilesListHeaderButton',
+
+ components: {
+ MenuDown,
+ MenuUp,
+ NcButton,
+ },
+
+ inject: ['toggleSortBy'],
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ mode: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const sortingStore = useSortingStore()
+ return {
+ sortingStore,
+ }
+ },
+
+ computed: {
+ ...mapState(useSortingStore, ['filesSortingConfig']),
+
+ currentView() {
+ return this.$navigation.active
+ },
+
+ sortingMode() {
+ return this.sortingStore.getSortingMode(this.currentView.id)
+ || this.currentView.defaultSortKey
+ || 'basename'
+ },
+ isAscSorting() {
+ return this.sortingStore.isAscSorting(this.currentView.id) === true
+ },
+ },
+
+ methods: {
+ sortAriaLabel(column) {
+ const direction = this.isAscSorting
+ ? this.t('files', 'ascending')
+ : this.t('files', 'descending')
+ return this.t('files', 'Sort list by {column} ({direction})', {
+ column,
+ direction,
+ })
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style lang="scss">
+.files-list__column-sort-button {
+ // Compensate for cells margin
+ margin: 0 calc(var(--cell-margin) * -1);
+ // Reverse padding
+ padding: 0 4px 0 16px !important;
+
+ // Icon after text
+ .button-vue__wrapper {
+ flex-direction: row-reverse;
+ // Take max inner width for text overflow ellipsis
+ // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+ width: 100%;
+ }
+
+ .button-vue__icon {
+ transition-timing-function: linear;
+ transition-duration: .1s;
+ transition-property: opacity;
+ opacity: 0;
+ }
+
+ // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
+ .button-vue__text {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ &--active,
+ &:hover,
+ &:focus,
+ &:active {
+ .button-vue__icon {
+ opacity: 1 !important;
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
new file mode 100644
index 00000000000..ad0ba2069ff
--- /dev/null
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -0,0 +1,338 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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>
+ <RecycleScroller ref="recycleScroller"
+ class="files-list"
+ key-field="source"
+ :items="nodes"
+ :item-size="55"
+ :table-mode="true"
+ item-class="files-list__row"
+ item-tag="tr"
+ list-class="files-list__body"
+ list-tag="tbody"
+ role="table">
+ <template #default="{ item, active, index }">
+ <!-- File row -->
+ <FileEntry :active="active"
+ :index="index"
+ :is-size-available="isSizeAvailable"
+ :files-list-width="filesListWidth"
+ :nodes="nodes"
+ :source="item" />
+ </template>
+
+ <template #before>
+ <!-- Accessibility description -->
+ <caption class="hidden-visually">
+ {{ currentView.caption || '' }}
+ {{ t('files', 'This list is not fully rendered for performances reasons. The files will be rendered as you navigate through the list.') }}
+ </caption>
+
+ <!-- Thead-->
+ <FilesListHeader :files-list-width="filesListWidth"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes" />
+ </template>
+
+ <template #after>
+ <!-- Tfoot-->
+ <FilesListFooter :files-list-width="filesListWidth"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes"
+ :summary="summary" />
+ </template>
+ </RecycleScroller>
+</template>
+
+<script lang="ts">
+import { RecycleScroller } from 'vue-virtual-scroller'
+import { translate, translatePlural } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import FileEntry from './FileEntry.vue'
+import FilesListFooter from './FilesListFooter.vue'
+import FilesListHeader from './FilesListHeader.vue'
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
+
+export default Vue.extend({
+ name: 'FilesListVirtual',
+
+ components: {
+ RecycleScroller,
+ FileEntry,
+ FilesListHeader,
+ FilesListFooter,
+ },
+
+ mixins: [
+ filesListWidthMixin,
+ ],
+
+ props: {
+ currentView: {
+ type: Object,
+ required: true,
+ },
+ nodes: {
+ type: Array,
+ 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)
+ },
+ isSizeAvailable() {
+ // Hide size column on narrow screens
+ if (this.filesListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.attributes.size !== undefined)
+ },
+ },
+
+ mounted() {
+ // Make the root recycle scroller a table for proper semantics
+ const slots = this.$el.querySelectorAll('.vue-recycle-scroller__slot')
+ slots[0].setAttribute('role', 'thead')
+ slots[1].setAttribute('role', 'tfoot')
+ },
+
+ methods: {
+ getFileId(node) {
+ return node.attributes.fileid
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list {
+ --row-height: 55px;
+ --cell-margin: 14px;
+
+ --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 {
+ // Table head, body and footer
+ tbody, .vue-recycle-scroller__slot {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ // Necessary for virtual scrolling absolute
+ position: relative;
+ }
+
+ // Table header
+ .vue-recycle-scroller__slot[role='thead'] {
+ // Pinned on top when scrolling
+ position: sticky;
+ z-index: 10;
+ top: 0;
+ height: var(--row-height);
+ background-color: var(--color-main-background);
+ }
+
+ tr {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ td, th {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ justify-content: left;
+ width: var(--row-height);
+ height: var(--row-height);
+ margin: 0;
+ padding: 0;
+ color: var(--color-text-maxcontrast);
+ border: none;
+
+ // Columns should try to add any text
+ // node wrapped in a span. That should help
+ // with the ellipsis on overflow.
+ span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .files-list__row-checkbox {
+ justify-content: center;
+ .checkbox-radio-switch {
+ display: flex;
+ justify-content: center;
+
+ --icon-size: var(--checkbox-size);
+
+ label.checkbox-radio-switch__label {
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ margin: 0;
+ padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
+ }
+
+ .checkbox-radio-switch__icon {
+ margin: 0 !important;
+ }
+ }
+ }
+
+ .files-list__row-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--icon-preview-size);
+ height: 100%;
+ // Show same padding as the checkbox right padding for visual balance
+ margin-right: var(--checkbox-padding);
+ color: var(--color-primary-element);
+ // No shrinking or growing allowed
+ flex: 0 0 var(--icon-preview-size);
+
+ & > span {
+ justify-content: flex-start;
+ }
+
+ svg {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ &-preview {
+ overflow: hidden;
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ // Center and contain the preview
+ background-position: center;
+ background-size: contain;
+ }
+ }
+
+ .files-list__row-name {
+ // Prevent link from overflowing
+ overflow: hidden;
+ // Take as much space as possible
+ flex: 1 1 auto;
+
+ a {
+ display: flex;
+ align-items: center;
+ // Fill cell height and width
+ width: 100%;
+ height: 100%;
+
+ // Keyboard indicator a11y
+ &:focus .files-list__row-name-text,
+ &:focus-visible .files-list__row-name-text {
+ outline: 2px solid var(--color-main-text) !important;
+ border-radius: 20px;
+ }
+ }
+
+ .files-list__row-name-text {
+ // Make some space for the outline
+ padding: 5px 10px;
+ margin-left: -10px;
+ }
+ }
+
+ .files-list__row-actions {
+ width: auto;
+
+ // Add margin to all cells after the actions
+ & ~ td,
+ & ~ th {
+ margin: 0 var(--cell-margin);
+ }
+
+ button {
+ .button-vue__text {
+ // Remove bold from default button styling
+ font-weight: normal;
+ }
+ &:not(:hover, :focus, :active) .button-vue__wrapper {
+ // Also apply color-text-maxcontrast to non-active button
+ color: var(--color-text-maxcontrast);
+ }
+ }
+ }
+
+ .files-list__row-size {
+ // Right align text
+ justify-content: flex-end;
+ width: calc(var(--row-height) * 1.5);
+ // opacity varies with the size
+ color: var(--color-main-text);
+
+ // Icon is before text since size is right aligned
+ .files-list__column-sort-button {
+ padding: 0 16px 0 4px !important;
+ .button-vue__wrapper {
+ flex-direction: row;
+ }
+ }
+ }
+
+ .files-list__row-column-custom {
+ width: calc(var(--row-height) * 2);
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index bfcbaea3776..d38d4d2fd9e 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -80,15 +80,10 @@ export default {
*/
setInterval(this.throttleUpdateStorageStats, 60 * 1000)
- subscribe('files:file:created', this.throttleUpdateStorageStats)
- subscribe('files:file:deleted', this.throttleUpdateStorageStats)
- subscribe('files:file:moved', this.throttleUpdateStorageStats)
- subscribe('files:file:updated', this.throttleUpdateStorageStats)
-
- subscribe('files:folder:created', this.throttleUpdateStorageStats)
- subscribe('files:folder:deleted', this.throttleUpdateStorageStats)
- subscribe('files:folder:moved', this.throttleUpdateStorageStats)
- subscribe('files:folder:updated', this.throttleUpdateStorageStats)
+ subscribe('files:node:created', this.throttleUpdateStorageStats)
+ subscribe('files:node:deleted', this.throttleUpdateStorageStats)
+ subscribe('files:node:moved', this.throttleUpdateStorageStats)
+ subscribe('files:node:updated', this.throttleUpdateStorageStats)
},
methods: {
diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue
index c55a2841517..cb22dc3e477 100644
--- a/apps/files/src/components/Setting.vue
+++ b/apps/files/src/components/Setting.vue
@@ -1,7 +1,7 @@
<!--
- @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev>
-
- - @author Gary Kim <gary@garykim.dev>
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
diff --git a/apps/files/src/main.js b/apps/files/src/main.js
index 3099a4c619c..a8464f0ee0d 100644
--- a/apps/files/src/main.js
+++ b/apps/files/src/main.js
@@ -1,10 +1,17 @@
import './templates.js'
import './legacy/filelistSearch.js'
+import './actions/deleteAction.ts'
+
import processLegacyFilesViews from './legacy/navigationMapper.js'
import Vue from 'vue'
+import { createPinia, PiniaVuePlugin } from 'pinia'
+
import NavigationService from './services/Navigation.ts'
+import registerPreviewServiceWorker from './services/ServiceWorker.js'
+
import NavigationView from './views/Navigation.vue'
+import FilesListView from './views/FilesList.vue'
import SettingsService from './services/Settings.js'
import SettingsModel from './models/Setting.js'
@@ -15,9 +22,14 @@ import router from './router/router.js'
window.OCA.Files = window.OCA.Files ?? {}
window.OCP.Files = window.OCP.Files ?? {}
+// Init Pinia store
+Vue.use(PiniaVuePlugin)
+const pinia = createPinia()
+
// Init Navigation Service
const Navigation = new NavigationService()
Object.assign(window.OCP.Files, { Navigation })
+Vue.prototype.$navigation = Navigation
// Init Files App Settings Service
const Settings = new SettingsService()
@@ -32,8 +44,21 @@ const FilesNavigationRoot = new View({
Navigation,
},
router,
+ pinia,
})
FilesNavigationRoot.$mount('#app-navigation-files')
+// Init content list view
+const ListView = Vue.extend(FilesListView)
+const FilesList = new ListView({
+ name: 'FilesListRoot',
+ router,
+ pinia,
+})
+FilesList.$mount('#app-content-vue')
+
// Init legacy files views
processLegacyFilesViews()
+
+// Register preview service worker
+registerPreviewServiceWorker()
diff --git a/apps/files/src/mixins/filesListWidth.ts b/apps/files/src/mixins/filesListWidth.ts
new file mode 100644
index 00000000000..a2bb6b486bc
--- /dev/null
+++ b/apps/files/src/mixins/filesListWidth.ts
@@ -0,0 +1,43 @@
+/**
+ * @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/>.
+ *
+ */
+
+import Vue from 'vue'
+
+export default Vue.extend({
+ data() {
+ return {
+ filesListWidth: null as number | null,
+ }
+ },
+ created() {
+ const fileListEl = document.querySelector('#app-content-vue')
+ this.$resizeObserver = new ResizeObserver((entries) => {
+ if (entries.length > 0 && entries[0].target === fileListEl) {
+ this.filesListWidth = entries[0].contentRect.width
+ }
+ })
+ this.$resizeObserver.observe(fileListEl as Element)
+ },
+ beforeDestroy() {
+ this.$resizeObserver.disconnect()
+ },
+})
diff --git a/apps/files/src/models/Setting.js b/apps/files/src/models/Setting.js
index db276da85af..8387248d252 100644
--- a/apps/files/src/models/Setting.js
+++ b/apps/files/src/models/Setting.js
@@ -2,7 +2,7 @@
* @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
- * @author Gary Kim <gary@garykim.dev>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
diff --git a/apps/files/src/services/FileAction.ts b/apps/files/src/services/FileAction.ts
new file mode 100644
index 00000000000..8c1d325e645
--- /dev/null
+++ b/apps/files/src/services/FileAction.ts
@@ -0,0 +1,184 @@
+/**
+ * @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/>.
+ *
+ */
+
+import { Node } from '@nextcloud/files'
+import logger from '../logger'
+
+declare global {
+ interface Window {
+ OC: any;
+ _nc_fileactions: FileAction[] | undefined;
+ }
+}
+
+/**
+ * TODO: remove and move to @nextcloud/files
+ * @see https://github.com/nextcloud/nextcloud-files/pull/608
+ */
+interface FileActionData {
+ /** Unique ID */
+ id: string
+ /** Translatable string displayed in the menu */
+ displayName: (files: Node[], view) => string
+ /** Svg as inline string. <svg><path fill="..." /></svg> */
+ iconSvgInline: (files: Node[], view) => string
+ /** Condition wether this action is shown or not */
+ enabled?: (files: Node[], view) => boolean
+ /**
+ * Function executed on single file action
+ * @returns true if the action was executed, false otherwise
+ * @throws Error if the action failed
+ */
+ exec: (file: Node, view) => Promise<boolean>,
+ /**
+ * Function executed on multiple files action
+ * @returns true if the action was executed, false otherwise
+ * @throws Error if the action failed
+ */
+ execBatch?: (files: Node[], view) => Promise<boolean[]>
+ /** This action order in the list */
+ order?: number,
+ /** Make this action the default */
+ default?: boolean,
+ /**
+ * If true, the renderInline function will be called
+ */
+ inline?: (file: Node, view) => boolean,
+ /**
+ * If defined, the returned html element will be
+ * appended before the actions menu.
+ */
+ renderInline?: (file: Node, view) => HTMLElement,
+}
+
+export class FileAction {
+
+ private _action: FileActionData
+
+ constructor(action: FileActionData) {
+ this.validateAction(action)
+ this._action = action
+ }
+
+ get id() {
+ return this._action.id
+ }
+
+ get displayName() {
+ return this._action.displayName
+ }
+
+ get iconSvgInline() {
+ return this._action.iconSvgInline
+ }
+
+ get enabled() {
+ return this._action.enabled
+ }
+
+ get exec() {
+ return this._action.exec
+ }
+
+ get execBatch() {
+ return this._action.execBatch
+ }
+
+ get order() {
+ return this._action.order
+ }
+
+ get default() {
+ return this._action.default
+ }
+
+ get inline() {
+ return this._action.inline
+ }
+
+ get renderInline() {
+ return this._action.renderInline
+ }
+
+ private validateAction(action: FileActionData) {
+ if (!action.id || typeof action.id !== 'string') {
+ throw new Error('Invalid id')
+ }
+
+ if (!action.displayName || typeof action.displayName !== 'function') {
+ throw new Error('Invalid displayName function')
+ }
+
+ if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') {
+ throw new Error('Invalid iconSvgInline function')
+ }
+
+ if (!action.exec || typeof action.exec !== 'function') {
+ throw new Error('Invalid exec function')
+ }
+
+ // Optional properties --------------------------------------------
+ if ('enabled' in action && typeof action.enabled !== 'function') {
+ throw new Error('Invalid enabled function')
+ }
+
+ if ('execBatch' in action && typeof action.execBatch !== 'function') {
+ throw new Error('Invalid execBatch function')
+ }
+
+ if ('order' in action && typeof action.order !== 'number') {
+ throw new Error('Invalid order')
+ }
+
+ if ('default' in action && typeof action.default !== 'boolean') {
+ throw new Error('Invalid default')
+ }
+
+ if ('inline' in action && typeof action.inline !== 'function') {
+ throw new Error('Invalid inline function')
+ }
+
+ if ('renderInline' in action && typeof action.renderInline !== 'function') {
+ throw new Error('Invalid renderInline function')
+ }
+ }
+
+}
+
+export const registerFileAction = function(action: FileAction): void {
+ if (typeof window._nc_fileactions === 'undefined') {
+ window._nc_fileactions = []
+ logger.debug('FileActions initialized')
+ }
+
+ // Check duplicates
+ if (window._nc_fileactions.find(search => search.id === action.id)) {
+ logger.error(`FileAction ${action.id} already registered`, { action })
+ return
+ }
+
+ window._nc_fileactions.push(action)
+}
+
+export const getFileActions = function(): FileAction[] {
+ return window._nc_fileactions || []
+}
diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts
index 9efed538825..a39b04b642a 100644
--- a/apps/files/src/services/Navigation.ts
+++ b/apps/files/src/services/Navigation.ts
@@ -19,25 +19,29 @@
* 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: string
- /** Special function used to sort Nodes between them */
- sortFunction?: (nodeA: Node, nodeB: Node) => number;
+ /** The content of the cell. The element will be appended within */
+ render: (node: Node, view: Navigation) => HTMLElement
+ /** Function used to sort Nodes between them */
+ sort?: (nodeA: Node, nodeB: Node) => number
/** Custom summary of the column to display at the end of the list.
Will not be displayed if nothing is provided */
- summary?: (node: Node[]) => string
+ summary?: (node: Node[], view: Navigation) => string
}
export interface Navigation {
@@ -45,8 +49,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 */
@@ -64,6 +75,12 @@ export interface Navigation {
expanded?: boolean
/**
+ * Will be used as default if the user
+ * haven't customized their sorting column
+ * */
+ defaultSortKey?: string
+
+ /**
* This view is sticky a legacy view.
* Here until all the views are migrated to Vue.
* @deprecated It will be removed in a near future
@@ -150,8 +167,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)) {
@@ -184,6 +201,10 @@ const isValidNavigation = function(view: Navigation): boolean {
throw new Error('Navigation expanded must be a boolean')
}
+ if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') {
+ throw new Error('Navigation defaultSortKey must be a string')
+ }
+
return true
}
@@ -193,19 +214,19 @@ const isValidNavigation = function(view: Navigation): boolean {
*/
const isValidColumn = function(column: Column): boolean {
if (!column.id || typeof column.id !== 'string') {
- throw new Error('Column id is required')
+ throw new Error('A column id is required')
}
if (!column.title || typeof column.title !== 'string') {
- throw new Error('Column title is required')
+ throw new Error('A column title is required')
}
- if (!column.property || typeof column.property !== 'string') {
- throw new Error('Column property is required')
+ if (!column.render || typeof column.render !== 'function') {
+ throw new Error('A render function is required')
}
// Optional properties
- if (column.sortFunction && typeof column.sortFunction !== 'function') {
+ if (column.sort && typeof column.sort !== 'function') {
throw new Error('Column sortFunction must be a function')
}
diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts
new file mode 100644
index 00000000000..840d6a48afa
--- /dev/null
+++ b/apps/files/src/services/PreviewService.ts
@@ -0,0 +1,37 @@
+/**
+ * @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/>.
+ *
+ */
+
+// The preview service worker cache name (see webpack config)
+const SWCacheName = 'previews'
+
+/**
+ * Check if the preview is already cached by the service worker
+ */
+export const isCachedPreview = function(previewUrl: string) {
+ return caches.open(SWCacheName)
+ .then(function(cache) {
+ return cache.match(previewUrl)
+ .then(function(response) {
+ return !!response
+ })
+ })
+}
diff --git a/apps/files/src/services/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js
new file mode 100644
index 00000000000..b89d5af4040
--- /dev/null
+++ b/apps/files/src/services/ServiceWorker.js
@@ -0,0 +1,40 @@
+/**
+ * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+ *
+ * @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/>.
+ *
+ */
+import { generateUrl } from '@nextcloud/router'
+import logger from '../logger.js'
+
+export default () => {
+ if ('serviceWorker' in navigator) {
+ // Use the window load event to keep the page load performant
+ window.addEventListener('load', async () => {
+ try {
+ const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true })
+ const registration = await navigator.serviceWorker.register(url, { scope: '/' })
+ logger.debug('SW registered: ', { registration })
+ } catch (error) {
+ logger.error('SW registration failed: ', { error })
+ }
+ })
+ } else {
+ logger.debug('Service Worker is not enabled on this browser.')
+ }
+}
diff --git a/apps/files/src/services/Settings.js b/apps/files/src/services/Settings.js
index 83c2c850580..323a2499a78 100644
--- a/apps/files/src/services/Settings.js
+++ b/apps/files/src/services/Settings.js
@@ -1,7 +1,7 @@
/**
* @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
*
- * @author Gary Kim <gary@garykim.dev>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
diff --git a/apps/files/src/store/actionsmenu.ts b/apps/files/src/store/actionsmenu.ts
new file mode 100644
index 00000000000..66b1914ffbd
--- /dev/null
+++ b/apps/files/src/store/actionsmenu.ts
@@ -0,0 +1,30 @@
+/**
+ * @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 { defineStore } from 'pinia'
+import type { ActionsMenuStore } from '../types'
+
+export const useActionsMenuStore = defineStore('actionsmenu', {
+ state: () => ({
+ opened: null,
+ } as ActionsMenuStore),
+})
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
new file mode 100644
index 00000000000..11e4fc970a4
--- /dev/null
+++ b/apps/files/src/store/files.ts
@@ -0,0 +1,103 @@
+/**
+ * @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 type { FilesStore, RootsStore, RootOptions, Service, FilesState } from '../types.ts'
+
+import { defineStore } from 'pinia'
+import { subscribe } from '@nextcloud/event-bus'
+import Vue from 'vue'
+import logger from '../logger'
+import { FileId } from '../types'
+
+export const useFilesStore = () => {
+ const store = defineStore('files', {
+ state: (): FilesState => ({
+ files: {} as FilesStore,
+ roots: {} as RootsStore,
+ }),
+
+ getters: {
+ /**
+ * Get a file or folder by id
+ */
+ getNode: (state) => (id: FileId): Node|undefined => state.files[id],
+
+ /**
+ * Get a list of files or folders by their IDs
+ * Does not return undefined values
+ */
+ getNodes: (state) => (ids: FileId[]): 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],
+ },
+
+ actions: {
+ updateNodes(nodes: Node[]) {
+ // Update the store all at once
+ const files = nodes.reduce((acc, node) => {
+ if (!node.attributes.fileid) {
+ logger.warn('Trying to update/set a node without fileid', node)
+ return acc
+ }
+ acc[node.attributes.fileid] = node
+ return acc
+ }, {} as FilesStore)
+
+ Vue.set(this, 'files', {...this.files, ...files})
+ },
+
+ deleteNodes(nodes: Node[]) {
+ nodes.forEach(node => {
+ if (node.fileid) {
+ Vue.delete(this.files, node.fileid)
+ }
+ })
+ },
+
+ setRoot({ service, root }: RootOptions) {
+ Vue.set(this.roots, service, root)
+ },
+
+ onDeletedNode(node: Node) {
+ this.deleteNodes([node])
+ },
+ }
+ })
+
+ const fileStore = store()
+ // Make sure we only register the listeners once
+ if (!fileStore._initialized) {
+ // subscribe('files:node:created', fileStore.onCreatedNode)
+ subscribe('files:node:deleted', fileStore.onDeletedNode)
+ // subscribe('files:node:moved', fileStore.onMovedNode)
+ // subscribe('files:node:updated', fileStore.onUpdatedNode)
+
+ fileStore._initialized = true
+ }
+
+ return fileStore
+}
diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts
new file mode 100644
index 00000000000..1ba8285b960
--- /dev/null
+++ b/apps/files/src/store/keyboard.ts
@@ -0,0 +1,64 @@
+/**
+ * @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 { defineStore } from 'pinia'
+import Vue from 'vue'
+
+/**
+ * Observe various events and save the current
+ * special keys states. Useful for checking the
+ * current status of a key when executing a method.
+ */
+export const useKeyboardStore = () => {
+ const store = defineStore('keyboard', {
+ state: () => ({
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ }),
+
+ actions: {
+ onEvent(event: MouseEvent | KeyboardEvent) {
+ if (!event) {
+ event = window.event as MouseEvent | KeyboardEvent
+ }
+ Vue.set(this, 'altKey', !!event.altKey)
+ Vue.set(this, 'ctrlKey', !!event.ctrlKey)
+ Vue.set(this, 'metaKey', !!event.metaKey)
+ Vue.set(this, 'shiftKey', !!event.shiftKey)
+ },
+ }
+ })
+
+ const keyboardStore = store()
+ // Make sure we only register the listeners once
+ if (!keyboardStore._initialized) {
+ window.addEventListener('keydown', keyboardStore.onEvent)
+ window.addEventListener('keyup', keyboardStore.onEvent)
+ window.addEventListener('mousemove', keyboardStore.onEvent)
+
+ keyboardStore._initialized = true
+ }
+
+ return keyboardStore
+}
diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts
new file mode 100644
index 00000000000..bcd7375518c
--- /dev/null
+++ b/apps/files/src/store/paths.ts
@@ -0,0 +1,70 @@
+/**
+ * @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 { PathOptions, ServicesState } from '../types.ts'
+
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+import { subscribe } from '@nextcloud/event-bus'
+import { FileId } from '../types'
+
+export const usePathsStore = () => {
+ const store = defineStore('paths', {
+ state: (): ServicesState => ({}),
+
+ getters: {
+ getPath: (state) => {
+ return (service: string, path: string): FileId|undefined => {
+ if (!state[service]) {
+ return undefined
+ }
+ return state[service][path]
+ }
+ },
+ },
+
+ actions: {
+ addPath(payload: PathOptions) {
+ // If it doesn't exists, init the service state
+ if (!this[payload.service]) {
+ Vue.set(this, payload.service, {})
+ }
+
+ // Now we can set the provided path
+ Vue.set(this[payload.service], payload.path, payload.fileid)
+ },
+ }
+ })
+
+ const pathsStore = store()
+ // Make sure we only register the listeners once
+ if (!pathsStore._initialized) {
+ // TODO: watch folders to update paths?
+ // subscribe('files:node:created', pathsStore.onCreatedNode)
+ // subscribe('files:node:deleted', pathsStore.onDeletedNode)
+ // subscribe('files:node:moved', pathsStore.onMovedNode)
+
+ pathsStore._initialized = true
+ }
+
+ return pathsStore
+}
diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts
new file mode 100644
index 00000000000..0d67420e963
--- /dev/null
+++ b/apps/files/src/store/selection.ts
@@ -0,0 +1,60 @@
+/**
+ * @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 { defineStore } from 'pinia'
+import Vue from 'vue'
+import { FileId, SelectionStore } from '../types'
+
+export const useSelectionStore = defineStore('selection', {
+ state: () => ({
+ selected: [],
+ lastSelection: [],
+ lastSelectedIndex: null,
+ } as SelectionStore),
+
+ actions: {
+ /**
+ * Set the selection of fileIds
+ */
+ set(selection = [] as FileId[]) {
+ Vue.set(this, 'selected', selection)
+ },
+
+ /**
+ * Set the last selected index
+ */
+ setLastIndex(lastSelectedIndex = null as FileId | null) {
+ // Update the last selection if we provided a new selection starting point
+ Vue.set(this, 'lastSelection', lastSelectedIndex ? this.selected : [])
+ Vue.set(this, 'lastSelectedIndex', lastSelectedIndex)
+ },
+
+ /**
+ * Reset the selection
+ */
+ reset() {
+ Vue.set(this, 'selected', [])
+ Vue.set(this, 'lastSelection', [])
+ Vue.set(this, 'lastSelectedIndex', null)
+ }
+ }
+})
diff --git a/apps/files/src/store/sorting.ts b/apps/files/src/store/sorting.ts
new file mode 100644
index 00000000000..6afb6fa97b6
--- /dev/null
+++ b/apps/files/src/store/sorting.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+import axios from '@nextcloud/axios'
+import type { direction, SortingStore } from '../types.ts'
+
+const saveUserConfig = (mode: string, direction: direction, view: string) => {
+ return axios.post(generateUrl('/apps/files/api/v1/sorting'), {
+ mode,
+ direction,
+ view,
+ })
+}
+
+const filesSortingConfig = loadState('files', 'filesSortingConfig', {}) as SortingStore
+
+export const useSortingStore = defineStore('sorting', {
+ state: () => ({
+ filesSortingConfig,
+ }),
+
+ getters: {
+ isAscSorting: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.direction !== 'desc',
+ getSortingMode: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.mode,
+ },
+
+ actions: {
+ /**
+ * Set the sorting key AND sort by ASC
+ * The key param must be a valid key of a File object
+ * If not found, will be searched within the File attributes
+ */
+ setSortingBy(key: string = 'basename', view: string = 'files') {
+ const config = this.filesSortingConfig[view] || {}
+ config.mode = key
+ config.direction = 'asc'
+
+ // Save new config
+ Vue.set(this.filesSortingConfig, view, config)
+ saveUserConfig(config.mode, config.direction, view)
+ },
+
+ /**
+ * Toggle the sorting direction
+ */
+ toggleSortingDirection(view: string = 'files') {
+ const config = this.filesSortingConfig[view] || { 'direction': 'asc' }
+ const newDirection = config.direction === 'asc' ? 'desc' : 'asc'
+ config.direction = newDirection
+
+ // Save new config
+ Vue.set(this.filesSortingConfig, view, config)
+ saveUserConfig(config.mode, config.direction, view)
+ }
+ }
+})
+
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
new file mode 100644
index 00000000000..05d63c95424
--- /dev/null
+++ b/apps/files/src/store/userconfig.ts
@@ -0,0 +1,75 @@
+/**
+ * @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 { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+import axios from '@nextcloud/axios'
+import type { UserConfig, UserConfigStore } from '../types.ts'
+import { emit, subscribe } from '@nextcloud/event-bus'
+
+const userConfig = loadState('files', 'config', {
+ show_hidden: false,
+ crop_image_previews: true,
+}) as UserConfig
+
+export const useUserConfigStore = () => {
+ const store = defineStore('userconfig', {
+ state: () => ({
+ userConfig,
+ } as UserConfigStore),
+
+ actions: {
+ /**
+ * Update the user config local store
+ */
+ onUpdate(key: string, value: boolean) {
+ Vue.set(this.userConfig, key, value)
+ },
+
+ /**
+ * Update the user config local store AND on server side
+ */
+ async update(key: string, value: boolean) {
+ await axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
+ value,
+ })
+
+ emit('files:config:updated', { key, value })
+ }
+ }
+ })
+
+ const userConfigStore = store()
+
+ // Make sure we only register the listeners once
+ if (!userConfigStore._initialized) {
+ subscribe('files:config:updated', function({ key, value }: { key: string, value: boolean }) {
+ userConfigStore.onUpdate(key, value)
+ })
+ userConfigStore._initialized = true
+ }
+
+ return userConfigStore
+}
+
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
new file mode 100644
index 00000000000..2e8358aa704
--- /dev/null
+++ b/apps/files/src/types.ts
@@ -0,0 +1,94 @@
+/**
+ * @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
+export type FileId = number
+
+// Files store
+export type FilesState = {
+ files: FilesStore,
+ roots: RootsStore,
+}
+
+export type FilesStore = {
+ [fileid: FileId]: Node
+}
+
+export type RootsStore = {
+ [service: Service]: Folder
+}
+
+export interface RootOptions {
+ root: Folder
+ service: Service
+}
+
+// Paths store
+export type ServicesState = {
+ [service: Service]: PathsStore
+}
+
+export type PathsStore = {
+ [path: string]: number
+}
+
+export interface PathOptions {
+ service: Service
+ path: string
+ fileid: FileId
+}
+
+// Sorting store
+export type direction = 'asc' | 'desc'
+
+export interface SortingConfig {
+ mode: string
+ direction: direction
+}
+
+export interface SortingStore {
+ [key: string]: SortingConfig
+}
+
+// User config store
+export interface UserConfig {
+ [key: string]: boolean
+}
+export interface UserConfigStore {
+ userConfig: UserConfig
+}
+
+export interface SelectionStore {
+ selected: FileId[]
+ lastSelection: FileId[]
+ lastSelectedIndex: number | null
+}
+
+// Actions menu store
+export type GlobalActions = 'global'
+export interface ActionsMenuStore {
+ opened: GlobalActions|string|null
+}
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
new file mode 100644
index 00000000000..34006228f37
--- /dev/null
+++ b/apps/files/src/views/FilesList.vue
@@ -0,0 +1,360 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @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" @reload="fetchContent" />
+
+ <!-- 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
+ ref="filesListVirtual"
+ :current-view="currentView"
+ :nodes="dirContents" />
+ </NcAppContent>
+</template>
+
+<script lang="ts">
+import { Folder, File, Node } from '@nextcloud/files'
+import { join } from 'path'
+import { orderBy } from 'natural-orderby'
+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 Vue from 'vue'
+
+import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useSortingStore } from '../store/sorting.ts'
+import BreadCrumbs from '../components/BreadCrumbs.vue'
+import FilesListVirtual from '../components/FilesListVirtual.vue'
+import logger from '../logger.js'
+
+export default Vue.extend({
+ name: 'FilesList',
+
+ components: {
+ BreadCrumbs,
+ FilesListVirtual,
+ NcAppContent,
+ NcButton,
+ NcEmptyContent,
+ NcLoadingIcon,
+ TrashCan,
+ },
+
+ setup() {
+ const pathsStore = usePathsStore()
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ const sortingStore = useSortingStore()
+ return {
+ filesStore,
+ pathsStore,
+ selectionStore,
+ sortingStore,
+ }
+ },
+
+ data() {
+ return {
+ loading: true,
+ promise: null,
+ }
+ },
+
+ computed: {
+ /** @return {Navigation} */
+ currentView() {
+ return this.$navigation.active
+ || this.$navigation.views.find(view => view.id === 'files')
+ },
+
+ /**
+ * 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.currentView?.id) {
+ return
+ }
+
+ if (this.dir === '/') {
+ return this.filesStore.getRoot(this.currentView.id)
+ }
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
+ return this.filesStore.getNode(fileId)
+ },
+
+ sortingMode() {
+ return this.sortingStore.getSortingMode(this.currentView.id)
+ || this.currentView.defaultSortKey
+ || 'basename'
+ },
+ isAscSorting() {
+ return this.sortingStore.isAscSorting(this.currentView.id) === true
+ },
+
+ /**
+ * The current directory contents.
+ *
+ * @return {Node[]}
+ */
+ dirContents() {
+ if (!this.currentView) {
+ return []
+ }
+
+ const customColumn = this.currentView.columns
+ .find(column => column.id === this.sortingMode)
+
+ // Custom column must provide their own sorting methods
+ if (customColumn?.sort && typeof customColumn.sort === 'function') {
+ const results = [...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)]
+ .sort(customColumn.sort)
+ return this.isAscSorting ? results : results.reverse()
+ }
+
+ return orderBy(
+ [...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)],
+ [
+ // Sort folders first if sorting by name
+ ...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
+ // Use sorting mode
+ v => v[this.sortingMode],
+ // Fallback to name
+ v => v.basename,
+ ],
+ this.isAscSorting ? ['asc', 'asc', 'asc'] : ['desc', 'desc', 'desc'],
+ )
+ },
+
+ /**
+ * 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.selectionStore.reset()
+ this.fetchContent()
+ },
+
+ dir(newDir, oldDir) {
+ logger.debug('Directory changed', { newDir, oldDir })
+ // TODO: preserve selection on browsing?
+ this.selectionStore.reset()
+ this.fetchContent()
+
+ // Scroll to top, force virtual scroller to re-render
+ if (this.$refs?.filesListVirtual?.$el) {
+ this.$refs.filesListVirtual.$el.scrollTop = 0
+ }
+ },
+ },
+
+ 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.filesStore.updateNodes(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 === '/') {
+ this.filesStore.setRoot({ service: currentView.id, root: folder })
+ } else
+ // Otherwise, add the folder to the store
+ if (folder.attributes.fileid) {
+ this.filesStore.updateNodes([folder])
+ this.pathsStore.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.pathsStore.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.filesStore.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.cy.ts b/apps/files/src/views/Navigation.cy.ts
index c8b0f07dea1..3d5307e6800 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -2,21 +2,21 @@ import * as InitialState from '@nextcloud/initial-state'
import * as L10n from '@nextcloud/l10n'
import FolderSvg from '@mdi/svg/svg/folder.svg'
import ShareSvg from '@mdi/svg/svg/share-variant.svg'
+import { createTestingPinia } from '@pinia/testing'
-import NavigationService from '../services/Navigation'
+import NavigationService from '../services/Navigation.ts'
import NavigationView from './Navigation.vue'
import router from '../router/router.js'
describe('Navigation renders', () => {
- const Navigation = new NavigationService()
+ const Navigation = new NavigationService() as NavigationService
before(() => {
cy.stub(InitialState, 'loadState')
.returns({
- used: 1024 * 1024 * 1024,
+ used: 1000 * 1000 * 1000,
quota: -1,
})
-
})
it('renders', () => {
@@ -24,6 +24,11 @@ describe('Navigation renders', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -33,13 +38,13 @@ describe('Navigation renders', () => {
})
describe('Navigation API', () => {
- const Navigation = new NavigationService()
+ const Navigation = new NavigationService() as NavigationService
it('Check API entries rendering', () => {
Navigation.register({
id: 'files',
name: 'Files',
- getFiles: () => [],
+ getContents: () => Promise.resolve(),
icon: FolderSvg,
order: 1,
})
@@ -48,6 +53,11 @@ describe('Navigation API', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
router,
})
@@ -61,7 +71,7 @@ describe('Navigation API', () => {
Navigation.register({
id: 'sharing',
name: 'Sharing',
- getFiles: () => [],
+ getContents: () => Promise.resolve(),
icon: ShareSvg,
order: 2,
})
@@ -70,6 +80,11 @@ describe('Navigation API', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
router,
})
@@ -83,7 +98,7 @@ describe('Navigation API', () => {
Navigation.register({
id: 'sharingin',
name: 'Shared with me',
- getFiles: () => [],
+ getContents: () => Promise.resolve(),
parent: 'sharing',
icon: ShareSvg,
order: 1,
@@ -93,6 +108,11 @@ describe('Navigation API', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
router,
})
@@ -120,7 +140,7 @@ describe('Navigation API', () => {
Navigation.register({
id: 'files',
name: 'Files',
- getFiles: () => [],
+ getContents: () => Promise.resolve(),
icon: FolderSvg,
order: 1,
})
@@ -151,6 +171,11 @@ describe('Quota rendering', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation-settings-quota]').should('not.exist')
@@ -160,7 +185,7 @@ describe('Quota rendering', () => {
cy.stub(InitialState, 'loadState')
.as('loadStateStats')
.returns({
- used: 1024 * 1024 * 1024,
+ used: 1000 * 1000 * 1000,
quota: -1,
})
@@ -168,6 +193,11 @@ describe('Quota rendering', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
@@ -179,8 +209,8 @@ describe('Quota rendering', () => {
cy.stub(InitialState, 'loadState')
.as('loadStateStats')
.returns({
- used: 1024 * 1024 * 1024,
- quota: 5 * 1024 * 1024 * 1024,
+ used: 1000 * 1000 * 1000,
+ quota: 5 * 1000 * 1000 * 1000,
relative: 20, // percent
})
@@ -188,6 +218,11 @@ describe('Quota rendering', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
@@ -200,8 +235,8 @@ describe('Quota rendering', () => {
cy.stub(InitialState, 'loadState')
.as('loadStateStats')
.returns({
- used: 5 * 1024 * 1024 * 1024,
- quota: 1024 * 1024 * 1024,
+ used: 5 * 1000 * 1000 * 1000,
+ quota: 1000 * 1000 * 1000,
relative: 500, // percent
})
@@ -209,6 +244,11 @@ describe('Quota rendering', () => {
propsData: {
Navigation,
},
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue
index d9fdfa7fe02..26ac99c15d3 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -1,7 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- - @author Gary Kim <gary@garykim.dev>
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
@@ -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,17 @@ 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
+ // TODO: remove when legacy views are dropped
+ if (view?.id === oldView?.id) {
+ return
+ }
+
+ this.Navigation.setActive(view)
+ logger.debug('Navigation changed', { id: view.id, view })
+
+ // debugger
this.showView(view, oldView)
},
},
@@ -163,6 +182,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 +199,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 +213,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)
diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue
index 9a117b70e22..efd9f8cad22 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -1,7 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- - @author Gary Kim <gary@garykim.dev>
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
@@ -26,11 +26,11 @@
@update:open="onClose">
<!-- Settings API-->
<NcAppSettingsSection id="settings" :title="t('files', 'Files settings')">
- <NcCheckboxRadioSwitch :checked.sync="show_hidden"
+ <NcCheckboxRadioSwitch :checked="userConfig.show_hidden"
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch :checked.sync="crop_image_previews"
+ <NcCheckboxRadioSwitch :checked="userConfig.crop_image_previews"
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
@@ -86,18 +86,11 @@ import Clipboard from 'vue-material-design-icons/Clipboard.vue'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
import Setting from '../components/Setting.vue'
-import { emit } from '@nextcloud/event-bus'
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
-import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-
-const userConfig = loadState('files', 'config', {
- show_hidden: false,
- crop_image_previews: true,
-})
+import { useUserConfigStore } from '../store/userconfig.ts'
export default {
name: 'Settings',
@@ -117,11 +110,15 @@ export default {
},
},
- data() {
+ setup() {
+ const userConfigStore = useUserConfigStore()
return {
+ userConfigStore,
+ }
+ },
- ...userConfig,
-
+ data() {
+ return {
// Settings API
settings: window.OCA?.Files?.Settings?.settings || [],
@@ -133,6 +130,12 @@ export default {
}
},
+ computed: {
+ userConfig() {
+ return this.userConfigStore.userConfig
+ },
+ },
+
beforeMount() {
// Update the settings API entries state
this.settings.forEach(setting => setting.open())
@@ -149,10 +152,7 @@ export default {
},
setConfig(key, value) {
- emit('files:config:updated', { key, value })
- axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
- value,
- })
+ this.userConfigStore.update(key, value)
},
async copyCloudId() {