diff options
Diffstat (limited to 'apps/files_versions/src')
-rw-r--r-- | apps/files_versions/src/components/Version.vue | 492 | ||||
-rw-r--r-- | apps/files_versions/src/components/VersionLabelDialog.vue | 123 | ||||
-rw-r--r-- | apps/files_versions/src/components/VirtualScrolling.vue | 346 | ||||
-rw-r--r-- | apps/files_versions/src/css/versions.css | 103 | ||||
-rw-r--r-- | apps/files_versions/src/files_versions_tab.js | 30 | ||||
-rw-r--r-- | apps/files_versions/src/utils/davClient.js | 47 | ||||
-rw-r--r-- | apps/files_versions/src/utils/davRequest.js | 24 | ||||
-rw-r--r-- | apps/files_versions/src/utils/logger.js | 20 | ||||
-rw-r--r-- | apps/files_versions/src/utils/versions.js | 139 | ||||
-rw-r--r-- | apps/files_versions/src/utils/versions.ts | 133 | ||||
-rw-r--r-- | apps/files_versions/src/views/VersionTab.vue | 218 |
11 files changed, 1209 insertions, 466 deletions
diff --git a/apps/files_versions/src/components/Version.vue b/apps/files_versions/src/components/Version.vue index 6a9bac977a8..dc36e4134f9 100644 --- a/apps/files_versions/src/components/Version.vue +++ b/apps/files_versions/src/components/Version.vue @@ -1,162 +1,177 @@ <!-- - - @copyright 2022 Carl Schwan <carl@carlschwan.eu> - - @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/>. - --> + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div> - <NcListItem class="version" - :title="versionLabel" - :href="downloadURL" - :force-display-actions="true" - data-files-versions-version> - <template #icon> - <img lazy="true" - :src="previewURL" - alt="" - height="256" - width="256" - class="version__image"> - </template> - <template #subtitle> - <div class="version__info"> - <span v-tooltip="formattedDate">{{ version.mtime | humanDateFromNow }}</span> - <!-- Separate dot to improve alignement --> - <span class="version__info__size">•</span> - <span class="version__info__size">{{ version.size | humanReadableSize }}</span> + <NcListItem class="version" + :force-display-actions="true" + :actions-aria-label="t('files_versions', 'Actions for version from {versionHumanExplicitDate}', { versionHumanExplicitDate })" + :data-files-versions-version="version.fileVersion" + @click="click"> + <!-- Icon --> + <template #icon> + <div v-if="!(loadPreview || previewLoaded)" class="version__image" /> + <img v-else-if="version.previewUrl && !previewErrored" + :src="version.previewUrl" + alt="" + decoding="async" + fetchpriority="low" + loading="lazy" + class="version__image" + @load="previewLoaded = true" + @error="previewErrored = true"> + <div v-else + class="version__image"> + <ImageOffOutline :size="20" /> + </div> + </template> + + <!-- author --> + <template #name> + <div class="version__info"> + <div v-if="versionLabel" + class="version__info__label" + data-cy-files-version-label + :title="versionLabel"> + {{ versionLabel }} </div> - </template> - <template #actions> - <NcActionButton v-if="capabilities.files.version_labeling === true" - :close-after-click="true" - @click="openVersionLabelModal"> - <template #icon> - <Pencil :size="22" /> - </template> - {{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }} - </NcActionButton> - <NcActionButton v-if="!isCurrent" - :close-after-click="true" - @click="restoreVersion"> - <template #icon> - <BackupRestore :size="22" /> - </template> - {{ t('files_versions', 'Restore version') }} - </NcActionButton> - <NcActionLink :href="downloadURL" - :close-after-click="true" - :download="downloadURL"> - <template #icon> - <Download :size="22" /> - </template> - {{ t('files_versions', 'Download version') }} - </NcActionLink> - <NcActionButton v-if="!isCurrent && capabilities.files.version_deletion === true" - :close-after-click="true" - @click="deleteVersion"> - <template #icon> - <Delete :size="22" /> - </template> - {{ t('files_versions', 'Delete version') }} - </NcActionButton> - </template> - </NcListItem> - <NcModal v-if="showVersionLabelForm" - :title="t('files_versions', 'Name this version')" - @close="showVersionLabelForm = false"> - <form class="version-label-modal" - @submit.prevent="setVersionLabel(formVersionLabelValue)"> - <label> - <div class="version-label-modal__title">{{ t('photos', 'Version name') }}</div> - <NcTextField ref="labelInput" - :value.sync="formVersionLabelValue" - :placeholder="t('photos', 'Version name')" - :label-outside="true" /> - </label> - - <div class="version-label-modal__info"> - {{ t('photos', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }} + <div v-if="versionAuthor" + class="version__info" + data-cy-files-version-author-name> + <span v-if="versionLabel">•</span> + <NcAvatar class="avatar" + :user="version.author" + :size="20" + disable-menu + disable-tooltip + :show-user-status="false" /> + <div class="version__info__author_name" + :title="versionAuthor"> + {{ versionAuthor }} + </div> </div> + </div> + </template> - <div class="version-label-modal__actions"> - <NcButton :disabled="formVersionLabelValue.trim().length === 0" @click="setVersionLabel('')"> - {{ t('files_versions', 'Remove version name') }} - </NcButton> - <NcButton type="primary" native-type="submit"> - <template #icon> - <Check /> - </template> - {{ t('files_versions', 'Save version name') }} - </NcButton> - </div> - </form> - </NcModal> - </div> + <!-- Version file size as subline --> + <template #subname> + <div class="version__info version__info__subline"> + <NcDateTime class="version__info__date" + relative-time="short" + :timestamp="version.mtime" /> + <!-- Separate dot to improve alignment --> + <span>•</span> + <span>{{ humanReadableSize }}</span> + </div> + </template> + + <!-- Actions --> + <template #actions> + <NcActionButton v-if="enableLabeling && hasUpdatePermissions" + data-cy-files-versions-version-action="label" + :close-after-click="true" + @click="labelUpdate"> + <template #icon> + <Pencil :size="22" /> + </template> + {{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }} + </NcActionButton> + <NcActionButton v-if="!isCurrent && canView && canCompare" + data-cy-files-versions-version-action="compare" + :close-after-click="true" + @click="compareVersion"> + <template #icon> + <FileCompare :size="22" /> + </template> + {{ t('files_versions', 'Compare to current version') }} + </NcActionButton> + <NcActionButton v-if="!isCurrent && hasUpdatePermissions" + data-cy-files-versions-version-action="restore" + :close-after-click="true" + @click="restoreVersion"> + <template #icon> + <BackupRestore :size="22" /> + </template> + {{ t('files_versions', 'Restore version') }} + </NcActionButton> + <NcActionLink v-if="isDownloadable" + data-cy-files-versions-version-action="download" + :href="downloadURL" + :close-after-click="true" + :download="downloadURL"> + <template #icon> + <Download :size="22" /> + </template> + {{ t('files_versions', 'Download version') }} + </NcActionLink> + <NcActionButton v-if="!isCurrent && enableDeletion && hasDeletePermissions" + data-cy-files-versions-version-action="delete" + :close-after-click="true" + @click="deleteVersion"> + <template #icon> + <Delete :size="22" /> + </template> + {{ t('files_versions', 'Delete version') }} + </NcActionButton> + </template> + </NcListItem> </template> +<script lang="ts"> +import type { PropType } from 'vue' +import type { Version } from '../utils/versions' + +import { getCurrentUser } from '@nextcloud/auth' +import { Permission, formatFileSize } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { joinPaths } from '@nextcloud/paths' +import { getRootUrl, generateUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + +import axios from '@nextcloud/axios' +import moment from '@nextcloud/moment' +import logger from '../utils/logger' -<script> import BackupRestore from 'vue-material-design-icons/BackupRestore.vue' +import Delete from 'vue-material-design-icons/Delete.vue' import Download from 'vue-material-design-icons/Download.vue' -import Pencil from 'vue-material-design-icons/Pencil.vue' -import Check from 'vue-material-design-icons/Check.vue' -import Delete from 'vue-material-design-icons/Delete' -import { NcActionButton, NcActionLink, NcListItem, NcModal, NcButton, NcTextField, Tooltip } from '@nextcloud/vue' -import moment from '@nextcloud/moment' -import { translate } from '@nextcloud/l10n' -import { joinPaths } from '@nextcloud/paths' -import { generateUrl } from '@nextcloud/router' -import { loadState } from '@nextcloud/initial-state' +import FileCompare from 'vue-material-design-icons/FileCompare.vue' +import ImageOffOutline from 'vue-material-design-icons/ImageOffOutline.vue' +import Pencil from 'vue-material-design-icons/PencilOutline.vue' -export default { +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcListItem from '@nextcloud/vue/components/NcListItem' +import Tooltip from '@nextcloud/vue/directives/Tooltip' + +const hasPermission = (permissions: number, permission: number): boolean => (permissions & permission) !== 0 + +export default defineComponent({ name: 'Version', + components: { NcActionLink, NcActionButton, + NcAvatar, + NcDateTime, NcListItem, - NcModal, - NcButton, - NcTextField, BackupRestore, Download, + FileCompare, Pencil, - Check, Delete, + ImageOffOutline, }, + directives: { tooltip: Tooltip, }, - filters: { - /** - * @param {number} bytes - * @return {string} - */ - humanReadableSize(bytes) { - return OC.Util.humanFileSize(bytes) - }, - /** - * @param {number} timestamp - * @return {string} - */ - humanDateFromNow(timestamp) { - return moment(timestamp).fromNow() - }, - }, + props: { - /** @type {Vue.PropOptions<import('../utils/versions.js').Version>} */ version: { - type: Object, + type: Object as PropType<Version>, required: true, }, fileInfo: { @@ -171,86 +186,158 @@ export default { type: Boolean, default: false, }, + loadPreview: { + type: Boolean, + default: false, + }, + canView: { + type: Boolean, + default: false, + }, + canCompare: { + type: Boolean, + default: false, + }, }, + + emits: ['click', 'compare', 'restore', 'delete', 'label-update-request'], + data() { return { - showVersionLabelForm: false, - formVersionLabelValue: this.version.label, + previewLoaded: false, + previewErrored: false, capabilities: loadState('core', 'capabilities', { files: { version_labeling: false, version_deletion: false } }), + versionAuthor: '' as string | null, } }, + computed: { - /** - * @return {string} - */ - versionLabel() { + humanReadableSize() { + return formatFileSize(this.version.size) + }, + + versionLabel(): string { + const label = this.version.label ?? '' + if (this.isCurrent) { - if (this.version.label === undefined || this.version.label === '') { - return translate('files_versions', 'Current version') + if (label === '') { + return t('files_versions', 'Current version') } else { - return `${this.version.label} (${translate('files_versions', 'Current version')})` + return `${label} (${t('files_versions', 'Current version')})` } } - if (this.isFirstVersion && this.version.label === '') { - return translate('files_versions', 'Initial version') + if (this.isFirstVersion && label === '') { + return t('files_versions', 'Initial version') } - return this.version.label + return label + }, + + versionHumanExplicitDate(): string { + return moment(this.version.mtime).format('LLLL') }, - /** - * @return {string} - */ - downloadURL() { + downloadURL(): string { if (this.isCurrent) { - return joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name) + return getRootUrl() + joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name) } else { - return this.version.url + return getRootUrl() + this.version.url } }, - /** - * @return {string} - */ - previewURL() { - if (this.isCurrent) { - return generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', { - fileId: this.fileInfo.id, - fileEtag: this.fileInfo.etag, - }) - } else { - return this.version.preview + enableLabeling(): boolean { + return this.capabilities.files.version_labeling === true + }, + + enableDeletion(): boolean { + return this.capabilities.files.version_deletion === true + }, + + hasDeletePermissions(): boolean { + return hasPermission(this.fileInfo.permissions, Permission.DELETE) + }, + + hasUpdatePermissions(): boolean { + return hasPermission(this.fileInfo.permissions, Permission.UPDATE) + }, + + isDownloadable(): boolean { + if ((this.fileInfo.permissions & Permission.READ) === 0) { + return false } + + // If the mount type is a share, ensure it got download permissions. + if (this.fileInfo.mountType === 'shared') { + const downloadAttribute = this.fileInfo.shareAttributes + .find((attribute) => attribute.scope === 'permissions' && attribute.key === 'download') || {} + // If the download attribute is set to false, the file is not downloadable + if (downloadAttribute?.value === false) { + return false + } + } + + return true }, }, + + created() { + this.fetchDisplayName() + }, + methods: { - openVersionLabelModal() { - this.showVersionLabelForm = true - this.$nextTick(() => { - this.$refs.labelInput.$el.getElementsByTagName('input')[0].focus() - }) + labelUpdate() { + this.$emit('label-update-request') }, restoreVersion() { this.$emit('restore', this.version) }, - setVersionLabel(label) { - this.formVersionLabelValue = label - this.showVersionLabelForm = false - this.$emit('label-update', this.version, label) + async deleteVersion() { + // Let @nc-vue properly remove the popover before we delete the version. + // This prevents @nc-vue from throwing a error. + await this.$nextTick() + await this.$nextTick() + this.$emit('delete', this.version) }, - deleteVersion() { - this.$emit('delete', this.version) + async fetchDisplayName() { + this.versionAuthor = null + if (!this.version.author) { + return + } + + if (this.version.author === getCurrentUser()?.uid) { + this.versionAuthor = t('files_versions', 'You') + } else { + try { + const { data } = await axios.post(generateUrl('/displaynames'), { users: [this.version.author] }) + this.versionAuthor = data.users[this.version.author] + } catch (error) { + logger.warn('Could not load user display name', { error }) + } + } }, - formattedDate() { - return moment(this.version.mtime) + click() { + if (!this.canView) { + window.location.href = this.downloadURL + return + } + this.$emit('click', { version: this.version }) }, + + compareVersion() { + if (!this.canView) { + throw new Error('Cannot compare version of this file') + } + this.$emit('compare', { version: this.version }) + }, + + t, }, -} +}) </script> <style scoped lang="scss"> @@ -263,9 +350,31 @@ export default { flex-direction: row; align-items: center; gap: 0.5rem; + color: var(--color-main-text); + font-weight: 500; + overflow: hidden; + + &__label { + font-weight: 700; + // Fix overflow on narrow screens + overflow: hidden; + text-overflow: ellipsis; + min-width: 110px; + } + + &__author_name { + overflow: hidden; + text-overflow: ellipsis; + } - &__size { - color: var(--color-text-lighter); + &__date { + // Fix overflow on narrow screens + overflow: hidden; + text-overflow: ellipsis; + } + + &__subline { + color: var(--color-text-maxcontrast) } } @@ -274,30 +383,11 @@ export default { height: 3rem; border: 1px solid var(--color-border); border-radius: var(--border-radius-large); - } -} - -.version-label-modal { - display: flex; - justify-content: space-between; - flex-direction: column; - height: 250px; - padding: 16px; - - &__title { - margin-bottom: 12px; - font-weight: 600; - } - - &__info { - margin-top: 12px; - color: var(--color-text-maxcontrast); - } - &__actions { + // Useful to display no preview icon. display: flex; - justify-content: space-between; - margin-top: 64px; + justify-content: center; + color: var(--color-text-light); } } </style> diff --git a/apps/files_versions/src/components/VersionLabelDialog.vue b/apps/files_versions/src/components/VersionLabelDialog.vue new file mode 100644 index 00000000000..760780cae61 --- /dev/null +++ b/apps/files_versions/src/components/VersionLabelDialog.vue @@ -0,0 +1,123 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog :buttons="dialogButtons" + content-classes="version-label-modal" + is-form + :open="open" + size="normal" + :name="t('files_versions', 'Name this version')" + @update:open="$emit('update:open', $event)" + @submit="setVersionLabel(editedVersionLabel)"> + <NcTextField ref="labelInput" + class="version-label-modal__input" + :label="t('files_versions', 'Version name')" + :placeholder="t('files_versions', 'Version name')" + :value.sync="editedVersionLabel" /> + + <p class="version-label-modal__info"> + {{ t('files_versions', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }} + </p> + </NcDialog> +</template> + +<script lang="ts"> +import { t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' +import svgCheck from '@mdi/svg/svg/check.svg?raw' + +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +type Focusable = Vue & { focus: () => void } + +export default defineComponent({ + name: 'VersionLabelDialog', + components: { + NcDialog, + NcTextField, + }, + props: { + open: { + type: Boolean, + default: false, + }, + versionLabel: { + type: String, + default: '', + }, + }, + data() { + return { + editedVersionLabel: '', + } + }, + computed: { + dialogButtons() { + const buttons: unknown[] = [] + if (this.versionLabel.trim() === '') { + // If there is no label just offer a cancel action that just closes the dialog + buttons.push({ + label: t('files_versions', 'Cancel'), + }) + } else { + // If there is already a label set, offer to remove the version label + buttons.push({ + label: t('files_versions', 'Remove version name'), + type: 'error', + nativeType: 'reset', + callback: () => { this.setVersionLabel('') }, + }) + } + return [ + ...buttons, + { + label: t('files_versions', 'Save version name'), + type: 'primary', + nativeType: 'submit', + icon: svgCheck, + }, + ] + }, + }, + watch: { + versionLabel: { + immediate: true, + handler(label) { + this.editedVersionLabel = label ?? '' + }, + }, + open: { + immediate: true, + handler(open) { + if (open) { + this.$nextTick(() => (this.$refs.labelInput as Focusable).focus()) + } + this.editedVersionLabel = this.versionLabel + }, + }, + }, + methods: { + setVersionLabel(label: string) { + this.$emit('label-update', label) + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +.version-label-modal { + &__info { + color: var(--color-text-maxcontrast); + margin-block: calc(3 * var(--default-grid-baseline)); + } + + &__input { + margin-block-start: calc(2 * var(--default-grid-baseline)); + } +} +</style> diff --git a/apps/files_versions/src/components/VirtualScrolling.vue b/apps/files_versions/src/components/VirtualScrolling.vue new file mode 100644 index 00000000000..5a502036839 --- /dev/null +++ b/apps/files_versions/src/components/VirtualScrolling.vue @@ -0,0 +1,346 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div v-if="!useWindow && containerElement === null" ref="container" class="vs-container"> + <div ref="rowsContainer" + class="vs-rows-container" + :style="rowsContainerStyle"> + <slot :visible-sections="visibleSections" /> + <slot name="loader" /> + </div> + </div> + <div v-else + ref="rowsContainer" + class="vs-rows-container" + :style="rowsContainerStyle"> + <slot :visible-sections="visibleSections" /> + <slot name="loader" /> + </div> +</template> + +<script lang="ts"> +import { defineComponent, type PropType } from 'vue' + +import logger from '../utils/logger.js' + +interface RowItem { + id: string // Unique id for the item. + key?: string // Unique key for the item. +} + +interface Row { + key: string // Unique key for the row. + height: number // The height of the row. + sectionKey: string // Unique key for the row. + items: RowItem[] // List of items in the row. +} + +interface VisibleRow extends Row { + distance: number // The distance from the visible viewport +} + +interface Section { + key: string, // Unique key for the section. + rows: Row[], // The height of the row. + height: number, // Height of the section, excluding the header. +} + +interface VisibleSection extends Section { + rows: VisibleRow[], // The height of the row. +} + +export default defineComponent({ + name: 'VirtualScrolling', + + props: { + sections: { + type: Array as PropType<Section[]>, + required: true, + }, + + containerElement: { + type: HTMLElement, + default: null, + }, + + useWindow: { + type: Boolean, + default: false, + }, + + headerHeight: { + type: Number, + default: 75, + }, + renderDistance: { + type: Number, + default: 0.5, + }, + bottomBufferRatio: { + type: Number, + default: 2, + }, + scrollToKey: { + type: String, + default: '', + }, + }, + + data() { + return { + scrollPosition: 0, + containerHeight: 0, + rowsContainerHeight: 0, + resizeObserver: null as ResizeObserver|null, + } + }, + + computed: { + visibleSections(): VisibleSection[] { + logger.debug('[VirtualScrolling] Computing visible section', { sections: this.sections }) + + // Optimization: get those computed properties once to not go through vue's internal every time we need them. + const containerHeight = this.containerHeight + const containerTop = this.scrollPosition + const containerBottom = containerTop + containerHeight + + let currentRowTop = 0 + let currentRowBottom = 0 + + // Compute whether a row should be included in the DOM (shouldRender) + // And how visible the row is. + const visibleSections = this.sections + .map(section => { + currentRowBottom += this.headerHeight + + return { + ...section, + rows: section.rows.reduce((visibleRows, row) => { + currentRowTop = currentRowBottom + currentRowBottom += row.height + + let distance = 0 + + if (currentRowBottom < containerTop) { + distance = (containerTop - currentRowBottom) / containerHeight + } else if (currentRowTop > containerBottom) { + distance = (currentRowTop - containerBottom) / containerHeight + } + + if (distance > this.renderDistance) { + return visibleRows + } + + return [ + ...visibleRows, + { + ...row, + distance, + }, + ] + }, [] as VisibleRow[]), + } + }) + .filter(section => section.rows.length > 0) + + // To allow vue to recycle the DOM elements instead of adding and deleting new ones, + // we assign a random key to each items. When a item removed, we recycle its key for new items, + // so vue can replace the content of removed DOM elements with the content of new items, but keep the other DOM elements untouched. + const visibleItems = visibleSections + .flatMap(({ rows }) => rows) + .flatMap(({ items }) => items) + + const rowIdToKeyMap = this._rowIdToKeyMap as {[key: string]: string} + + visibleItems.forEach(item => (item.key = rowIdToKeyMap[item.id])) + + const usedTokens = visibleItems + .map(({ key }) => key) + .filter(key => key !== undefined) + + const unusedTokens = Object.values(rowIdToKeyMap).filter(key => !usedTokens.includes(key)) + + visibleItems + .filter(({ key }) => key === undefined) + .forEach(item => (item.key = unusedTokens.pop() ?? Math.random().toString(36).substr(2))) + + // this._rowIdToKeyMap is created in the beforeCreate hook, so value changes are not tracked. + // Therefore, we wont trigger the computation of visibleSections again if we alter the value of this._rowIdToKeyMap. + // eslint-disable-next-line vue/no-side-effects-in-computed-properties + this._rowIdToKeyMap = visibleItems.reduce((finalMapping, { id, key }) => ({ ...finalMapping, [`${id}`]: key }), {}) + + return visibleSections + }, + + /** + * Total height of all the rows + some room for the loader. + */ + totalHeight(): number { + const loaderHeight = 0 + + return this.sections + .map(section => this.headerHeight + section.height) + .reduce((totalHeight, sectionHeight) => totalHeight + sectionHeight, 0) + loaderHeight + }, + + paddingTop(): number { + if (this.visibleSections.length === 0) { + return 0 + } + + let paddingTop = 0 + + for (const section of this.sections) { + if (section.key !== this.visibleSections[0].rows[0].sectionKey) { + paddingTop += this.headerHeight + section.height + continue + } + + for (const row of section.rows) { + if (row.key === this.visibleSections[0].rows[0].key) { + return paddingTop + } + + paddingTop += row.height + } + + paddingTop += this.headerHeight + } + + return paddingTop + }, + + /** + * padding-top is used to replace not included item in the container. + */ + rowsContainerStyle(): { height: string; paddingTop: string } { + return { + height: `${this.totalHeight}px`, + paddingTop: `${this.paddingTop}px`, + } + }, + + /** + * Whether the user is near the bottom. + * If true, then the need-content event will be emitted. + */ + isNearBottom(): boolean { + const buffer = this.containerHeight * this.bottomBufferRatio + return this.scrollPosition + this.containerHeight >= this.totalHeight - buffer + }, + + container() { + logger.debug('[VirtualScrolling] Computing container') + if (this.containerElement !== null) { + return this.containerElement + } else if (this.useWindow) { + return window + } else { + return this.$refs.container as Element + } + }, + }, + + watch: { + isNearBottom(value) { + logger.debug('[VirtualScrolling] isNearBottom changed', { value }) + if (value) { + this.$emit('need-content') + } + }, + + visibleSections() { + // Re-emit need-content when rows is updated and isNearBottom is still true. + // If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content. + if (this.isNearBottom) { + this.$emit('need-content') + } + }, + + scrollToKey(key) { + let currentRowTopDistanceFromTop = 0 + + for (const section of this.sections) { + if (section.key !== key) { + currentRowTopDistanceFromTop += this.headerHeight + section.height + continue + } + + break + } + + logger.debug('[VirtualScrolling] Scrolling to', { currentRowTopDistanceFromTop }) + this.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' }) + }, + }, + + beforeCreate() { + this._rowIdToKeyMap = {} + }, + + mounted() { + this.resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const cr = entry.contentRect + if (entry.target === this.container) { + this.containerHeight = cr.height + } + if (entry.target.classList.contains('vs-rows-container')) { + this.rowsContainerHeight = cr.height + } + } + }) + + if (this.useWindow) { + window.addEventListener('resize', this.updateContainerSize, { passive: true }) + this.containerHeight = window.innerHeight + } else { + this.resizeObserver.observe(this.container as HTMLElement|Element) + } + + this.resizeObserver.observe(this.$refs.rowsContainer as Element) + this.container.addEventListener('scroll', this.updateScrollPosition, { passive: true }) + }, + + beforeDestroy() { + if (this.useWindow) { + window.removeEventListener('resize', this.updateContainerSize) + } + + this.resizeObserver?.disconnect() + this.container.removeEventListener('scroll', this.updateScrollPosition) + }, + + methods: { + updateScrollPosition() { + this._onScrollHandle ??= requestAnimationFrame(() => { + this._onScrollHandle = null + if (this.useWindow) { + this.scrollPosition = (this.container as Window).scrollY + } else { + this.scrollPosition = (this.container as HTMLElement|Element).scrollTop + } + }) + }, + + updateContainerSize() { + this.containerHeight = window.innerHeight + }, + }, +}) +</script> + +<style scoped lang="scss"> +.vs-container { + overflow-y: scroll; + height: 100%; +} + +.vs-rows-container { + box-sizing: border-box; + will-change: scroll-position, padding; + contain: layout paint style; +} +</style> diff --git a/apps/files_versions/src/css/versions.css b/apps/files_versions/src/css/versions.css new file mode 100644 index 00000000000..1637394ef48 --- /dev/null +++ b/apps/files_versions/src/css/versions.css @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +.versionsTabView .clear-float { + clear: both; +} + +.versionsTabView li { + width: 100%; + cursor: default; + height: 56px; + float: left; + border-bottom: 0; +} + +.versionsTabView li:last-child { + border-bottom: none; +} + +.versionsTabView a, +.versionsTabView div > span { + vertical-align: middle; + opacity: .5; +} + +.versionsTabView li a{ + padding: 19px 10px 7px; +} + +.versionsTabView a:hover, +.versionsTabView a:focus { + opacity: 1; +} + +.versionsTabView .preview-container { + display: inline-block; + vertical-align: top; +} + +.versionsTabView img { + cursor: pointer; + padding-inline-end: 4px; +} + +.versionsTabView img.preview { + position: relative; + top: 6px; + inset-inline-start: 10px; + border: 1px solid var(--color-border-dark); + cursor: default; + padding-inline-end: 0; +} + +.versionsTabView .version-container { + display: inline-block; +} + +.versionsTabView .versiondate { + min-width: 100px; + vertical-align: super; +} + +.versionsTabView .version-details { + text-align: start; +} + +.versionsTabView .version-details > span { + padding: 0 10px; +} + +.versionsTabView .revertVersion { + cursor: pointer; + float: right; + margin-inline-end: 0; +} + +.versionsTabView li.active .downloadVersion { + opacity: 1; +} + +.versionsTabView li.active .version-details .size { + color: var(--color-main-text); + opacity: 1; +} + +.versionsTabView li.active { + background-color: var(--color-primary-light); + border-radius: 16px; +} + +.versionsTabView li.active a.revertVersion { + opacity: 1; +} + +.version-container { + padding-inline-start: 5px; +} + +.version-details { + margin-top: -7px; +} diff --git a/apps/files_versions/src/files_versions_tab.js b/apps/files_versions/src/files_versions_tab.js index e67199436fa..12f36bad24a 100644 --- a/apps/files_versions/src/files_versions_tab.js +++ b/apps/files_versions/src/files_versions_tab.js @@ -1,34 +1,20 @@ /** - * @copyright 2022 Carl Schwan <carl@carlschwan.eu> - * @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/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' import { translate as t, translatePlural as n } from '@nextcloud/l10n' import VersionTab from './views/VersionTab.vue' -import VTooltip from 'v-tooltip' -// eslint-disable-next-line node/no-missing-import, import/no-unresolved +import VTooltipPlugin from 'v-tooltip' +// eslint-disable-next-line n/no-missing-import, import/no-unresolved import BackupRestore from '@mdi/svg/svg/backup-restore.svg?raw' Vue.prototype.t = t Vue.prototype.n = n -Vue.use(VTooltip) +Vue.use(VTooltipPlugin) // Init Sharing tab component const View = Vue.extend(VersionTab) @@ -59,6 +45,12 @@ window.addEventListener('DOMContentLoaded', function() { update(fileInfo) { TabInstance.update(fileInfo) }, + setIsActive(isActive) { + if (!TabInstance) { + return + } + TabInstance.setIsActive(isActive) + }, destroy() { TabInstance.$destroy() TabInstance = null diff --git a/apps/files_versions/src/utils/davClient.js b/apps/files_versions/src/utils/davClient.js index e4bfeb10411..029373e9193 100644 --- a/apps/files_versions/src/utils/davClient.js +++ b/apps/files_versions/src/utils/davClient.js @@ -1,34 +1,29 @@ /** - * @copyright 2022 Louis Chemineau <mlouis@chmn.me> - * - * @author Louis Chemineau <mlouis@chmn.me> - * - * @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/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { createClient, getPatcher } from 'webdav' +import { createClient } from 'webdav' import { generateRemoteUrl } from '@nextcloud/router' -import axios from '@nextcloud/axios' +import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth' +// init webdav client const rootPath = 'dav' +const remote = generateRemoteUrl(rootPath) +const client = createClient(remote) -// force our axios -const patcher = getPatcher() -patcher.patch('request', axios) +// set CSRF token header +const setHeaders = (token) => { + client.setHeaders({ + // Add this so the server knows it is an request from the browser + 'X-Requested-With': 'XMLHttpRequest', + // Inject user auth + requesttoken: token ?? '', + }) +} -// init webdav client on default dav endpoint -const remote = generateRemoteUrl(rootPath) -export default createClient(remote) +// refresh headers when request token changes +onRequestTokenUpdate(setHeaders) +setHeaders(getRequestToken()) + +export default client diff --git a/apps/files_versions/src/utils/davRequest.js b/apps/files_versions/src/utils/davRequest.js index fb2126d98bf..1dcf620564a 100644 --- a/apps/files_versions/src/utils/davRequest.js +++ b/apps/files_versions/src/utils/davRequest.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default `<?xml version="1.0"?> @@ -29,6 +12,9 @@ export default `<?xml version="1.0"?> <d:getcontentlength /> <d:getcontenttype /> <d:getlastmodified /> + <d:getetag /> <nc:version-label /> + <nc:version-author /> + <nc:has-preview /> </d:prop> </d:propfind>` diff --git a/apps/files_versions/src/utils/logger.js b/apps/files_versions/src/utils/logger.js index 4f0356764d9..f84cb969244 100644 --- a/apps/files_versions/src/utils/logger.js +++ b/apps/files_versions/src/utils/logger.js @@ -1,22 +1,6 @@ /** - * @copyright 2022 Louis Chemineau <mlouis@chmn.me> - * - * @author Louis Chemineau <mlouis@chmn.me> - * - * @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/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getLoggerBuilder } from '@nextcloud/logger' diff --git a/apps/files_versions/src/utils/versions.js b/apps/files_versions/src/utils/versions.js deleted file mode 100644 index 1a5dde10824..00000000000 --- a/apps/files_versions/src/utils/versions.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @copyright 2022 Louis Chemineau <mlouis@chmn.me> - * - * @author Louis Chemineau <mlouis@chmn.me> - * - * @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 { getCurrentUser } from '@nextcloud/auth' -import client from '../utils/davClient.js' -import davRequest from '../utils/davRequest.js' -import logger from '../utils/logger.js' -import { joinPaths } from '@nextcloud/paths' -import { generateUrl } from '@nextcloud/router' -import moment from '@nextcloud/moment' - -/** - * @typedef {object} Version - * @property {string} fileId - The id of the file associated to the version. - * @property {string} label - 'Current version' or '' - * @property {string} fileName - File name relative to the version DAV endpoint - * @property {string} mimeType - Empty for the current version, else the actual mime type of the version - * @property {string} size - Human readable size - * @property {string} type - 'file' - * @property {number} mtime - Version creation date as a timestamp - * @property {string} preview - Preview URL of the version - * @property {string} url - Download URL of the version - * @property {string|null} fileVersion - The version id, null for the current version - */ - -/** - * @param fileInfo - * @return {Promise<Version[]>} - */ -export async function fetchVersions(fileInfo) { - const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}` - - try { - /** @type {import('webdav').ResponseDataDetailed<import('webdav').FileStat[]>} */ - const response = await client.getDirectoryContents(path, { - data: davRequest, - details: true, - }) - return response.data - // Filter out root - .filter(({ mime }) => mime !== '') - .map(version => formatVersion(version, fileInfo)) - } catch (exception) { - logger.error('Could not fetch version', { exception }) - throw exception - } -} - -/** - * Restore the given version - * - * @param {Version} version - */ -export async function restoreVersion(version) { - try { - logger.debug('Restoring version', { url: version.url }) - await client.moveFile( - `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`, - `/versions/${getCurrentUser()?.uid}/restore/target` - ) - } catch (exception) { - logger.error('Could not restore version', { exception }) - throw exception - } -} - -/** - * Format version - * - * @param {object} version - raw version received from the versions DAV endpoint - * @param {object} fileInfo - file properties received from the files DAV endpoint - * @return {Version} - */ -function formatVersion(version, fileInfo) { - return { - fileId: fileInfo.id, - label: version.props['version-label'], - fileName: version.filename, - mimeType: version.mime, - size: version.size, - type: version.type, - mtime: moment(version.lastmod).unix() * 1000, - preview: generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', { - file: joinPaths(fileInfo.path, fileInfo.name), - fileVersion: version.basename, - }), - url: joinPaths('/remote.php/dav', version.filename), - fileVersion: version.basename, - } -} - -/** - * @param {Version} version - * @param {string} newLabel - */ -export async function setVersionLabel(version, newLabel) { - return await client.customRequest( - version.fileName, - { - method: 'PROPPATCH', - data: `<?xml version="1.0"?> - <d:propertyupdate xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns" - xmlns:ocs="http://open-collaboration-services.org/ns"> - <d:set> - <d:prop> - <nc:version-label>${newLabel}</nc:version-label> - </d:prop> - </d:set> - </d:propertyupdate>`, - } - ) -} - -/** - * @param {Version} version - */ -export async function deleteVersion(version) { - await client.deleteFile(version.fileName) -} diff --git a/apps/files_versions/src/utils/versions.ts b/apps/files_versions/src/utils/versions.ts new file mode 100644 index 00000000000..6d5933f0bd9 --- /dev/null +++ b/apps/files_versions/src/utils/versions.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable jsdoc/require-param */ +/* eslint-disable jsdoc/require-jsdoc */ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { joinPaths, encodePath } from '@nextcloud/paths' +import moment from '@nextcloud/moment' + +import client from '../utils/davClient.js' +import davRequest from '../utils/davRequest.js' +import logger from '../utils/logger.js' + +export interface Version { + fileId: string, // The id of the file associated to the version. + label: string, // 'Current version' or '' + author: string|null, // UID for the author of the version + filename: string, // File name relative to the version DAV endpoint + basename: string, // A base name generated from the mtime + mime: string, // Empty for the current version, else the actual mime type of the version + etag: string, // Empty for the current version, else the actual mime type of the version + size: string, // Human readable size + type: string, // 'file' + mtime: number, // Version creation date as a timestamp + permissions: string, // Only readable: 'R' + previewUrl: string, // Preview URL of the version + url: string, // Download URL of the version + source: string, // The WebDAV endpoint of the ressource + fileVersion: string|null, // The version id, null for the current version +} + +export async function fetchVersions(fileInfo: any): Promise<Version[]> { + const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}` + + try { + const response = await client.getDirectoryContents(path, { + data: davRequest, + details: true, + }) as ResponseDataDetailed<FileStat[]> + + return response.data + // Filter out root + .filter(({ mime }) => mime !== '') + .map(version => formatVersion(version, fileInfo)) + } catch (exception) { + logger.error('Could not fetch version', { exception }) + throw exception + } +} + +/** + * Restore the given version + */ +export async function restoreVersion(version: Version) { + try { + logger.debug('Restoring version', { url: version.url }) + await client.moveFile( + `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`, + `/versions/${getCurrentUser()?.uid}/restore/target`, + ) + } catch (exception) { + logger.error('Could not restore version', { exception }) + throw exception + } +} + +/** + * Format version + */ +function formatVersion(version: any, fileInfo: any): Version { + const mtime = moment(version.lastmod).unix() * 1000 + let previewUrl = '' + + if (mtime === fileInfo.mtime) { // Version is the current one + previewUrl = generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0&forceIcon=1&mimeFallback=1', { + fileId: fileInfo.id, + fileEtag: fileInfo.etag, + }) + } else { + previewUrl = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}&mimeFallback=1', { + file: joinPaths(fileInfo.path, fileInfo.name), + fileVersion: version.basename, + }) + } + + return { + fileId: fileInfo.id, + // If version-label is defined make sure it is a string (prevent issue if the label is a number an PHP returns a number then) + label: version.props['version-label'] && String(version.props['version-label']), + author: version.props['version-author'] ?? null, + filename: version.filename, + basename: moment(mtime).format('LLL'), + mime: version.mime, + etag: `${version.props.getetag}`, + size: version.size, + type: version.type, + mtime, + permissions: 'R', + previewUrl, + url: joinPaths('/remote.php/dav', version.filename), + source: generateRemoteUrl('dav') + encodePath(version.filename), + fileVersion: version.basename, + } +} + +export async function setVersionLabel(version: Version, newLabel: string) { + return await client.customRequest( + version.filename, + { + method: 'PROPPATCH', + data: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <d:set> + <d:prop> + <nc:version-label>${newLabel}</nc:version-label> + </d:prop> + </d:set> + </d:propertyupdate>`, + }, + ) +} + +export async function deleteVersion(version: Version) { + await client.deleteFile(version.filename) +} diff --git a/apps/files_versions/src/views/VersionTab.vue b/apps/files_versions/src/views/VersionTab.vue index f2e9576abd0..a643aef439d 100644 --- a/apps/files_versions/src/views/VersionTab.vue +++ b/apps/files_versions/src/views/VersionTab.vue @@ -1,58 +1,90 @@ <!-- - - @copyright 2022 Carl Schwan <carl@carlschwan.eu> - - @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/>. - --> + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <ul data-files-versions-versions-list> - <Version v-for="version in orderedVersions" - :key="version.mtime" - :version="version" - :file-info="fileInfo" - :is-current="version.mtime === fileInfo.mtime" - :is-first-version="version.mtime === initialVersionMtime" - @restore="handleRestore" - @label-update="handleLabelUpdate" - @delete="handleDelete" /> - </ul> + <div class="versions-tab__container"> + <VirtualScrolling v-slot="{ visibleSections }" + :sections="sections" + :header-height="0"> + <ul :aria-label="t('files_versions', 'File versions')" data-files-versions-versions-list> + <template v-if="visibleSections.length === 1"> + <Version v-for="(row) of visibleSections[0].rows" + :key="row.items[0].mtime" + :can-view="canView" + :can-compare="canCompare" + :load-preview="isActive" + :version="row.items[0]" + :file-info="fileInfo" + :is-current="row.items[0].mtime === fileInfo.mtime" + :is-first-version="row.items[0].mtime === initialVersionMtime" + @click="openVersion" + @compare="compareVersion" + @restore="handleRestore" + @label-update-request="handleLabelUpdateRequest(row.items[0])" + @delete="handleDelete" /> + </template> + </ul> + <NcLoadingIcon v-if="loading" slot="loader" class="files-list-viewer__loader" /> + </VirtualScrolling> + <VersionLabelDialog v-if="editedVersion" + :open.sync="showVersionLabelForm" + :version-label="editedVersion.label" + @label-update="handleLabelUpdate" /> + </div> </template> <script> +import path from 'path' + +import { getCurrentUser } from '@nextcloud/auth' import { showError, showSuccess } from '@nextcloud/dialogs' -import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.ts' import Version from '../components/Version.vue' +import VirtualScrolling from '../components/VirtualScrolling.vue' +import VersionLabelDialog from '../components/VersionLabelDialog.vue' export default { name: 'VersionTab', components: { Version, + VirtualScrolling, + VersionLabelDialog, + NcLoadingIcon, + }, + + setup() { + return { + isMobile: useIsMobile(), + } }, + data() { return { fileInfo: null, - /** @type {import('../utils/versions.js').Version[]} */ + isActive: false, + /** @type {import('../utils/versions.ts').Version[]} */ versions: [], loading: false, + showVersionLabelForm: false, + editedVersion: null, } }, computed: { + sections() { + const rows = this.orderedVersions.map(version => ({ key: version.mtime, height: 68, sectionKey: 'versions', items: [version] })) + return [{ key: 'versions', rows, height: 68 * this.orderedVersions.length }] + }, + /** * Order versions by mtime. * Put the current version at the top. * - * @return {import('../utils/versions.js').Version[]} + * @return {import('../utils/versions.ts').Version[]} */ orderedVersions() { return [...this.versions].sort((a, b) => { @@ -68,6 +100,7 @@ export default { /** * Return the mtime of the first version to display "Initial version" label + * * @return {number} */ initialVersionMtime() { @@ -75,6 +108,43 @@ export default { .map(version => version.mtime) .reduce((a, b) => Math.min(a, b)) }, + + viewerFileInfo() { + // We need to remap bitmask to dav permissions as the file info we have is converted through client.js + let davPermissions = '' + if (this.fileInfo.permissions & 1) { + davPermissions += 'R' + } + if (this.fileInfo.permissions & 2) { + davPermissions += 'W' + } + if (this.fileInfo.permissions & 8) { + davPermissions += 'D' + } + return { + ...this.fileInfo, + mime: this.fileInfo.mimetype, + basename: this.fileInfo.name, + filename: this.fileInfo.path + '/' + this.fileInfo.name, + permissions: davPermissions, + fileid: this.fileInfo.id, + } + }, + + /** @return {boolean} */ + canView() { + return window.OCA.Viewer?.mimetypesCompare?.includes(this.fileInfo.mimetype) + }, + + canCompare() { + return !this.isMobile + }, + }, + mounted() { + subscribe('files_versions:restore:restored', this.fetchVersions) + }, + beforeUnmount() { + unsubscribe('files_versions:restore:restored', this.fetchVersions) }, methods: { /** @@ -89,6 +159,13 @@ export default { }, /** + * @param {boolean} isActive whether the tab is active + */ + async setIsActive(isActive) { + this.isActive = isActive + }, + + /** * Get the existing versions infos */ async fetchVersions() { @@ -103,7 +180,7 @@ export default { /** * Handle restored event from Version.vue * - * @param {import('../utils/versions.js').Version} version + * @param {import('../utils/versions.ts').Version} version The version to restore */ async handleRestore(version) { // Update local copy of fileInfo as rendering depends on it. @@ -114,45 +191,65 @@ export default { mtime: version.mtime, } + const restoreStartedEventState = { + preventDefault: false, + fileInfo: this.fileInfo, + version, + } + emit('files_versions:restore:requested', restoreStartedEventState) + if (restoreStartedEventState.preventDefault) { + return + } + try { await restoreVersion(version) - if (version.label !== '') { + if (version.label) { showSuccess(t('files_versions', `${version.label} restored`)) } else if (version.mtime === this.initialVersionMtime) { showSuccess(t('files_versions', 'Initial version restored')) } else { showSuccess(t('files_versions', 'Version restored')) } - await this.fetchVersions() + emit('files_versions:restore:restored', version) } catch (exception) { this.fileInfo = oldFileInfo showError(t('files_versions', 'Could not restore version')) + emit('files_versions:restore:failed', version) } }, /** * Handle label-updated event from Version.vue - * - * @param {import('../utils/versions.js').Version} version - * @param {string} newName + * @param {import('../utils/versions.ts').Version} version The version to update */ - async handleLabelUpdate(version, newName) { - const oldLabel = version.label - version.label = newName + handleLabelUpdateRequest(version) { + this.showVersionLabelForm = true + this.editedVersion = version + }, + + /** + * Handle label-updated event from Version.vue + * @param {string} newLabel The new label + */ + async handleLabelUpdate(newLabel) { + const oldLabel = this.editedVersion.label + this.editedVersion.label = newLabel + this.showVersionLabelForm = false try { - await setVersionLabel(version, newName) + await setVersionLabel(this.editedVersion, newLabel) + this.editedVersion = null } catch (exception) { - version.label = oldLabel - showError(t('files_versions', 'Could not set version name')) + this.editedVersion.label = oldLabel + showError(this.t('files_versions', 'Could not set version label')) + logger.error('Could not set version label', { exception }) } }, /** * Handle deleted event from Version.vue * - * @param {import('../utils/versions.js').Version} version - * @param {string} newName + * @param {import('../utils/versions.ts').Version} version The version to delete */ async handleDelete(version) { const index = this.versions.indexOf(version) @@ -172,6 +269,39 @@ export default { resetState() { this.$set(this, 'versions', []) }, + + openVersion({ version }) { + // Open current file view instead of read only + if (version.mtime === this.fileInfo.mtime) { + OCA.Viewer.open({ fileInfo: this.viewerFileInfo }) + return + } + + // Versions previews are too small for our use case, so we override previewUrl + // which makes the viewer render the original file. + // We also point to the original filename if the version is the current one. + const versions = this.versions.map(version => ({ + ...version, + filename: version.mtime === this.fileInfo.mtime ? path.join('files', getCurrentUser()?.uid ?? '', this.fileInfo.path, this.fileInfo.name) : version.filename, + previewUrl: undefined, + })) + + OCA.Viewer.open({ + fileInfo: versions.find(v => v.source === version.source), + enableSidebar: false, + }) + }, + + compareVersion({ version }) { + const versions = this.versions.map(version => ({ ...version, previewUrl: undefined })) + + OCA.Viewer.compare(this.viewerFileInfo, versions.find(v => v.source === version.source)) + }, }, } </script> +<style lang="scss"> +.versions-tab__container { + height: 100%; +} +</style> |