refactor(files_versions): adjust frontend for new files sidebar API

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-12-28 17:21:43 +01:00
parent 4a9cdeb01f
commit 493c371a22
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
8 changed files with 176 additions and 212 deletions

View file

@ -24,6 +24,6 @@ class LoadAdditionalListener implements IEventListener {
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addStyle(Application::APP_ID, 'sidebar-tab');
Util::addScript(Application::APP_ID, 'sidebar-tab');
Util::addInitScript(Application::APP_ID, 'sidebar-tab');
}
}

View file

@ -24,6 +24,6 @@ class LoadSidebarListener implements IEventListener {
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addStyle(Application::APP_ID, 'sidebar-tab');
Util::addScript(Application::APP_ID, 'sidebar-tab');
Util::addInitScript(Application::APP_ID, 'sidebar-tab');
}
}

View file

@ -8,6 +8,7 @@
:force-display-actions="true"
:actions-aria-label="t('files_versions', 'Actions for version from {versionHumanExplicitDate}', { versionHumanExplicitDate })"
:data-files-versions-version="version.fileVersion"
:href="downloadURL"
@click="click">
<!-- Icon -->
<template #icon>
@ -131,8 +132,8 @@
</template>
<script lang="ts" setup>
import type { INode } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { LegacyFileInfo } from '../../../files/src/services/FileInfo.ts'
import type { Version } from '../utils/versions.ts'
import { getCurrentUser } from '@nextcloud/auth'
@ -140,7 +141,6 @@ import { formatFileSize, Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { join } from '@nextcloud/paths'
import { getRootUrl } from '@nextcloud/router'
import { computed, nextTick, ref } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
@ -161,8 +161,8 @@ const props = defineProps({
required: true,
},
fileInfo: {
type: Object as PropType<LegacyFileInfo>,
node: {
type: Object as PropType<INode>,
required: true,
},
@ -194,8 +194,6 @@ const props = defineProps({
const emit = defineEmits(['click', 'compare', 'restore', 'delete', 'label-update-request'])
const hasPermission = (permissions: number, permission: number): boolean => (permissions & permission) !== 0
const previewLoaded = ref(false)
const previewErrored = ref(false)
const capabilities = ref(loadState('core', 'capabilities', { files: { version_labeling: false, version_deletion: false } }))
@ -240,7 +238,7 @@ const versionHumanExplicitDate = computed(() => {
const downloadURL = computed(() => {
if (props.isCurrent) {
return getRootUrl() + join('/remote.php/webdav', props.fileInfo.path, props.fileInfo.name)
return props.node.source
} else {
return getRootUrl() + props.version.url
}
@ -255,21 +253,21 @@ const enableDeletion = computed(() => {
})
const hasDeletePermissions = computed(() => {
return hasPermission(props.fileInfo.permissions, Permission.DELETE)
return hasPermission(props.node, Permission.DELETE)
})
const hasUpdatePermissions = computed(() => {
return hasPermission(props.fileInfo.permissions, Permission.UPDATE)
return hasPermission(props.node, Permission.UPDATE)
})
const isDownloadable = computed(() => {
if ((props.fileInfo.permissions & Permission.READ) === 0) {
if ((props.node.permissions & Permission.READ) === 0) {
return false
}
// If the mount type is a share, ensure it got download permissions.
if (props.fileInfo.mountType === 'shared') {
const downloadAttribute = props.fileInfo.shareAttributes
if (props.node.attributes['mount-type'] === 'shared' && props.node.attributes['share-attributes']) {
const downloadAttribute = JSON.parse(props.node.attributes['share-attributes'])
.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) {
@ -281,21 +279,21 @@ const isDownloadable = computed(() => {
})
/**
*
* Label update request
*/
function labelUpdate() {
emit('label-update-request')
}
/**
*
* Restore version
*/
function restoreVersion() {
emit('restore', props.version)
}
/**
*
* Delete version
*/
async function deleteVersion() {
// Let @nc-vue properly remove the popover before we delete the version.
@ -306,18 +304,20 @@ async function deleteVersion() {
}
/**
* Handle click on the version entry
*
* @param event - The click event
*/
function click() {
if (!props.canView) {
window.location.href = downloadURL.value
return
function click(event: MouseEvent) {
if (props.canView) {
event.preventDefault()
}
emit('click', { version: props.version })
}
/**
*
* If the user can compare, emit the compare event
*/
function compareVersion() {
if (!props.canView) {
@ -325,6 +325,16 @@ function compareVersion() {
}
emit('compare', { version: props.version })
}
/**
* Check if the current user has the given permission on the node
*
* @param node - The node to check
* @param permission - The permission to check
*/
function hasPermission(node: INode, permission: number): boolean {
return (node.permissions & permission) !== 0
}
</script>
<style scoped lang="scss">

View file

@ -1,50 +1,46 @@
/**
/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { App, ComponentPublicInstance } from 'vue'
import BackupRestore from '@mdi/svg/svg/backup-restore.svg?raw'
import { FileType, registerSidebarTab } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { createApp } from 'vue'
import FilesVersionsSidebarTab from './views/FilesVersionsSidebarTab.vue'
import { isPublicShare } from '@nextcloud/sharing/public'
import { defineAsyncComponent, defineCustomElement } from 'vue'
// Init FilesVersions tab component
let filesVersionsTabApp: App<Element> | null = null
let filesVersionsTabInstance: ComponentPublicInstance<typeof FilesVersionsSidebarTab> | null = null
const tagName = 'files-versions_sidebar-tab'
const FilesVersionsSidebarTab = defineAsyncComponent(() => import('./views/FilesVersionsSidebarTab.vue'))
window.addEventListener('DOMContentLoaded', function() {
if (window.OCA.Files?.Sidebar === undefined) {
registerSidebarTab({
id: 'files_versions',
order: 90,
displayName: t('files_versions', 'Versions'),
iconSvgInline: BackupRestore,
enabled({ node }) {
if (isPublicShare()) {
return false
}
if (node.type !== FileType.File) {
return false
}
// setup tab
setupTab()
return true
},
tagName,
})
/**
* Setup the custom element for the Files Versions sidebar tab.
*/
function setupTab() {
if (window.customElements.get(tagName)) {
// already defined
return
}
window.OCA.Files.Sidebar.registerTab(new window.OCA.Files.Sidebar.Tab({
id: 'files_versions',
name: t('files_versions', 'Versions'),
iconSvg: BackupRestore,
async mount(el, fileInfo) {
// destroy previous instance if available
if (filesVersionsTabApp) {
filesVersionsTabApp.unmount()
}
filesVersionsTabApp = createApp(FilesVersionsSidebarTab)
filesVersionsTabInstance = filesVersionsTabApp.mount(el)
filesVersionsTabInstance.update(fileInfo)
},
update(fileInfo) {
filesVersionsTabInstance!.update(fileInfo)
},
setIsActive(isActive) {
filesVersionsTabInstance?.setIsActive(isActive)
},
destroy() {
filesVersionsTabApp?.unmount()
filesVersionsTabApp = null
},
enabled(fileInfo) {
return !(fileInfo?.isDirectory() ?? true)
},
window.customElements.define(tagName, defineCustomElement(FilesVersionsSidebarTab, {
shadowRoot: false,
}))
})
}

View file

@ -1,33 +0,0 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
import { generateRemoteUrl } from '@nextcloud/router'
import { createClient } from 'webdav'
// init webdav client
const rootPath = 'dav'
const remote = generateRemoteUrl(rootPath)
const client = createClient(remote)
/**
* set CSRF token header
*
* @param token - CSRF token
*/
function 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 ?? '',
})
}
// refresh headers when request token changes
onRequestTokenUpdate(setHeaders)
setHeaders(getRequestToken())
export default client

View file

@ -1,18 +1,17 @@
/* 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 { INode } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getClient } from '@nextcloud/files/dav'
import moment from '@nextcloud/moment'
import { encodePath, join } from '@nextcloud/paths'
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import client from '../utils/davClient.ts'
import davRequest from '../utils/davRequest.ts'
import logger from '../utils/logger.ts'
@ -25,7 +24,7 @@ export interface Version {
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
size: number // File size in bytes
type: string // 'file'
mtime: number // Version creation date as a timestamp
permissions: string // Only readable: 'R'
@ -35,8 +34,15 @@ export interface Version {
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}`
const client = getClient()
/**
* Get file versions for a given node
*
* @param node - The node to fetch versions for
*/
export async function fetchVersions(node: INode): Promise<Version[]> {
const path = `/versions/${getCurrentUser()?.uid}/versions/${node.fileid}`
try {
const response = await client.getDirectoryContents(path, {
@ -47,7 +53,7 @@ export async function fetchVersions(fileInfo: any): Promise<Version[]> {
const versions = response.data
// Filter out root
.filter(({ mime }) => mime !== '')
.map((version) => formatVersion(version, fileInfo))
.map((version) => formatVersion(version as Required<FileStat>, node))
const authorIds = new Set(versions.map((version) => String(version.author)))
const authors = await axios.post(generateUrl('/displaynames'), { users: [...authorIds] })
@ -68,6 +74,8 @@ export async function fetchVersions(fileInfo: any): Promise<Version[]> {
/**
* Restore the given version
*
* @param version - The version to restore
*/
export async function restoreVersion(version: Version) {
try {
@ -84,25 +92,28 @@ export async function restoreVersion(version: Version) {
/**
* Format version
*
* @param version - The version data from WebDAV
* @param node - The original node
*/
function formatVersion(version: any, fileInfo: any): Version {
function formatVersion(version: Required<FileStat>, node: INode): Version {
const mtime = moment(version.lastmod).unix() * 1000
let previewUrl = ''
if (mtime === fileInfo.mtime) { // Version is the current one
if (mtime === node.mtime?.getTime()) { // 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,
fileId: node.fileid,
fileEtag: node.attributes.etag,
})
} else {
previewUrl = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}&mimeFallback=1', {
file: join(fileInfo.path, fileInfo.name),
file: node.path,
fileVersion: version.basename,
})
}
return {
fileId: fileInfo.id,
fileId: node.fileid!.toString(),
// 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'] ? String(version.props['version-author']) : null,
@ -122,6 +133,12 @@ function formatVersion(version: any, fileInfo: any): Version {
}
}
/**
* Set version label
*
* @param version - The version to set the label for
* @param newLabel - The new label
*/
export async function setVersionLabel(version: Version, newLabel: string) {
return await client.customRequest(
version.filename,
@ -142,6 +159,11 @@ export async function setVersionLabel(version: Version, newLabel: string) {
)
}
/**
* Delete version
*
* @param version - The version to delete
*/
export async function deleteVersion(version: Version) {
await client.deleteFile(version.filename)
}

View file

@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div v-if="fileInfo !== null" class="versions-tab__container">
<div v-if="node" class="versions-tab__container">
<VirtualScrolling
:sections="sections"
:header-height="0">
@ -17,8 +17,8 @@
:can-compare="canCompare"
:load-preview="isActive"
:version="row.items[0].version"
:file-info="fileInfo"
:is-current="row.items[0].version.mtime === fileInfo.mtime"
:node="node"
:is-current="row.items[0].version.mtime === currentVersionMtime"
:is-first-version="row.items[0].version.mtime === initialVersionMtime"
@click="openVersion"
@compare="compareVersion"
@ -41,16 +41,14 @@
</template>
<script lang="ts" setup>
import type { LegacyFileInfo } from '../../../files/src/services/FileInfo.ts'
import type { IFolder, INode, IView } from '@nextcloud/files'
import type { Version } from '../utils/versions.ts'
import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import path from 'path'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, ref, toRef, watch } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import VersionEntry from '../components/VersionEntry.vue'
import VersionLabelDialog from '../components/VersionLabelDialog.vue'
@ -58,28 +56,49 @@ import VirtualScrolling from '../components/VirtualScrolling.vue'
import logger from '../utils/logger.ts'
import { deleteVersion, fetchVersions, restoreVersion, setVersionLabel } from '../utils/versions.ts'
const isMobile = useIsMobile()
const props = defineProps<{
node?: INode
folder?: IFolder
view?: IView
}>()
const fileInfo = ref<LegacyFileInfo | null>(null)
defineExpose({ setActive })
const isMobile = useIsMobile()
const isActive = ref<boolean>(false)
const versions = ref<Version[]>([])
const loading = ref(false)
const showVersionLabelForm = ref(false)
const editedVersion = ref<Version | null>(null)
watch(toRef(() => props.node), async () => {
if (!props.node) {
return
}
try {
loading.value = true
versions.value = await fetchVersions(props.node)
} finally {
loading.value = false
}
}, { immediate: true })
const currentVersionMtime = computed(() => props.node?.mtime?.getTime() ?? 0)
/**
* Order versions by mtime.
* Put the current version at the top.
*/
const orderedVersions = computed(() => {
return [...versions.value].sort((a, b) => {
if (fileInfo.value === null) {
if (!props.node) {
return 0
}
if (a.mtime === fileInfo.value.mtime) {
if (a.mtime === props.node.mtime?.getTime()) {
return -1
} else if (b.mtime === fileInfo.value.mtime) {
} else if (b.mtime === props.node.mtime?.getTime()) {
return 1
} else {
return b.mtime - a.mtime
@ -88,7 +107,12 @@ const orderedVersions = computed(() => {
})
const sections = computed(() => {
const rows = orderedVersions.value.map((version) => ({ key: version.mtime.toString(), height: 68, sectionKey: 'versions', items: [{ id: version.mtime.toString(), version }] }))
const rows = orderedVersions.value.map((version) => ({
key: version.mtime.toString(),
height: 68,
sectionKey: 'versions',
items: [{ id: version.mtime.toString(), version }],
}))
return [{ key: 'versions', rows, height: 68 * orderedVersions.value.length }]
})
@ -101,82 +125,26 @@ const initialVersionMtime = computed(() => {
.reduce((a, b) => Math.min(a, b))
})
const viewerFileInfo = computed(() => {
if (fileInfo.value === null) {
return null
}
// We need to remap bitmask to dav permissions as the file info we have is converted through client.js
let davPermissions = ''
if (fileInfo.value.permissions & 1) {
davPermissions += 'R'
}
if (fileInfo.value.permissions & 2) {
davPermissions += 'W'
}
if (fileInfo.value.permissions & 8) {
davPermissions += 'D'
}
return {
...fileInfo.value,
mime: fileInfo.value.mimetype,
basename: fileInfo.value.name,
filename: fileInfo.value.path + '/' + fileInfo.value.name,
permissions: davPermissions,
fileid: fileInfo.value.id,
}
})
const canView = computed(() => {
if (fileInfo.value === null) {
if (!props.node) {
return false
}
return window.OCA.Viewer?.mimetypesCompare?.includes(fileInfo.value.mimetype)
return window.OCA.Viewer?.mimetypes?.includes(props.node?.mime)
})
const canCompare = computed(() => {
return !isMobile.value
})
onMounted(() => {
subscribe('files_versions:restore:restored', fetchVersions)
})
onBeforeUnmount(() => {
unsubscribe('files_versions:restore:restored', fetchVersions)
})
defineExpose({
/**
* Update current fileInfo and fetch new data
*
* @param _fileInfo the current file FileInfo
*/
async update(_fileInfo: LegacyFileInfo) {
fileInfo.value = _fileInfo
resetState()
internalFetchVersions()
},
/**
* @param _isActive whether the tab is active
*/
async setIsActive(_isActive: boolean) {
isActive.value = _isActive
},
&& window.OCA.Viewer?.mimetypesCompare?.includes(props.node?.mime)
})
/**
* Get the existing versions infos
* This method is called by the files app if the sidebar tab state changes.
*
* @param active - The new active state
*/
async function internalFetchVersions() {
try {
loading.value = true
versions.value = await fetchVersions(fileInfo.value)
} finally {
loading.value = false
}
function setActive(active: boolean) {
isActive.value = active
}
/**
@ -185,17 +153,19 @@ async function internalFetchVersions() {
* @param version The version to restore
*/
async function handleRestore(version: Version) {
// Update local copy of fileInfo as rendering depends on it.
const oldFileInfo = fileInfo.value
fileInfo.value = {
...fileInfo.value,
size: version.size,
mtime: version.mtime,
if (!props.node) {
return
}
// Update local copy of fileInfo as rendering depends on it.
const restoredNode = props.node.clone()
restoredNode.attributes.etag = version.etag
restoredNode.size = version.size
restoredNode.mtime = new Date(version.mtime)
const restoreStartedEventState = {
preventDefault: false,
fileInfo: fileInfo.value,
node: restoredNode,
version,
}
emit('files_versions:restore:requested', restoreStartedEventState)
@ -212,9 +182,9 @@ async function handleRestore(version: Version) {
} else {
showSuccess(t('files_versions', 'Version restored'))
}
emit('files_versions:restore:restored', version)
emit('files:node:updated', restoredNode)
emit('files_versions:restore:restored', { node: restoredNode, version })
} catch {
fileInfo.value = oldFileInfo
showError(t('files_versions', 'Could not restore version'))
emit('files_versions:restore:failed', version)
}
@ -271,25 +241,18 @@ async function handleDelete(version: Version) {
}
}
/**
* Reset the current view to its default state
*/
function resetState() {
versions.value = []
}
/**
* @param payload - The event payload
* @param payload.version - The version to open
*/
function openVersion({ version }: { version: Version }) {
if (fileInfo.value === null) {
if (props.node === null) {
return
}
// Open current file view instead of read only
if (version.mtime === fileInfo.value.mtime) {
window.OCA.Viewer.open({ fileInfo: viewerFileInfo.value })
if (version.mtime === props.node?.mtime?.getTime()) {
window.OCA.Viewer.open({ path: props.node.path })
return
}
@ -298,7 +261,7 @@ function openVersion({ version }: { version: Version }) {
...version,
// Versions previews are too small for our use case, so we override previewUrl
// to either point to the original file or original version.
filename: version.mtime === fileInfo.value.mtime ? path.join('files', getCurrentUser()?.uid ?? '', fileInfo.value.path, fileInfo.value.name) : version.filename,
filename: version.filename,
previewUrl: undefined,
},
enableSidebar: false,
@ -312,7 +275,10 @@ function openVersion({ version }: { version: Version }) {
function compareVersion({ version }: { version: Version }) {
const _versions = versions.value.map((version) => ({ ...version, previewUrl: undefined }))
window.OCA.Viewer.compare(viewerFileInfo.value, _versions.find((v) => v.source === version.source))
window.OCA.Viewer.compare(
{ path: props.node!.path },
_versions.find((v) => v.source === version.source),
)
}
</script>

View file

@ -6,6 +6,8 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import type { ShareSetting } from '../files_sharing/FilesSharingUtils.ts'
import { basename } from '@nextcloud/paths'
import { triggerActionForFile } from '../files/FilesUtils.ts'
import { createShare } from '../files_sharing/FilesSharingUtils.ts'
export function uploadThreeVersions(user: User, fileName: string) {
@ -24,11 +26,10 @@ export function openVersionsPanel(fileName: string) {
// Detect the versions list fetch
cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('getVersions')
// Open the versions tab
cy.window().then((win) => {
win.OCA.Files.Sidebar.setActiveTab('files_versions')
win.OCA.Files.Sidebar.open(`/${fileName}`)
})
triggerActionForFile(basename(fileName), 'details')
cy.get('[data-cy-sidebar]')
.find('[aria-controls="tab-files_versions"]')
.click()
// Wait for the versions list to be fetched
cy.wait('@getVersions')
@ -85,6 +86,8 @@ export function setupTestSharedFileFromUser(owner: User, randomFileName: string,
cy.login(owner)
cy.visit('/apps/files')
createShare(randomFileName, recipient.userId, shareOptions)
cy.logout()
cy.login(recipient)
cy.visit('/apps/files')
return cy.wrap(recipient)