diff options
Diffstat (limited to 'apps/files/src')
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() { |