Merge pull request #57277 from nextcloud/refactor/files-sidebar-nodeapi

refactor!: migrate files sidebar to Node API
This commit is contained in:
Ferdinand Thiessen 2026-01-05 13:38:26 +01:00 committed by GitHub
commit c50c5a9e6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
565 changed files with 3944 additions and 6112 deletions

View file

@ -180,14 +180,12 @@ describe('Inline unread comments action enabled tests', () => {
describe('Inline unread comments action execute tests', () => {
test('Action opens sidebar', async () => {
const openMock = vi.fn()
const setActiveTabMock = vi.fn()
window.OCA = {
Files: {
// @ts-expect-error Mocking for testing
Sidebar: {
_sidebar: () => ({
open: openMock,
setActiveTab: setActiveTabMock,
},
}),
},
}
@ -211,22 +209,19 @@ describe('Inline unread comments action execute tests', () => {
})
expect(result).toBe(null)
expect(setActiveTabMock).toBeCalledWith('comments')
expect(openMock).toBeCalledWith('/foobar.txt')
expect(openMock).toBeCalledWith(file, 'comments')
})
test('Action handles sidebar open failure', async () => {
const openMock = vi.fn(() => {
throw new Error('Mock error')
})
const setActiveTabMock = vi.fn()
window.OCA = {
Files: {
// @ts-expect-error Mocking for testing
Sidebar: {
_sidebar: () => ({
open: openMock,
setActiveTab: setActiveTabMock,
},
}),
},
}
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
@ -251,8 +246,7 @@ describe('Inline unread comments action execute tests', () => {
})
expect(result).toBe(false)
expect(setActiveTabMock).toBeCalledWith('comments')
expect(openMock).toBeCalledWith('/foobar.txt')
expect(openMock).toBeCalledWith(file, 'comments')
expect(logger.error).toBeCalledTimes(1)
})
})

View file

@ -1,9 +1,10 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import CommentProcessingSvg from '@mdi/svg/svg/comment-processing.svg?raw'
import { FileAction } from '@nextcloud/files'
import { FileAction, getSidebar } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import logger from '../logger.js'
@ -34,8 +35,8 @@ export const action = new FileAction({
}
try {
window.OCA.Files.Sidebar.setActiveTab('comments')
await window.OCA.Files.Sidebar.open(nodes[0].path)
const sidebar = getSidebar()
sidebar.open(nodes[0], 'comments')
return null
} catch (error) {
logger.error('Error while opening sidebar', { error })

View file

@ -1,59 +0,0 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw'
import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { registerCommentsPlugins } from './comments-activity-tab.ts'
// @ts-expect-error __webpack_nonce__ is injected by webpack
__webpack_nonce__ = getCSPNonce()
if (loadState('comments', 'activityEnabled', false) && OCA?.Activity?.registerSidebarAction !== undefined) {
// Do not mount own tab but mount into activity
window.addEventListener('DOMContentLoaded', function() {
registerCommentsPlugins()
})
} else {
// Init Comments tab component
let TabInstance = null
const commentTab = new OCA.Files.Sidebar.Tab({
id: 'comments',
name: t('comments', 'Comments'),
iconSvg: MessageReplyText,
async mount(el, fileInfo, context) {
if (TabInstance) {
TabInstance.$destroy()
}
TabInstance = new OCA.Comments.View('files', {
// Better integration with vue parent component
parent: context,
propsData: {
resourceId: fileInfo.id,
},
})
// Only mount after we have all the info we need
await TabInstance.update(fileInfo.id)
TabInstance.$mount(el)
},
update(fileInfo) {
TabInstance.update(fileInfo.id)
},
destroy() {
TabInstance.$destroy()
TabInstance = null
},
scrollBottomReached() {
TabInstance.onScrollBottomReached()
},
})
window.addEventListener('DOMContentLoaded', function() {
if (OCA.Files && OCA.Files.Sidebar) {
OCA.Files.Sidebar.registerTab(commentTab)
}
})
}

View file

@ -0,0 +1,57 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw'
import { getCSPNonce } from '@nextcloud/auth'
import { registerSidebarTab } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import wrap from '@vue/web-component-wrapper'
import { createPinia, PiniaVuePlugin } from 'pinia'
import Vue from 'vue'
import FilesSidebarTab from './views/FilesSidebarTab.vue'
import { registerCommentsPlugins } from './comments-activity-tab.ts'
__webpack_nonce__ = getCSPNonce()
const tagName = 'comments_files-sidebar-tab'
if (loadState('comments', 'activityEnabled', false) && OCA?.Activity?.registerSidebarAction !== undefined) {
// Do not mount own tab but mount into activity
window.addEventListener('DOMContentLoaded', function() {
registerCommentsPlugins()
})
} else {
registerSidebarTab({
id: 'comments',
displayName: t('comments', 'Comments'),
iconSvgInline: MessageReplyText,
order: 50,
tagName,
enabled() {
if (!window.customElements.get(tagName)) {
setupSidebarTab()
}
return true
},
})
}
/**
* Setup the sidebar tab as a web component
*/
function setupSidebarTab() {
Vue.use(PiniaVuePlugin)
Vue.mixin({ pinia: createPinia() })
const webComponent = wrap(Vue, FilesSidebarTab)
// In Vue 2, wrap doesn't support disabling shadow. Disable with a hack
Object.defineProperty(webComponent.prototype, 'attachShadow', {
value() { return this },
})
Object.defineProperty(webComponent.prototype, 'shadowRoot', {
get() { return this },
})
window.customElements.define(tagName, webComponent)
}

View file

@ -1,8 +1,9 @@
import { getCurrentUser } from '@nextcloud/auth'
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
@ -32,7 +33,7 @@ export default defineComponent({
},
methods: {
/**
* Autocomplete @mentions
* Autocomplete `@mentions`
*
* @param search the query
* @param callback the callback to process the results with

View file

@ -0,0 +1,40 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IFolder, INode, IView } from '@nextcloud/files'
import { computed } from 'vue'
import Comments from './Comments.vue'
const props = defineProps<{
node?: INode
// eslint-disable-next-line vue/no-unused-properties -- Required on the web component interface
folder?: IFolder
// eslint-disable-next-line vue/no-unused-properties -- Required on the web component interface
view?: IView
}>()
defineExpose({ setActive })
const resourceId = computed(() => props.node?.fileid)
/**
* Set this tab as active
*
* @param active - The active state
*/
function setActive(active: boolean) {
return active
}
</script>
<template>
<Comments
v-if="resourceId !== undefined"
:key="resourceId"
:resource-id="resourceId"
resource-type="files" />
</template>

View file

@ -6,35 +6,19 @@
<NcContent app-name="files">
<FilesNavigation v-if="!isPublic" />
<FilesList :is-public="isPublic" />
<FilesSidebar v-if="!isPublic" />
</NcContent>
</template>
<script lang="ts">
<script setup lang="ts">
import { isPublicShare } from '@nextcloud/sharing/public'
import { defineComponent } from 'vue'
import NcContent from '@nextcloud/vue/components/NcContent'
import FilesList from './views/FilesList.vue'
import FilesNavigation from './views/FilesNavigation.vue'
import FilesSidebar from './views/FilesSidebar.vue'
import { useHotKeys } from './composables/useHotKeys.ts'
export default defineComponent({
name: 'FilesApp',
useHotKeys()
components: {
NcContent,
FilesList,
FilesNavigation,
},
setup() {
// Register global hotkeys
useHotKeys()
const isPublic = isPublicShare()
return {
isPublic,
}
},
})
const isPublic = isPublicShare()
</script>

View file

@ -217,7 +217,7 @@ describe('Favorite action execute tests', () => {
// Check node change propagation
expect(file.attributes.favorite).toBe(1)
expect(eventBus.emit).toBeCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalled()
expect(eventBus.emit).toBeCalledWith('files:favorites:added', file)
})
@ -251,7 +251,7 @@ describe('Favorite action execute tests', () => {
// Check node change propagation
expect(file.attributes.favorite).toBe(0)
expect(eventBus.emit).toBeCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalled()
expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
})
@ -285,9 +285,9 @@ describe('Favorite action execute tests', () => {
// Check node change propagation
expect(file.attributes.favorite).toBe(0)
expect(eventBus.emit).toBeCalledTimes(2)
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file)
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:favorites:removed', file)
expect(eventBus.emit).toHaveBeenCalled()
expect(eventBus.emit).toHaveBeenCalledWith('files:node:deleted', file)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', file)
})
test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => {
@ -320,7 +320,7 @@ describe('Favorite action execute tests', () => {
// Check node change propagation
expect(file.attributes.favorite).toBe(0)
expect(eventBus.emit).toBeCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalled()
expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
})

View file

@ -1,8 +1,9 @@
/**
/*
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View } from '@nextcloud/files'
import type { INode, IView } from '@nextcloud/files'
import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
@ -26,17 +27,18 @@ const queue = new PQueue({ concurrency: 5 })
*
* @param nodes - The nodes to check
*/
function shouldFavorite(nodes: Node[]): boolean {
function shouldFavorite(nodes: INode[]): boolean {
return nodes.some((node) => node.attributes.favorite !== 1)
}
/**
* Favorite or unfavorite a node
*
* @param node
* @param view
* @param willFavorite
* @param node - The node to favorite/unfavorite
* @param view - The current view
* @param willFavorite - Whether to favorite or unfavorite the node
*/
export async function favoriteNode(node: Node, view: View, willFavorite: boolean): Promise<boolean> {
export async function favoriteNode(node: INode, view: IView, willFavorite: boolean): Promise<boolean> {
try {
// TODO: migrate to webdav tags plugin
const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path)
@ -55,6 +57,7 @@ export async function favoriteNode(node: Node, view: View, willFavorite: boolean
// Update the node webdav attribute
Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0)
emit('files:node:updated', node)
// Dispatch event to whoever is interested
if (willFavorite) {

View file

@ -3,17 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { IView } from '@nextcloud/files'
import { File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import logger from '../logger.ts'
import { action } from './sidebarAction.ts'
const sidebar = vi.hoisted(() => ({
available: true,
open: vi.fn(),
}))
vi.mock('@nextcloud/files', async (original) => ({
...(await original()),
getSidebar: () => sidebar,
}))
const view = {
id: 'files',
name: 'Files',
} as View
} as IView
beforeEach(() => {
sidebar.available = true
vi.clearAllMocks()
})
describe('Open sidebar action conditions tests', () => {
test('Default values', () => {
@ -38,9 +53,6 @@ describe('Open sidebar action conditions tests', () => {
describe('Open sidebar action enabled tests', () => {
test('Enabled for ressources within user root folder', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
@ -60,9 +72,6 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled without permissions', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
@ -82,9 +91,6 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled if more than one node', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file1 = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
@ -110,8 +116,7 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled if no Sidebar', () => {
// @ts-expect-error mocking for tests
window.OCA = {}
sidebar.available = false
const file = new File({
id: 1,
@ -131,9 +136,6 @@ describe('Open sidebar action enabled tests', () => {
})
test('Disabled for non-dav ressources', () => {
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: {} } }
const file = new File({
id: 1,
source: 'https://domain.com/documents/admin/foobar.txt',
@ -154,14 +156,7 @@ describe('Open sidebar action enabled tests', () => {
describe('Open sidebar action exec tests', () => {
test('Open sidebar', async () => {
const openMock = vi.fn()
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
const goToRouteMock = vi.fn()
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
sidebar.available = true
const file = new File({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
@ -177,33 +172,17 @@ describe('Open sidebar action exec tests', () => {
root: '/files/admin',
})
const exec = await action.exec({
// Silent action
expect(await action.exec({
nodes: [file],
view,
folder,
contents: [],
})
// Silent action
expect(exec).toBe(null)
expect(openMock).toBeCalledWith('/foobar.txt')
expect(defaultTabMock).toBeCalledWith('sharing')
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/', opendetails: 'true' },
true,
)
})).toBeNull()
expect(sidebar.open).toBeCalledWith(file, 'sharing')
})
test('Open sidebar for folder', async () => {
const openMock = vi.fn()
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
const goToRouteMock = vi.fn()
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const file = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar',
@ -227,23 +206,13 @@ describe('Open sidebar action exec tests', () => {
})
// Silent action
expect(exec).toBe(null)
expect(openMock).toBeCalledWith('/foobar')
expect(defaultTabMock).toBeCalledWith('sharing')
expect(goToRouteMock).toBeCalledWith(
null,
{ view: view.id, fileid: '1' },
{ dir: '/', opendetails: 'true' },
true,
)
expect(sidebar.open).toBeCalledWith(file, 'sharing')
})
test('Open sidebar fails', async () => {
const openMock = vi.fn(() => {
throw new Error('Mock error')
sidebar.open.mockImplementationOnce(() => {
throw new Error('Sidebar error')
})
const defaultTabMock = vi.fn()
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
const file = new File({
@ -261,7 +230,7 @@ describe('Open sidebar action exec tests', () => {
contents: [],
})
expect(exec).toBe(false)
expect(openMock).toBeCalledTimes(1)
expect(logger.error).toBeCalledTimes(1)
expect(sidebar.open).toHaveBeenCalledOnce()
expect(logger.error).toHaveBeenCalledOnce()
})
})

View file

@ -1,10 +1,11 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import InformationSvg from '@mdi/svg/svg/information-outline.svg?raw'
import { FileAction, Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { FileAction, getSidebar, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { isPublicShare } from '@nextcloud/sharing/public'
import logger from '../logger.ts'
@ -17,49 +18,34 @@ export const action = new FileAction({
// Sidebar currently supports user folder only, /files/USER
enabled: ({ nodes }) => {
const node = nodes[0]
if (nodes.length !== 1 || !node) {
return false
}
const sidebar = getSidebar()
if (!sidebar.available) {
return false
}
if (isPublicShare()) {
return false
}
// Only works on single node
if (nodes.length !== 1) {
return false
}
if (!nodes[0]) {
return false
}
// Only work if the sidebar is available
if (!window?.OCA?.Files?.Sidebar) {
return false
}
return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false
return node.root.startsWith('/files/') && node.permissions !== Permission.NONE
},
async exec({ nodes, view, folder }) {
const node = nodes[0]
async exec({ nodes }) {
const sidebar = getSidebar()
const [node] = nodes
try {
// If the sidebar is already open for the current file, do nothing
if (window.OCA.Files?.Sidebar?.file === node.path) {
if (sidebar.node?.source === node.source) {
logger.debug('Sidebar already open for this file', { node })
return null
}
// Open sidebar and set active tab to sharing by default
window.OCA.Files?.Sidebar?.setActiveTab('sharing')
// TODO: migrate Sidebar to use a Node instead
await window.OCA.Files?.Sidebar?.open(node.path)
// Silently update current fileid
window.OCP?.Files?.Router?.goToRoute(
null,
{ view: view.id, fileid: String(node.fileid) },
{ ...window.OCP.Files.Router.query, dir: folder.path, opendetails: 'true' },
true,
)
sidebar.open(node, 'sharing')
return null
} catch (error) {
logger.error('Error while opening sidebar', { error })

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import starOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
import starSvg from '@mdi/svg/svg/star.svg?raw'
import { registerSidebarAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { favoriteNode } from './favoriteAction.ts'
/**
* Register the favorite/unfavorite action in the sidebar
*/
export function registerSidebarFavoriteAction() {
registerSidebarAction({
id: 'files-favorite',
order: 0,
enabled({ node }) {
return node.isDavResource && node.root.startsWith('/files/')
},
displayName({ node }) {
if (node.attributes.favorite) {
return t('files', 'Unfavorite')
}
return t('files', 'Favorite')
},
iconSvgInline({ node }) {
if (node.attributes.favorite) {
return starSvg
}
return starOutlineSvg
},
onClick({ node, view }) {
favoriteNode(node, view, !node.attributes.favorite)
},
})
}

View file

@ -8,7 +8,7 @@
<FolderOpenIcon v-if="dragover" v-once />
<template v-else>
<FolderIcon v-once />
<OverlayIcon
<component
:is="folderOverlay"
v-if="folderOverlay"
class="files-list__row-icon-overlay" />
@ -42,7 +42,7 @@
<FavoriteIcon v-once />
</span>
<OverlayIcon
<component
:is="fileOverlay"
v-if="fileOverlay"
class="files-list__row-icon-overlay files-list__row-icon-overlay--file" />
@ -56,11 +56,9 @@ import type { UserConfig } from '../../types.ts'
import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
import { decode } from 'blurhash'
import { defineComponent } from 'vue'
import { computed, defineComponent, toRef } from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
@ -73,6 +71,7 @@ import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import CollectivesIcon from './CollectivesIcon.vue'
import FavoriteIcon from './FavoriteIcon.vue'
import { usePreviewImage } from '../../composables/usePreviewImage.ts'
import logger from '../../logger.ts'
import { isLivePhoto } from '../../services/LivePhotos.ts'
import { useUserConfigStore } from '../../store/userconfig.ts'
@ -111,16 +110,19 @@ export default defineComponent({
},
},
setup() {
setup(props) {
const userConfigStore = useUserConfigStore()
const isPublic = isPublicShare()
const publicSharingToken = getSharingToken()
const previewUrl = usePreviewImage(
toRef(props, 'source'),
computed(() => ({
crop: userConfigStore.userConfig.crop_image_previews === true,
size: props.gridMode ? 128 : 32,
})),
)
return {
userConfigStore,
isPublic,
publicSharingToken,
previewUrl,
}
},
@ -140,60 +142,6 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
cropPreviews(): boolean {
return this.userConfig.crop_image_previews === true
},
previewUrl() {
if (this.source.type === FileType.Folder) {
return null
}
if (this.backgroundFailed === true) {
return null
}
if (this.source.attributes['has-preview'] !== true
&& this.source.mime !== undefined
&& this.source.mime !== 'application/octet-stream'
) {
const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
mime: this.source.mime,
})
const url = new URL(window.location.origin + previewUrl)
return url.href
}
try {
const previewUrl = this.source.attributes.previewUrl
|| (this.isPublic
? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
token: this.publicSharingToken,
file: this.source.path,
})
: generateUrl('/core/preview?fileId={fileid}', {
fileid: String(this.source.fileid),
})
)
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', this.gridMode ? '128' : '32')
url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
// Etag to force refresh preview on change
const etag = this.source?.attributes?.etag || ''
url.searchParams.set('v', etag.slice(0, 6))
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch {
return null
}
},
fileOverlay() {
if (isLivePhoto(this.source)) {
return PlayCircleIcon

View file

@ -77,8 +77,7 @@ import type { ComponentPublicInstance, PropType } from 'vue'
import type { UserConfig } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { FileType, Folder, getFileActions, Permission, View } from '@nextcloud/files'
import { FileType, Folder, getFileActions, getSidebar, Permission, View } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
@ -90,7 +89,6 @@ import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import VirtualList from './VirtualList.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
@ -134,6 +132,7 @@ export default defineComponent({
},
setup() {
const sidebar = getSidebar()
const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
@ -148,6 +147,7 @@ export default defineComponent({
openDetails,
openFile,
sidebar,
activeStore,
selectionStore,
userConfigStore,
@ -270,20 +270,18 @@ export default defineComponent({
// Add events on parent to cover both the table and DragAndDrop notice
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
subscribe('files:sidebar:closed', this.onSidebarClosed)
},
beforeUnmount() {
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
unsubscribe('files:sidebar:closed', this.onSidebarClosed)
},
methods: {
handleOpenQueries() {
// If the list is empty, or we don't have a fileId,
// there's nothing to be done.
if (this.isEmpty || !this.fileId) {
if (this.isEmpty || this.fileId === null) {
return
}
@ -311,22 +309,12 @@ export default defineComponent({
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find((n) => n.fileid === fileId) as NcNode
if (node && sidebarAction?.enabled?.({
nodes: [node],
folder: this.currentFolder,
view: this.currentView,
contents: this.nodes,
})) {
if (node && this.sidebar.available) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec({
nodes: [node],
folder: this.currentFolder,
view: this.currentView,
contents: this.nodes,
})
return
this.sidebar.open(node)
} else {
logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
}
logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
},
scrollToFile(fileId: number | null, warn = true) {
@ -363,19 +351,6 @@ export default defineComponent({
)
},
// When sidebar is closed, we remove the openDetails parameter from the URL
onSidebarClosed() {
if (this.openDetails) {
const query = { ...this.$route.query }
delete query.opendetails
window.OCP.Files.Router.goToRoute(
null,
this.$route.params,
query,
)
}
},
/**
* Handle opening a file (e.g. by ?openfile=true)
*

View file

@ -0,0 +1,65 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { INode } from '@nextcloud/files'
import { mdiStar } from '@mdi/js'
import { formatFileSize } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
const props = defineProps<{ node: INode }>()
const isFavourited = computed(() => props.node.attributes.favorite === 1)
const size = computed(() => formatFileSize(props.node.size ?? 0))
</script>
<template>
<div :class="$style.filesSidebarSubname">
<NcIconSvgWrapper
v-if="isFavourited"
inline
:path="mdiStar"
:name="t('files', 'Favorite')" />
<span>{{ size }}</span>
<span v-if="node.mtime">
<span :class="$style.filesSidebarSubname__separator"></span>
<NcDateTime :timestamp="node.mtime" />
</span>
<template v-if="node.owner">
<span :class="$style.filesSidebarSubname__separator"></span>
<NcUserBubble
:class="$style.filesSidebarSubname__userBubble"
:title="t('files', 'Owner')"
:user="node.owner"
:display-name="node.attributes['owner-display-name']" />
</template>
</div>
</template>
<style module>
.filesSidebarSubname {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 8px;
}
.filesSidebarSubname__separator {
display: inline-block;
font-weight: bold !important;
}
.filesSidebarSubname__userBubble {
display: inline-flex !important;
}
</style>

View file

@ -0,0 +1,69 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { ISidebarTab, SidebarComponent } from '@nextcloud/files'
import { NcIconSvgWrapper, NcLoadingIcon } from '@nextcloud/vue'
import { ref, toRef, watch, watchEffect } from 'vue'
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import { useActiveStore } from '../../store/active.ts'
import { useSidebarStore } from '../../store/sidebar.ts'
const props = defineProps<{
/**
* If this is the currently active tab
*/
active: boolean
/**
* The sidebar tab definition.
*/
tab: ISidebarTab
}>()
const sidebar = useSidebarStore()
const activeStore = useActiveStore()
const loading = ref(true)
watch(toRef(props, 'tab'), async () => {
loading.value = true
await window.customElements.whenDefined(props.tab.tagName)
loading.value = false
}, { immediate: true })
const tabElement = ref<SidebarComponent>()
watchEffect(async () => {
if (tabElement.value) {
// Mark as active
await tabElement.value.setActive?.(props.active)
}
})
</script>
<template>
<NcAppSidebarTab
:id="tab.id"
:order="tab.order"
:name="tab.displayName">
<template #icon>
<NcIconSvgWrapper :svg="tab.iconSvgInline" />
</template>
<NcEmptyContent v-if="loading">
<template #icon>
<NcLoadingIcon />
</template>
</NcEmptyContent>
<component
:is="tab.tagName"
v-else
ref="tabElement"
:node.prop="sidebar.currentNode"
:folder.prop="activeStore.activeFolder"
:view.prop="activeStore.activeView" />
</NcAppSidebarTab>
</template>

View file

@ -1,44 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div />
</template>
<script>
export default {
name: 'LegacyView',
props: {
component: {
type: Object,
required: true,
},
fileInfo: {
type: Object,
required: true,
},
},
watch: {
fileInfo(fileInfo) {
// update the backbone model FileInfo
this.setFileInfo(fileInfo)
},
},
mounted() {
// append the backbone element and set the FileInfo
this.component.$el.replaceAll(this.$el)
this.setFileInfo(this.fileInfo)
},
methods: {
setFileInfo(fileInfo) {
this.component.setFileInfo(fileInfo)
},
},
}
</script>

View file

@ -1,119 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppSidebarTab
:id="id"
ref="tab"
:name="name"
:icon="icon"
@bottomReached="onScrollBottomReached">
<template #icon>
<slot name="icon" />
</template>
<!-- Fallback loading -->
<NcEmptyContent v-if="loading" icon="icon-loading" />
<!-- Using a dummy div as Vue mount replace the element directly
It does NOT append to the content -->
<div ref="mount" />
</NcAppSidebarTab>
</template>
<script>
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
components: {
NcAppSidebarTab,
NcEmptyContent,
},
props: {
fileInfo: {
type: Object,
required: true,
},
id: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
/**
* Lifecycle methods.
* They are prefixed with `on` to avoid conflict with Vue
* methods like this.destroy
*/
onMount: {
type: Function,
required: true,
},
onUpdate: {
type: Function,
required: true,
},
onDestroy: {
type: Function,
required: true,
},
onScrollBottomReached: {
type: Function,
default: () => {},
},
},
data() {
return {
loading: true,
}
},
computed: {
// TODO: implement a better way to force pass a prop from Sidebar
activeTab() {
return this.$parent.activeTab
},
},
watch: {
async fileInfo(newFile, oldFile) {
// Update fileInfo on change
if (newFile.id !== oldFile.id) {
this.loading = true
await this.onUpdate(this.fileInfo)
this.loading = false
}
},
},
async mounted() {
this.loading = true
// Mount the tab: mounting point, fileInfo, vue context
await this.onMount(this.$refs.mount, this.fileInfo, this.$refs.tab)
this.loading = false
},
async beforeDestroy() {
// unmount the tab
await this.onDestroy()
},
}
</script>

View file

@ -4,6 +4,7 @@
*/
import type { View } from '@nextcloud/files'
import type { Mock } from 'vitest'
import type { Location } from 'vue-router'
import axios from '@nextcloud/axios'
@ -104,7 +105,7 @@ describe('HotKeysService testing', () => {
activeStore.activeFolder = root
// @ts-expect-error mocking for tests
window.OCA = { Files: { Sidebar: { async open() {}, setActiveTab: () => {} } } }
window.OCA = { Files: { _sidebar: () => ({ open() {} }) } }
initialState = document.createElement('input')
initialState.setAttribute('type', 'hidden')
initialState.setAttribute('id', 'initial-state-files_trashbin-config')
@ -143,6 +144,9 @@ describe('HotKeysService testing', () => {
})
it('Pressing s should toggle favorite', () => {
(favoriteAction.enabled as Mock).mockReturnValue(true);
(favoriteAction.exec as Mock).mockImplementationOnce(() => Promise.resolve(null))
vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
dispatchEvent({ key: 's', code: 'KeyS' })
@ -152,7 +156,6 @@ describe('HotKeysService testing', () => {
dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true })
dispatchEvent({ key: 's', code: 'KeyS', metaKey: true })
expect(favoriteAction.enabled).toHaveReturnedWith(true)
expect(favoriteAction.exec).toHaveBeenCalledOnce()
})

View file

@ -0,0 +1,86 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import type { MaybeRefOrGetter } from '@vueuse/core'
import { FileType } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
import { toValue } from '@vueuse/core'
import { computed } from 'vue'
/**
* Get the preview URL for a given node.
*
* @param node - The node to get the preview for
* @param options - The preview options
* @param options.crop - Whether to crop the preview (default: true)
* @param options.fallback - Whether to use a mime type icon as fallback (default: true)
* @param options.size - The size of the preview in pixels (default: 128). Can be a number or a tuple [width, height]
*/
export function usePreviewImage(
node: MaybeRefOrGetter<INode | undefined>,
options: MaybeRefOrGetter<{ crop?: boolean, fallback?: boolean, size?: number | [number, number] }> = {},
) {
return computed(() => {
const source = toValue(node)
if (!source) {
return
}
if (source.type === FileType.Folder) {
return
}
const fallback = toValue(options).fallback ?? true
if (source.attributes['has-preview'] !== true
&& source.mime !== undefined
&& source.mime !== 'application/octet-stream'
) {
if (!fallback) {
return
}
const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
mime: source.mime,
})
const url = new URL(window.location.origin + previewUrl)
return url.href
}
const crop = toValue(options).crop ?? true
const [sizeX, sizeY] = [toValue(options).size ?? 128].flat()
try {
const previewUrl = source.attributes.previewUrl
|| (isPublicShare()
? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
token: getSharingToken()!,
file: source.path,
})
: generateUrl('/core/preview?fileId={fileid}', {
fileid: String(source.fileid),
})
)
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', sizeX.toString())
url.searchParams.set('y', (sizeY ?? sizeX).toString())
url.searchParams.set('mimeFallback', fallback.toString())
// Etag to force refresh preview on change
const etag = source.attributes.etag || source.mtime?.getTime() || ''
url.searchParams.set('v', etag.slice(0, 6))
// Handle cropping
url.searchParams.set('a', crop ? '0' : '1')
return url.href
} catch {
return
}
})
}

View file

@ -3,33 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListFilter, Node, View } from '@nextcloud/files'
import type { IFileListFilter, INode, IView } from '@nextcloud/files'
import type { SearchScope, UserConfig } from './types.ts'
declare module '@nextcloud/event-bus' {
export interface NextcloudEvents {
// mapping of 'event name' => 'event type'
'files:config:updated': { key: string, value: UserConfig[string] }
'files:view-config:updated': { key: string, value: string | number | boolean, view: string }
'files:view-config:updated': { key: string, value: string | number | boolean, IView: string }
'files:favorites:removed': Node
'files:favorites:added': Node
'files:favorites:added': INode
'files:favorites:removed': INode
'files:filter:added': IFileListFilter
'files:filter:removed': string
// the state of some filters has changed
'files:filters:changed': undefined
'files:navigation:changed': View
'files:navigation:changed': IView
'files:node:created': Node
'files:node:deleted': Node
'files:node:updated': Node
'files:node:rename': Node
'files:node:renamed': Node
'files:node:moved': { node: Node, oldSource: string }
'files:node:created': INode
'files:node:deleted': INode
'files:node:updated': INode
'files:node:rename': INode
'files:node:renamed': INode
'files:node:moved': { INode: INode, oldSource: string }
'files:search:updated': { query: string, scope: SearchScope }
'files:sidebar:opened': INode
'files:sidebar:closed': undefined
}
}

29
apps/files/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,29 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ISidebar } from '@nextcloud/files'
import type { Pinia } from 'pinia'
import type Router from './services/RouterService.ts'
declare global {
interface Window {
/** private pinia instance to share it between entry points (needed with Webpack) */
_nc_files_pinia: Pinia
OCP: {
Files: {
/** The files router service to allow apps to interact with the files router instance */
Router: Router
}
}
OCA: Record<string, unknown> & {
Files?: {
/** private implementation of the sidebar to be proxied by `@nextcloud/files` */
_sidebar?: () => Omit<ISidebar, 'available' | 'registerTab' | 'registerAction' | 'registerAction'>
}
}
}
}

View file

@ -16,6 +16,7 @@ import { action as openInFilesAction } from './actions/openInFilesAction.ts'
import { action as editLocallyAction } from './actions/openLocallyAction.ts'
import { action as renameAction } from './actions/renameAction.ts'
import { action as sidebarAction } from './actions/sidebarAction.ts'
import { registerSidebarFavoriteAction } from './actions/sidebarFavoriteAction.ts'
import { action as viewInFolderAction } from './actions/viewInFolderAction.ts'
import { registerFilenameFilter } from './filters/FilenameFilter.ts'
import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts'
@ -69,6 +70,9 @@ registerModifiedFilter()
registerFilenameFilter()
registerFilterToSearchToggle()
// Register sidebar action
registerSidebarFavoriteAction()
// Register preview service worker
registerPreviewServiceWorker()

View file

@ -1,125 +0,0 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import DOMPurify from 'dompurify'
export default class Tab {
_id
_name
_icon
_iconSvgSanitized
_mount
_setIsActive
_update
_destroy
_enabled
_scrollBottomReached
/**
* Create a new tab instance
*
* @param {object} options destructuring object
* @param {string} options.id the unique id of this tab
* @param {string} options.name the translated tab name
* @param {string} [options.icon] the icon css class
* @param {string} [options.iconSvg] the icon in svg format
* @param {Function} options.mount function to mount the tab
* @param {Function} [options.setIsActive] function to forward the active state of the tab
* @param {Function} options.update function to update the tab
* @param {Function} options.destroy function to destroy the tab
* @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean
* @param {Function} [options.scrollBottomReached] executed when the tab is scrolled to the bottom
*/
constructor({ id, name, icon, iconSvg, mount, setIsActive, update, destroy, enabled, scrollBottomReached } = {}) {
if (enabled === undefined) {
enabled = () => true
}
if (scrollBottomReached === undefined) {
scrollBottomReached = () => { }
}
// Sanity checks
if (typeof id !== 'string' || id.trim() === '') {
throw new Error('The id argument is not a valid string')
}
if (typeof name !== 'string' || name.trim() === '') {
throw new Error('The name argument is not a valid string')
}
if ((typeof icon !== 'string' || icon.trim() === '') && typeof iconSvg !== 'string') {
throw new Error('Missing valid string for icon or iconSvg argument')
}
if (typeof mount !== 'function') {
throw new Error('The mount argument should be a function')
}
if (setIsActive !== undefined && typeof setIsActive !== 'function') {
throw new Error('The setIsActive argument should be a function')
}
if (typeof update !== 'function') {
throw new Error('The update argument should be a function')
}
if (typeof destroy !== 'function') {
throw new Error('The destroy argument should be a function')
}
if (typeof enabled !== 'function') {
throw new Error('The enabled argument should be a function')
}
if (typeof scrollBottomReached !== 'function') {
throw new Error('The scrollBottomReached argument should be a function')
}
this._id = id
this._name = name
this._icon = icon
this._mount = mount
this._setIsActive = setIsActive
this._update = update
this._destroy = destroy
this._enabled = enabled
this._scrollBottomReached = scrollBottomReached
if (typeof iconSvg === 'string') {
this._iconSvgSanitized = DOMPurify.sanitize(iconSvg)
}
}
get id() {
return this._id
}
get name() {
return this._name
}
get icon() {
return this._icon
}
get iconSvg() {
return this._iconSvgSanitized
}
get mount() {
return this._mount
}
get setIsActive() {
return this._setIsActive || (() => undefined)
}
get update() {
return this._update
}
get destroy() {
return this._destroy
}
get enabled() {
return this._enabled
}
get scrollBottomReached() {
return this._scrollBottomReached
}
}

View file

@ -1,9 +1,12 @@
/**
/*
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import type { RawLocation, Route } from 'vue-router'
import { subscribe } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'
import { relative } from 'path'
import queryString from 'query-string'
@ -11,6 +14,7 @@ import Vue from 'vue'
import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
import logger from '../logger.ts'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
import { usePathsStore } from '../store/paths.ts'
import { defaultView } from '../utils/filesViews.ts'
@ -142,4 +146,30 @@ router.beforeResolve((to, from, next) => {
next()
})
subscribe('files:node:deleted', (node: INode) => {
if (router.currentRoute.params.fileid === String(node.fileid)) {
const params = { ...router.currentRoute.params }
const { getPath } = usePathsStore(getPinia())
const { getNode } = useFilesStore(getPinia())
const source = getPath(router.currentRoute.params.view, node.dirname)
const parentFolder = getNode(source!)
if (source && parentFolder) {
params.fileid = String(parentFolder.fileid)
} else {
delete params.fileid
}
const query = { ...router.currentRoute.query }
delete query.opendetails
delete query.openfile
router.replace({
...router.currentRoute,
name: router.currentRoute.name as string,
params,
query,
})
}
})
export default router

View file

@ -1,7 +1,8 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Location, Route } from 'vue-router'
import type VueRouter from 'vue-router'
@ -51,23 +52,25 @@ export default class RouterService {
/**
* Trigger a route change on the files App
*
* @param name the route name
* @param name - The route name or null to keep current route and just update params/query
* @param params the route parameters
* @param query the url query parameters
* @param replace replace the current history
* @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location
*/
goToRoute(
name?: string,
params?: Record<string, string>,
name: string | null,
params: Record<string, string>,
query?: Record<string, string | (string | null)[] | null | undefined>,
replace?: boolean,
): Promise<Route> {
return this.router.push({
name,
query,
params,
replace,
} as Location)
if (!name) {
name = this.router.currentRoute.name as string
}
const location: Location = { name, query, params }
if (replace) {
return this._router.replace(location)
}
return this._router.push(location)
}
}

View file

@ -1,80 +0,0 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import logger from '../logger.ts'
export default class Sidebar {
_state
constructor() {
// init empty state
this._state = {}
// init default values
this._state.tabs = []
this._state.views = []
this._state.file = ''
this._state.activeTab = ''
logger.debug('OCA.Files.Sidebar initialized')
}
/**
* Get the sidebar state
*
* @readonly
* @memberof Sidebar
* @return {object} the data state
*/
get state() {
return this._state
}
/**
* Register a new tab view
*
* @memberof Sidebar
* @param {object} tab a new unregistered tab
* @return {boolean}
*/
registerTab(tab) {
const hasDuplicate = this._state.tabs.findIndex((check) => check.id === tab.id) > -1
if (!hasDuplicate) {
this._state.tabs.push(tab)
return true
}
logger.error(`An tab with the same id ${tab.id} already exists`, { tab })
return false
}
registerSecondaryView(view) {
const hasDuplicate = this._state.views.findIndex((check) => check.id === view.id) > -1
if (!hasDuplicate) {
this._state.views.push(view)
return true
}
logger.error('A similar view already exists', { view })
return false
}
/**
* Return current opened file
*
* @memberof Sidebar
* @return {string} the current opened file
*/
get file() {
return this._state.file
}
/**
* Set the current visible sidebar tab
*
* @memberof Sidebar
* @param {string} id the tab unique id
*/
setActiveTab(id) {
this._state.activeTab = id
}
}

View file

@ -10,8 +10,9 @@ import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextc
export const client = getClient()
/**
* Fetches a node from the given path
*
* @param path
* @param path - The path to fetch the node from
*/
export async function fetchNode(path: string): Promise<Node> {
const propfindPayload = getDefaultPropfind()

View file

@ -1,53 +1,13 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import SidebarView from './views/FilesSidebar.vue'
import Tab from './models/Tab.js'
import Sidebar from './services/Sidebar.js'
import type { ISidebar } from '@nextcloud/files'
Vue.prototype.t = t
import { getPinia } from './store/index.ts'
import { useSidebarStore } from './store/sidebar.ts'
// Init Sidebar Service
if (!window.OCA.Files) {
window.OCA.Files = {}
}
Object.assign(window.OCA.Files, { Sidebar: new Sidebar() })
Object.assign(window.OCA.Files.Sidebar, { Tab })
window.addEventListener('DOMContentLoaded', function() {
const contentElement = document.querySelector('body > .content')
|| document.querySelector('body > #content')
let vueParent
// Make sure we have a proper layout
if (contentElement) {
// Make sure we have a mountpoint
if (!document.getElementById('app-sidebar')) {
const sidebarElement = document.createElement('div')
sidebarElement.id = 'app-sidebar'
contentElement.appendChild(sidebarElement)
}
// Helps with vue debug, as we mount the sidebar to the
// content element which is a vue instance itself
vueParent = contentElement.__vue__ as Vue
}
// Init vue app
const View = Vue.extend(SidebarView)
const AppSidebar = new View({
name: 'SidebarRoot',
parent: vueParent,
}).$mount('#app-sidebar')
// Expose Sidebar methods
window.OCA.Files.Sidebar.open = AppSidebar.open
window.OCA.Files.Sidebar.close = AppSidebar.close
window.OCA.Files.Sidebar.setFullScreenMode = AppSidebar.setFullScreenMode
window.OCA.Files.Sidebar.setShowTagsDefault = AppSidebar.setShowTagsDefault
})
// Provide sidebar implementation which is proxied by the `@nextcloud/files` library for app usage.
window.OCA.Files ??= {}
window.OCA.Files._sidebar = () => useSidebarStore(getPinia()) satisfies Omit<ISidebar, 'available' | 'registerAction' | 'registerTab' | 'registerAction'>

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FileAction, Folder, Node, View } from '@nextcloud/files'
import type { FileAction, IFolder, INode, IView } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import { getNavigation } from '@nextcloud/files'
@ -20,17 +20,17 @@ export const useActiveStore = defineStore('active', () => {
/**
* The currently active folder
*/
const activeFolder = ref<Folder>()
const activeFolder = ref<IFolder>()
/**
* The current active node within the folder
*/
const activeNode = ref<Node>()
const activeNode = ref<INode>()
/**
* The current active view
*/
const activeView = ref<View>()
const activeView = ref<IView>()
initialize()
@ -39,7 +39,7 @@ export const useActiveStore = defineStore('active', () => {
*
* @param node - The node thats deleted
*/
function onDeletedNode(node: Node) {
function onDeletedNode(node: INode) {
if (activeNode.value && activeNode.value.source === node.source) {
activeNode.value = undefined
}
@ -50,7 +50,7 @@ export const useActiveStore = defineStore('active', () => {
*
* @param view - The new active view
*/
function onChangedView(view: View | null = null) {
function onChangedView(view: IView | null = null) {
logger.debug('Setting active view', { view })
activeView.value = view ?? undefined
activeNode.value = undefined

View file

@ -6,7 +6,7 @@
import { createPinia } from 'pinia'
/**
*
* Get the Pinia instance for the Files app.
*/
export function getPinia() {
if (window._nc_files_pinia) {

View file

@ -0,0 +1,193 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode, ISidebarContext } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import { getSidebarActions, getSidebarTabs } from '@nextcloud/files'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import logger from '../logger.ts'
import { useActiveStore } from './active.ts'
export const useSidebarStore = defineStore('sidebar', () => {
const activeTab = ref<string>()
const currentNode = ref<INode>()
const isOpen = computed(() => !!currentNode.value)
const activeStore = useActiveStore()
const hasContext = computed(() => !!(currentNode.value && activeStore.activeFolder && activeStore.activeView))
const currentContext = computed<ISidebarContext | undefined>(() => {
if (!hasContext.value) {
return
}
return {
node: currentNode.value!,
folder: activeStore.activeFolder!,
view: activeStore.activeView!,
}
})
const currentActions = computed(() => currentContext.value ? getActions(currentContext.value) : [])
const currentTabs = computed(() => currentContext.value ? getTabs(currentContext.value) : [])
/**
* Open the sidebar for a given node and optional tab ID.
*
* @param node - The node to display in the sidebar.
* @param tabId - Optional ID of the tab to activate.
*/
function open(node: INode, tabId?: string) {
const activeStore = useActiveStore()
if (!(node && activeStore.activeFolder && activeStore.activeView)) {
logger.debug('Cannot open sidebar because the active folder or view is not set.', {
node,
activeFolder: activeStore.activeFolder,
activeView: activeStore.activeView,
})
throw new Error('Cannot open sidebar because the active folder or view is not set.')
}
const newTabs = getTabs({
node,
folder: activeStore.activeFolder,
view: activeStore.activeView,
})
if (tabId && !newTabs.find(({ id }) => id === tabId)) {
logger.warn(`Cannot open sidebar tab '${tabId}' because it is not available for the current context.`)
activeTab.value = newTabs[0]?.id
} else {
activeTab.value = tabId ?? newTabs[0]?.id
}
currentNode.value = node
}
/**
* Close the sidebar.
*/
function close() {
currentNode.value = undefined
}
/**
* Get the available tabs for the sidebar.
* If a context is provided, only tabs enabled for that context are returned.
*
* @param context - Optional context to filter the available tabs.
*/
function getTabs(context?: ISidebarContext) {
let tabs = getSidebarTabs()
if (context) {
tabs = tabs.filter((tab) => tab.enabled(context))
}
return tabs.sort((a, b) => a.order - b.order)
}
/**
* Get the available actions for the sidebar.
* If a context is provided, only actions enabled for that context are returned.
*
* @param context - Optional context to filter the available actions.
*/
function getActions(context?: ISidebarContext) {
let actions = getSidebarActions()
if (context) {
actions = actions.filter((tab) => tab.enabled(context))
}
return actions.sort((a, b) => a.order - b.order)
}
/**
* Set the active tab in the sidebar.
*
* @param tabId - The ID of the tab to activate.
*/
function setActiveTab(tabId: string) {
if (!currentTabs.value.find(({ id }) => id === tabId)) {
throw new Error(`Cannot set sidebar tab '${tabId}' because it is not available for the current context.`)
}
activeTab.value = tabId
}
// update the current node if updated
subscribe('files:node:updated', (node: INode) => {
if (node.source === currentNode.value?.source) {
currentNode.value = node
}
})
// close the sidebar if the current node is deleted
subscribe('files:node:deleted', (node) => {
if (node.fileid === currentNode.value?.fileid) {
close()
}
})
let initialized = false
// close sidebar when parameter is removed from url
subscribe('files:list:updated', () => {
if (!initialized) {
initialized = true
window.OCP.Files.Router._router.afterEach((to) => {
if (to.query && !('opendetails' in to.query)) {
close()
}
})
}
})
// watch open state and update URL query parameters
watch(currentNode, (node) => {
const query = { ...(window.OCP?.Files?.Router?.query ?? {}) }
if (!node && 'opendetails' in query) {
delete query.opendetails
window.OCP.Files.Router.goToRoute(
null,
{ ...window.OCP.Files.Router.params },
{
...query,
},
true,
)
}
if (node) {
const fileid = String(node.fileid)
if (!('opendetails' in query) || window.OCP.Files.Router.params.fileid !== fileid) {
window.OCP.Files.Router.goToRoute(
null,
{
...window.OCP.Files.Router.params,
fileid,
},
{
...query,
opendetails: 'true',
},
true,
)
}
}
})
return {
activeTab,
currentActions,
currentContext,
currentNode,
currentTabs,
hasContext,
isOpen,
open,
close,
getActions,
getTabs,
setActiveTab,
}
})

View file

@ -169,7 +169,7 @@ import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Folder, getFileListActions, Permission, sortNodes } from '@nextcloud/files'
import { Folder, getFileListActions, getSidebar, Permission, sortNodes } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
@ -195,7 +195,6 @@ import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
@ -250,6 +249,8 @@ export default defineComponent({
},
setup() {
const sidebar = getSidebar()
const { currentView } = useNavigation()
const { directory, fileId } = useRouteParameters()
const fileListWidth = useFileListWidth()
@ -283,6 +284,7 @@ export default defineComponent({
viewConfigStore,
// non reactive data
sidebar,
enableGridView,
forbiddenCharacters,
ShareType,
@ -557,9 +559,7 @@ export default defineComponent({
logger.debug('Directory changed', { newDir, oldDir })
// TODO: preserve selection on browsing?
this.selectionStore.reset()
if (window.OCA.Files.Sidebar?.close) {
window.OCA.Files.Sidebar.close()
}
this.sidebar.close()
this.fetchContent()
// Scroll to top, force virtual scroller to re-render
@ -578,7 +578,6 @@ export default defineComponent({
},
async mounted() {
subscribe('files:node:deleted', this.onNodeDeleted)
subscribe('files:node:updated', this.onUpdatedNode)
// reload on settings change
@ -603,7 +602,6 @@ export default defineComponent({
},
unmounted() {
unsubscribe('files:node:deleted', this.onNodeDeleted)
unsubscribe('files:node:updated', this.onUpdatedNode)
unsubscribe('files:config:updated', this.fetchContent)
unsubscribe('files:filters:changed', this.filterDirContent)
@ -686,32 +684,6 @@ export default defineComponent({
}
},
/**
* Handle the node deleted event to reset open file
*
* @param node The deleted node
*/
onNodeDeleted(node: Node) {
if (node.fileid && node.fileid === this.fileId) {
if (node.fileid === this.currentFolder?.fileid) {
// Handle the edge case that the current directory is deleted
// in this case we need to keep the current view but move to the parent directory
window.OCP.Files.Router.goToRoute(
null,
{ view: this.currentView!.id },
{ dir: this.currentFolder?.dirname ?? '/' },
)
} else {
// If the currently active file is deleted we need to remove the fileid and possible the `openfile` query
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: undefined },
{ ...this.$route.query, openfile: undefined },
)
}
}
},
/**
* The upload manager have finished handling the queue
*
@ -792,15 +764,7 @@ export default defineComponent({
return
}
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
window.OCA.Files.Sidebar.setActiveTab('sharing')
}
sidebarAction.exec({
nodes: [this.source],
view: this.currentView,
folder: this.currentFolder,
contents: this.dirContents,
})
this.sidebar.open(this.currentFolder, 'sharing')
},
toggleGridView() {

View file

@ -61,6 +61,7 @@ import FilesAppSettings from './FilesAppSettings.vue'
import { useNavigation } from '../composables/useNavigation.ts'
import logger from '../logger.ts'
import { useFiltersStore } from '../store/filters.ts'
import { useSidebarStore } from '../store/sidebar.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
const collator = Intl.Collator(
@ -87,6 +88,7 @@ export default defineComponent({
},
setup() {
const sidebar = useSidebarStore()
const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
@ -96,6 +98,7 @@ export default defineComponent({
t,
views,
sidebar,
filtersStore,
viewConfigStore,
}
@ -176,8 +179,7 @@ export default defineComponent({
* @param view View to set active
*/
showView(view: View) {
// Closing any opened sidebar
window.OCA?.Files?.Sidebar?.close?.()
this.sidebar.close()
getNavigation().setActive(view.id)
emit('files:navigation:changed', view)
},

View file

@ -3,620 +3,120 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { ref, toRef, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FilesSidebarSubname from '../components/FilesSidebar/FilesSidebarSubname.vue'
import FilesSidebarTab from '../components/FilesSidebar/FilesSidebarTab.vue'
import { usePreviewImage } from '../composables/usePreviewImage.ts'
import { useSidebarStore } from '../store/sidebar.ts'
const sidebar = useSidebarStore()
const previewUrl = usePreviewImage(toRef(sidebar, 'currentNode'), {
crop: false,
fallback: false,
size: [512, 288],
})
const background = ref<string>()
watch(previewUrl, () => {
background.value = undefined
// only try the background if there is more than a mime icon
if (previewUrl.value && !previewUrl.value.includes('/core/mimeicon')) {
const image = new Image()
image.onload = () => {
background.value = previewUrl.value
}
image.src = previewUrl.value
}
}, { immediate: true })
/**
* Emitted when the sidebar is fully closed.
* Trigger the event-bus event.
*/
function onClosed() {
if (sidebar.isOpen) {
// was opened again meanwhile
return
}
sidebar.currentNode = undefined
emit('files:sidebar:closed')
}
/**
* Emitted when the sidebar is fully opened.
* Trigger the event-bus event.
*/
function onOpened() {
emit('files:sidebar:opened', sidebar.currentNode!)
}
/**
* Emitted when the sidebar open state is toggled by the sidebar toggle button.
* As we hide the open button this is only triggered when the user closes the sidebar.
*
* @param open - The new open state
*/
function onToggle(open: boolean) {
if (!open) {
sidebar.close()
}
}
</script>
<template>
<NcAppSidebar
v-if="file"
ref="sidebar"
data-cy-sidebar
v-bind="appSidebar"
:force-menu="true"
@close="close"
@update:active="setActiveTab"
@[defaultActionListener].stop.prevent="onDefaultAction"
@opening="handleOpening"
@opened="handleOpened"
@closing="handleClosing"
@closed="handleClosed">
<template v-if="fileInfo" #subname>
<div class="sidebar__subname">
<NcIconSvgWrapper
v-if="fileInfo.isFavourited"
:path="mdiStar"
:name="t('files', 'Favorite')"
inline />
<span>{{ size }}</span>
<span class="sidebar__subname-separator"></span>
<NcDateTime :timestamp="fileInfo.mtime" />
<span class="sidebar__subname-separator"></span>
<span>{{ t('files', 'Owner') }}</span>
<NcUserBubble
:user="ownerId"
:display-name="nodeOwnerLabel" />
</div>
</template>
<!-- TODO: create a standard to allow multiple elements here? -->
<template v-if="fileInfo" #description>
<div class="sidebar__description">
<SystemTags
v-if="isSystemTagsEnabled && showTagsDefault"
v-show="showTags"
:disabled="!fileInfo?.canEdit()"
:file-id="fileInfo.id" />
<LegacyView
v-for="view in views"
:key="view.cid"
:component="view"
:file-info="fileInfo" />
</div>
force-menu
:active.sync="sidebar.activeTab"
:background="background"
:empty="!sidebar.hasContext"
:loading="!sidebar.hasContext"
:name="sidebar.currentNode?.displayname ?? t('files', 'Loading …')"
no-toggle
:open="sidebar.isOpen"
@closed="onClosed"
@opened="onOpened"
@update:open="onToggle">
<template v-if="sidebar.currentNode" #subname>
<FilesSidebarSubname :node="sidebar.currentNode" />
</template>
<!-- Actions menu -->
<template v-if="fileInfo" #secondary-actions>
<template v-if="sidebar.currentContext" #secondary-actions>
<!-- we cannot use a sub component due to limitations of the NcActions component -->
<NcActionButton
:close-after-click="true"
@click="toggleStarred(!fileInfo.isFavourited)">
v-for="action of sidebar.currentActions"
:key="action.id"
close-after-click
@click="action.onClick(sidebar.currentContext)">
<template #icon>
<NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStar : mdiStarOutline" />
<NcIconSvgWrapper :svg="action.iconSvgInline(sidebar.currentContext)" />
</template>
{{ fileInfo.isFavourited ? t('files', 'Remove from favorites') : t('files', 'Add to favorites') }}
</NcActionButton>
<!-- TODO: create proper api for apps to register actions
And inject themselves here. -->
<NcActionButton
v-if="isSystemTagsEnabled"
:close-after-click="true"
@click="toggleTags">
<template #icon>
<NcIconSvgWrapper :path="mdiTagMultipleOutline" />
</template>
{{ t('files', 'Tags') }}
{{ action.displayName(sidebar.currentContext) }}
</NcActionButton>
</template>
<!-- Error display -->
<NcEmptyContent v-if="error" icon="icon-error">
{{ error }}
</NcEmptyContent>
<!-- Description -->
<!-- <template v-if="hasContext" #description>
<FilesSidebarDescription />
</template> -->
<!-- If fileInfo fetch is complete, render tabs -->
<template v-for="tab in tabs" v-else-if="fileInfo">
<!-- Hide them if we're loading another file but keep them mounted -->
<SidebarTab
v-if="tab.enabled(fileInfo)"
v-show="!loading"
:id="tab.id"
<template v-if="sidebar.hasContext">
<FilesSidebarTab
v-for="tab in sidebar.currentTabs"
:key="tab.id"
:name="tab.name"
:icon="tab.icon"
:on-mount="tab.mount"
:on-update="tab.update"
:on-destroy="tab.destroy"
:on-scroll-bottom-reached="tab.scrollBottomReached"
:file-info="fileInfo">
<template v-if="tab.iconSvg !== undefined" #icon>
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="svg-icon" v-html="tab.iconSvg" />
</template>
</SidebarTab>
:active="sidebar.activeTab === tab.id"
:tab="tab" />
</template>
</NcAppSidebar>
</template>
<script lang="ts">
import type { INode } from '@nextcloud/files'
import { mdiStar, mdiStarOutline, mdiTagMultipleOutline } from '@mdi/js'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { File, Folder, formatFileSize } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import $ from 'jquery'
import { defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
import LegacyView from '../components/LegacyView.vue'
import SidebarTab from '../components/SidebarTab.vue'
import logger from '../logger.ts'
import FileInfo from '../services/FileInfo.js'
import { fetchNode } from '../services/WebdavClient.ts'
export default defineComponent({
name: 'FilesSidebar',
components: {
LegacyView,
NcActionButton,
NcAppSidebar,
NcDateTime,
NcEmptyContent,
NcIconSvgWrapper,
SidebarTab,
SystemTags,
NcUserBubble,
},
setup() {
const currentUser = getCurrentUser()
// Non reactive properties
return {
currentUser,
mdiStar,
mdiStarOutline,
mdiTagMultipleOutline,
}
},
data() {
return {
// reactive state
Sidebar: OCA.Files.Sidebar.state,
showTags: false,
showTagsDefault: true,
error: null,
loading: true,
fileInfo: null,
node: null as INode | null,
isFullScreen: false,
hasLowHeight: false,
}
},
computed: {
/**
* Current filename
* This is bound to the Sidebar service and
* is used to load a new file
*
* @return {string}
*/
file() {
return this.Sidebar.file
},
/**
* List of all the registered tabs
*
* @return {Array}
*/
tabs() {
return this.Sidebar.tabs
},
/**
* List of all the registered views
*
* @return {Array}
*/
views() {
return this.Sidebar.views
},
/**
* Current user dav root path
*
* @return {string}
*/
davPath() {
return `${getRemoteURL()}${getRootPath()}${encodePath(this.file)}`
},
/**
* Current active tab handler
*
* @return {string} the current active tab
*/
activeTab() {
return this.Sidebar.activeTab
},
/**
* File size formatted string
*
* @return {string}
*/
size() {
return formatFileSize(this.fileInfo?.size)
},
/**
* File background/figure to illustrate the sidebar header
*
* @return {string}
*/
background() {
return this.getPreviewIfAny(this.fileInfo)
},
/**
* App sidebar v-binding object
*
* @return {object}
*/
appSidebar() {
if (this.fileInfo) {
return {
'data-mimetype': this.fileInfo.mimetype,
active: this.activeTab,
background: this.background,
class: {
'app-sidebar--has-preview': this.fileInfo.hasPreview && !this.isFullScreen,
'app-sidebar--full': this.isFullScreen,
},
compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen,
loading: this.loading,
name: this.node?.displayname ?? this.fileInfo.name,
title: this.node?.displayname ?? this.fileInfo.name,
}
} else if (this.error) {
return {
key: 'error', // force key to re-render
subname: '',
name: '',
class: {
'app-sidebar--full': this.isFullScreen,
},
}
}
// no fileInfo yet, showing empty data
return {
loading: this.loading,
subname: '',
name: '',
class: {
'app-sidebar--full': this.isFullScreen,
},
}
},
/**
* Default action object for the current file
*
* @return {object}
*/
defaultAction() {
return this.fileInfo
&& OCA.Files && OCA.Files.App && OCA.Files.App.fileList
&& OCA.Files.App.fileList.fileActions
&& OCA.Files.App.fileList.fileActions.getDefaultFileAction
&& OCA.Files.App.fileList
.fileActions.getDefaultFileAction(this.fileInfo.mimetype, this.fileInfo.type, OC.PERMISSION_READ)
},
/**
* Dynamic header click listener to ensure
* nothing is listening for a click if there
* is no default action
*
* @return {string|null}
*/
defaultActionListener() {
return this.defaultAction ? 'figure-click' : null
},
isSystemTagsEnabled() {
return getCapabilities()?.systemtags?.enabled === true
},
ownerId() {
return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid
},
currentUserIsOwner() {
return this.ownerId === this.currentUser.uid
},
nodeOwnerLabel() {
let ownerDisplayName = this.node?.attributes?.['owner-display-name']
if (this.currentUserIsOwner) {
ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})`
}
return ownerDisplayName
},
sharedMultipleTimes() {
if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
return t('files', 'Shared multiple times with different people')
}
return null
},
},
created() {
subscribe('files:node:deleted', this.onNodeDeleted)
window.addEventListener('resize', this.handleWindowResize)
this.handleWindowResize()
},
beforeUnmount() {
unsubscribe('file:node:deleted', this.onNodeDeleted)
window.removeEventListener('resize', this.handleWindowResize)
},
methods: {
/**
* Can this tab be displayed ?
*
* @param {object} tab a registered tab
* @return {boolean}
*/
canDisplay(tab) {
return tab.enabled(this.fileInfo)
},
resetData() {
this.error = null
this.fileInfo = null
this.$nextTick(() => {
if (this.$refs.tabs) {
this.$refs.tabs.updateTabs()
}
})
},
getPreviewIfAny(fileInfo) {
if (fileInfo?.hasPreview && !this.isFullScreen) {
const etag = fileInfo?.etag || ''
return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`)
}
return this.getIconUrl(fileInfo)
},
/**
* Copied from https://github.com/nextcloud/server/blob/16e0887ec63591113ee3f476e0c5129e20180cde/apps/files/js/filelist.js#L1377
* TODO: We also need this as a standalone library
*
* @param {object} fileInfo the fileinfo
* @return {string} Url to the icon for mimeType
*/
getIconUrl(fileInfo) {
const mimeType = fileInfo?.mimetype || 'application/octet-stream'
if (mimeType === 'httpd/unix-directory') {
// use default folder icon
if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') {
return OC.MimeType.getIconUrl('dir-shared')
} else if (fileInfo.mountType === 'external-root') {
return OC.MimeType.getIconUrl('dir-external')
} else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') {
return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType)
} else if (fileInfo.shareTypes && (
fileInfo.shareTypes.indexOf(ShareType.Link) > -1
|| fileInfo.shareTypes.indexOf(ShareType.Email) > -1)
) {
return OC.MimeType.getIconUrl('dir-public')
} else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) {
return OC.MimeType.getIconUrl('dir-shared')
}
return OC.MimeType.getIconUrl('dir')
}
return OC.MimeType.getIconUrl(mimeType)
},
/**
* Set current active tab
*
* @param {string} id tab unique id
*/
setActiveTab(id) {
OCA.Files.Sidebar.setActiveTab(id)
this.tabs.forEach((tab) => {
try {
tab.setIsActive(id === tab.id)
} catch (error) {
logger.error('Error while setting tab active state', { error, id: tab.id, tab })
}
})
},
/**
* Toggle favorite state
* TODO: better implementation
*
* @param {boolean} state is favorite or not
*/
async toggleStarred(state) {
try {
await axios({
method: 'PROPPATCH',
url: this.davPath,
data: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
${state ? '<d:set>' : '<d:remove>'}
<d:prop>
<oc:favorite>1</oc:favorite>
</d:prop>
${state ? '</d:set>' : '</d:remove>'}
</d:propertyupdate>`,
})
/**
* TODO: adjust this when the Sidebar is finally using File/Folder classes
*
* @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115
*/
const isDir = this.fileInfo.type === 'dir'
const Node = isDir ? Folder : File
const node = new Node({
id: this.fileInfo.id,
source: `${getRemoteURL()}${getRootPath()}${this.file}`,
root: getRootPath(),
owner: null,
mime: isDir ? undefined : this.fileInfo.mimetype,
attributes: {
favorite: 1,
},
})
emit(state ? 'files:favorites:added' : 'files:favorites:removed', node)
this.fileInfo.isFavourited = state
} catch (error) {
showError(t('files', 'Unable to change the favorite state of the file'))
logger.error('Unable to change favorite state', { error })
}
},
onDefaultAction() {
if (this.defaultAction) {
// generate fake context
this.defaultAction.action(this.fileInfo.name, {
fileInfo: this.fileInfo,
dir: this.fileInfo.dir,
fileList: OCA.Files.App.fileList,
$file: $('body'),
})
}
},
/**
* Toggle the tags selector
*/
toggleTags() {
// toggle
this.showTags = !this.showTags
// save the new state
this.setShowTagsDefault(this.showTags)
},
/**
* Open the sidebar for the given file
*
* @param {string} path the file path to load
* @return {Promise}
* @throws {Error} loading failure
*/
async open(path) {
if (!path || path.trim() === '') {
throw new Error(`Invalid path '${path}'`)
}
// Only focus the tab when the selected file/tab is changed in already opened sidebar
// Focusing the sidebar on first file open is handled by NcAppSidebar
const focusTabAfterLoad = !!this.Sidebar.file
// update current opened file
this.Sidebar.file = path
// reset data, keep old fileInfo to not reload all tabs and just hide them
this.error = null
this.loading = true
try {
this.node = await fetchNode(this.file)
this.fileInfo = FileInfo(this.node)
// adding this as fallback because other apps expect it
this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
// DEPRECATED legacy views
// TODO: remove
this.views.forEach((view) => {
view.setFileInfo(this.fileInfo)
})
await this.$nextTick()
this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
this.loading = false
await this.$nextTick()
if (focusTabAfterLoad && this.$refs.sidebar) {
this.$refs.sidebar.focusActiveTabContent()
}
} catch (error) {
this.loading = false
this.error = t('files', 'Error while loading the file data')
logger.error('Error while loading the file data', { error })
throw new Error(error)
}
},
/**
* Close the sidebar
*/
close() {
this.Sidebar.file = ''
this.showTags = false
this.resetData()
},
/**
* Handle if the current node was deleted
*
* @param {import('@nextcloud/files').Node} node The deleted node
*/
onNodeDeleted(node) {
if (this.fileInfo && node && this.fileInfo.id === node.fileid) {
this.close()
}
},
/**
* Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar
*
* @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen.
*/
setFullScreenMode(isFullScreen) {
this.isFullScreen = isFullScreen
const content = document.querySelector('#content') || document.querySelector('#content-vue')
if (isFullScreen) {
content?.classList.add('with-sidebar--full')
} else {
content?.classList.remove('with-sidebar--full')
}
},
/**
* Allow to set whether tags should be shown by default from OCA.Files.Sidebar
*
* @param {boolean} showTagsDefault - Whether or not to show the tags by default.
*/
setShowTagsDefault(showTagsDefault) {
this.showTagsDefault = showTagsDefault
},
/**
* Emit SideBar events.
*/
handleOpening() {
emit('files:sidebar:opening')
},
handleOpened() {
emit('files:sidebar:opened')
},
handleClosing() {
emit('files:sidebar:closing')
},
handleClosed() {
emit('files:sidebar:closed')
},
handleWindowResize() {
this.hasLowHeight = document.documentElement.clientHeight < 1024
},
},
})
</script>
<style lang="scss" scoped>
.app-sidebar {
&--has-preview:deep {
@ -654,21 +154,6 @@ export default defineComponent({
}
}
.sidebar__subname {
display: flex;
align-items: center;
gap: 0 8px;
&-separator {
display: inline-block;
font-weight: bold !important;
}
.user-bubble__wrapper {
display: inline-flex;
}
}
.sidebar__description {
display: flex;
flex-direction: column;

View file

@ -130,9 +130,9 @@ describe('Favorites view definition', () => {
describe('Dynamic update of favorite folders', () => {
let Navigation
beforeEach(() => {
vi.restoreAllMocks()
delete window._nc_navigation
Navigation = getNavigation()
})
@ -167,8 +167,9 @@ describe('Dynamic update of favorite folders', () => {
contents: [],
})
expect(eventBus.emit).toHaveBeenCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalledTimes(2)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder)
})
test('Remove a favorite folder remove the entry from the navigation column', async () => {
@ -213,8 +214,9 @@ describe('Dynamic update of favorite folders', () => {
contents: [],
})
expect(eventBus.emit).toHaveBeenCalledTimes(1)
expect(eventBus.emit).toHaveBeenCalledTimes(2)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder)
expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder)
expect(fo).toHaveBeenCalled()
favoritesView = Navigation.views.find((view) => view.id === 'favorites')
@ -257,7 +259,8 @@ describe('Dynamic update of favorite folders', () => {
folder: {} as NcFolder,
contents: [],
})
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder)
expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
expect(eventBus.emit).toHaveBeenCalledWith('files:node:updated', folder)
// Create a folder with the same id but renamed
const renamedFolder = new Folder({
@ -269,6 +272,6 @@ describe('Dynamic update of favorite folders', () => {
// Exec the rename action
eventBus.emit('files:node:renamed', renamedFolder)
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder)
expect(eventBus.emit).toHaveBeenCalledWith('files:node:renamed', renamedFolder)
})
})

View file

@ -3,28 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw'
import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw'
import LinkSvg from '@mdi/svg/svg/link.svg?raw'
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'
import { FileAction, Permission, registerFileAction } from '@nextcloud/files'
import { FileAction, getSidebar, Permission, registerFileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { ShareType } from '@nextcloud/sharing'
import { isPublicShare } from '@nextcloud/sharing/public'
import CircleSvg from '../../../../core/img/apps/circles.svg?raw'
import { action as sidebarAction } from '../../../files/src/actions/sidebarAction.ts'
import { generateAvatarSvg } from '../utils/AccountIcon.ts'
import './sharingStatusAction.scss'
/**
* Check if the node is external (federated)
*
* @param node
* @param node - The node to check
*/
function isExternal(node: Node) {
function isExternal(node: INode) {
return node.attributes?.['is-federated'] ?? false
}
@ -136,12 +136,12 @@ export const action = new FileAction({
&& (node.permissions & Permission.READ) !== 0
},
async exec({ nodes, view, folder, contents }) {
async exec({ nodes }) {
// You need read permissions to see the sidebar
const node = nodes[0]
if ((node.permissions & Permission.READ) !== 0) {
window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing')
sidebarAction.exec({ nodes, view, folder, contents })
const sidebar = getSidebar()
sidebar.open(node, 'sharing')
return null
}

View file

@ -5,8 +5,11 @@
import ShareVariant from '@mdi/svg/svg/share-variant.svg?raw'
import { getCSPNonce } from '@nextcloud/auth'
import { registerSidebarTab } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import wrap from '@vue/web-component-wrapper'
import Vue from 'vue'
import FilesSidebarTab from './views/FilesSidebarTab.vue'
import ExternalShareActions from './services/ExternalShareActions.js'
import ShareSearch from './services/ShareSearch.js'
import TabSections from './services/TabSections.js'
@ -14,9 +17,7 @@ import TabSections from './services/TabSections.js'
__webpack_nonce__ = getCSPNonce()
// Init Sharing Tab Service
if (!window.OCA.Sharing) {
window.OCA.Sharing = {}
}
window.OCA.Sharing ??= {}
Object.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() })
Object.assign(window.OCA.Sharing, { ExternalShareActions: new ExternalShareActions() })
Object.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() })
@ -24,42 +25,34 @@ Object.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() })
Vue.prototype.t = t
Vue.prototype.n = n
// Init Sharing tab component
let TabInstance = null
const tagName = 'files_sharing-sidebar-tab'
window.addEventListener('DOMContentLoaded', function() {
if (OCA.Files && OCA.Files.Sidebar) {
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({
id: 'sharing',
name: t('files_sharing', 'Sharing'),
iconSvg: ShareVariant,
async mount(el, fileInfo, context) {
const SharingTab = (await import('./views/SharingTab.vue')).default
const View = Vue.extend(SharingTab)
if (TabInstance) {
TabInstance.$destroy()
}
TabInstance = new View({
// Better integration with vue parent component
parent: context,
})
// Only mount after we have all the info we need
await TabInstance.update(fileInfo)
TabInstance.$mount(el)
},
update(fileInfo) {
TabInstance.update(fileInfo)
},
destroy() {
if (TabInstance) {
TabInstance.$destroy()
TabInstance = null
}
},
}))
}
registerSidebarTab({
id: 'sharing',
displayName: t('files_sharing', 'Sharing'),
iconSvgInline: ShareVariant,
order: 10,
tagName,
enabled() {
if (!window.customElements.get(tagName)) {
setupSidebarTab()
}
return true
},
})
/**
* Setup the sidebar tab as a web component
*/
function setupSidebarTab() {
const webComponent = wrap(Vue, FilesSidebarTab)
// In Vue 2, wrap doesn't support diseabling shadow. Disable with a hack
Object.defineProperty(webComponent.prototype, 'attachShadow', {
value() { return this },
})
Object.defineProperty(webComponent.prototype, 'shadowRoot', {
get() { return this },
})
window.customElements.define(tagName, webComponent)
}

View file

@ -1,11 +1,9 @@
/**
/*!
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable jsdoc/require-jsdoc */
import type { Attribute, Node } from '@nextcloud/files'
import type { Attribute, INode } from '@nextcloud/files'
interface RawLegacyFileInfo {
id: number
@ -30,11 +28,16 @@ export type LegacyFileInfo = RawLegacyFileInfo & {
get: (key: keyof RawLegacyFileInfo) => unknown
isDirectory: () => boolean
canEdit: () => boolean
node: Node
node: INode
canDownload: () => boolean
}
export default function(node: Node): LegacyFileInfo {
/**
* Convert Node to legacy file info
*
* @param node - The Node to convert
*/
export default function(node: INode): LegacyFileInfo {
const rawFileInfo: RawLegacyFileInfo = {
id: node.fileid!,
path: node.dirname,

View file

@ -0,0 +1,26 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IFolder, INode, IView } from '@nextcloud/files'
import { computed } from 'vue'
import SharingTab from './SharingTab.vue'
import FileInfo from '../services/FileInfo.ts'
const props = defineProps<{
node?: INode
// eslint-disable-next-line vue/no-unused-properties -- Required on the web component interface
folder?: IFolder
// eslint-disable-next-line vue/no-unused-properties -- Required on the web component interface
view?: IView
}>()
const fileInfo = computed(() => props.node && FileInfo(props.node))
</script>
<template>
<SharingTab v-if="fileInfo" :file-info="fileInfo" />
</template>

View file

@ -230,6 +230,13 @@ export default {
mixins: [ShareDetails],
props: {
fileInfo: {
type: Object,
required: true,
},
},
data() {
return {
config: new Config(),
@ -238,8 +245,6 @@ export default {
expirationInterval: null,
loading: true,
fileInfo: null,
// reshare Share object
reshare: null,
sharedWithMe: {},
@ -328,18 +333,19 @@ export default {
},
},
methods: {
/**
* Update current fileInfo and fetch new data
*
* @param {object} fileInfo the current file FileInfo
*/
async update(fileInfo) {
this.fileInfo = fileInfo
this.resetState()
this.getShares()
watch: {
fileInfo: {
immediate: true,
handler(newValue, oldValue) {
if (oldValue?.id === undefined || oldValue?.id !== newValue?.id) {
this.resetState()
this.getShares()
}
},
},
},
methods: {
/**
* Get the existing shares infos
*/

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

@ -141,7 +141,7 @@
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { Tag, TagWithId } from '../types.ts'
@ -216,11 +216,13 @@ export default defineComponent({
props: {
nodes: {
type: Array as PropType<Node[]>,
type: Array as PropType<INode[]>,
required: true,
},
},
emits: ['close'],
setup() {
return {
emit,
@ -381,7 +383,7 @@ export default defineComponent({
})
// Efficient way of counting tags and their occurrences
this.tagList = this.nodes.reduce((acc: TagListCount, node: Node) => {
this.tagList = this.nodes.reduce((acc: TagListCount, node: INode) => {
const tags = getNodeSystemTags(node) || []
tags.forEach((tag) => {
acc[tag] = (acc[tag] || 0) + 1
@ -531,7 +533,7 @@ export default defineComponent({
return
}
const nodes = [] as Node[]
const nodes = [] as INode[]
// Update nodes
this.toAdd.forEach((tag) => {

View file

@ -35,11 +35,12 @@
</template>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import type { Tag, TagWithId } from '../types.js'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe } from '@nextcloud/event-bus'
import { getSidebar } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
@ -226,7 +227,7 @@ export default Vue.extend({
this.updateAndDispatchNodeTagsEvent(this.fileId)
},
async onTagUpdated(node: Node) {
async onTagUpdated(node: INode) {
if (node.fileid !== this.fileId) {
return
}
@ -243,7 +244,8 @@ export default Vue.extend({
},
async updateAndDispatchNodeTagsEvent(fileId: number) {
const path = window.OCA?.Files?.Sidebar?.file || ''
const sidebar = getSidebar()
const path = sidebar.node?.path ?? ''
try {
const node = await fetchNode(path)
if (node) {

View file

@ -3,12 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import type { TagWithId } from './types.ts'
declare module '@nextcloud/event-bus' {
interface NextcloudEvents {
'systemtags:node:updated': Node
'systemtags:node:updated': INode
'systemtags:tag:deleted': TagWithId
'systemtags:tag:updated': TagWithId
'systemtags:tag:created': TagWithId

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import TagMultipleSvg from '@mdi/svg/svg/tag-multiple-outline.svg?raw'
import { FileAction, Permission } from '@nextcloud/files'
@ -18,7 +18,7 @@ import { defineAsyncComponent } from 'vue'
* @param nodes Nodes to modify tags for
* @param nodes.nodes
*/
async function execBatch({ nodes }: { nodes: Node[] }): Promise<(null | boolean)[]> {
async function execBatch({ nodes }: { nodes: INode[] }): Promise<(null | boolean)[]> {
const response = await new Promise<null | boolean>((resolve) => {
spawnDialog(defineAsyncComponent(() => import('../components/SystemTagPicker.vue')), {
nodes,

View file

@ -0,0 +1,37 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import tagSvg from '@mdi/svg/svg/tag-outline.svg?raw'
import { registerSidebarAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import { defineAsyncComponent } from 'vue'
/**
* Register the "Add tags" action in the file sidebar
*/
export function registerFileSidebarAction() {
registerSidebarAction({
id: 'systemtags',
order: 20,
displayName() {
return t('systemtags', 'Add tags')
},
enabled() {
return true
},
iconSvgInline() {
return tagSvg
},
onClick({ node }) {
return spawnDialog(
defineAsyncComponent(() => import('../components/SystemTagPicker.vue')),
{
nodes: [node],
},
)
},
})
}

View file

@ -1,10 +1,12 @@
/**
/*!
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerFileAction } from '@nextcloud/files'
import { registerDavProperty } from '@nextcloud/files/dav'
import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAction.ts'
import { registerFileSidebarAction } from './files_actions/filesSidebarAction.ts'
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.ts'
import { action as openInFilesAction } from './files_actions/openInFilesAction.ts'
import { registerSystemTagsView } from './files_views/systemtagsView.ts'
@ -16,6 +18,7 @@ registerFileAction(inlineSystemTagsAction)
registerFileAction(openInFilesAction)
registerSystemTagsView()
registerFileSidebarAction()
document.addEventListener('DOMContentLoaded', () => {
registerHotkeys()

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { INode } from '@nextcloud/files'
import type { DAVResultResponseProps } from 'webdav'
import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js'
@ -68,7 +68,7 @@ export function formatTag(initialTag: Tag | ServerTag): ServerTag {
*
* @param node
*/
export function getNodeSystemTags(node: Node): string[] {
export function getNodeSystemTags(node: INode): string[] {
const attribute = node.attributes?.['system-tags']?.['system-tag']
if (attribute === undefined) {
return []
@ -92,7 +92,7 @@ export function getNodeSystemTags(node: Node): string[] {
* @param node
* @param tags
*/
export function setNodeSystemTags(node: Node, tags: string[]): void {
export function setNodeSystemTags(node: INode, tags: string[]): void {
Vue.set(node.attributes, 'system-tags', {
'system-tag': tags,
})

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
"scripts": {
"build": "webpack --node-env production --progress",
"dev": "webpack --node-env development --progress",
"postinstall": "patch-package",
"lint": "eslint --suppressions-location ../eslint-baseline-legacy.json --no-error-on-unmatched-pattern ./apps/*/ ./core/",
"lint:fix": "eslint --suppressions-location ../eslint-baseline-legacy.json --fix --no-error-on-unmatched-pattern ./apps/*/ ./core/",
"test": "vitest run",
@ -124,6 +125,7 @@
"handlebars-loader": "^1.7.3",
"mime": "^4.1.0",
"msw": "^2.12.6",
"patch-package": "^8.0.1",
"raw-loader": "^4.0.2",
"regextras": "^0.8.0",
"sass": "^1.97.1",

View file

@ -0,0 +1,43 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
#
# https://github.com/vuejs/vue-web-component-wrapper/pull/133
# https://github.com/vuejs/vue-web-component-wrapper/pull/122
#
diff --git a/node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js b/node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js
index 721bf1f..adba8e8 100644
--- a/node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js
+++ b/node_modules/@vue/web-component-wrapper/dist/vue-wc-wrapper.js
@@ -222,10 +222,17 @@ function wrap (Vue, Component) {
if (!wrapper._isMounted) {
// initialize attributes
const syncInitialAttributes = () => {
- wrapper.props = getInitialProps(camelizedPropsList);
+ wrapper.props = Object.assign(
+ getInitialProps(camelizedPropsList),
+ wrapper.props
+ )
hyphenatedPropsList.forEach(key => {
- syncAttribute(this, key);
- });
+ const camelized = camelize(key)
+ // Maybe setted by Element properties earler
+ if (typeof wrapper.props[camelized] === 'undefined' || this.hasAttribute(key)) {
+ syncAttribute(this, key)
+ }
+ })
};
if (isInitialized) {
diff --git a/node_modules/@vue/web-component-wrapper/types/index.d.ts b/node_modules/@vue/web-component-wrapper/types/index.d.ts
index 8b67b7b..612102c 100644
--- a/node_modules/@vue/web-component-wrapper/types/index.d.ts
+++ b/node_modules/@vue/web-component-wrapper/types/index.d.ts
@@ -3,6 +3,6 @@ import _Vue, { Component, AsyncComponent } from 'vue'
declare function wrap(
Vue: typeof _Vue,
Component: Component | AsyncComponent
-): HTMLElement
+): CustomElementConstructor
export default wrap

View file

@ -15,7 +15,6 @@ const WebpackSPDXPlugin = require('./WebpackSPDXPlugin.cjs')
const appVersion = readFileSync(path.join(__dirname, '../../version.php')).toString().match(/OC_Version.+\[([0-9]{2})/)?.[1] ?? 'unknown'
const isDev = process.env.NODE_ENV === 'development'
const isTesting = process.env.TESTING === 'true'
/**
*
@ -87,7 +86,30 @@ const config = {
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
oneOf: [
{
resourceQuery: /module/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
namedExport: false,
localIdentName: '_[local]_[hash:base64:5]',
exportLocalsConvention: 'asIs',
},
},
},
],
},
{
use: [
'style-loader',
'css-loader',
],
},
],
},
{
test: /\.scss$/,

View file

@ -7,7 +7,7 @@ const path = require('path')
module.exports = {
comments: {
'comments-app': path.join(__dirname, 'apps/comments/src', 'comments-app.js'),
'comments-tab': path.join(__dirname, 'apps/comments/src', 'comments-tab.js'),
'comments-tab': path.join(__dirname, 'apps/comments/src', 'files-sidebar.ts'),
init: path.join(__dirname, 'apps/comments/src', 'init.ts'),
},
core: {

View file

@ -25,9 +25,9 @@ export default defineConfig({
viewportWidth: 1280,
viewportHeight: 720,
// Tries again 2 more times on failure
// Tries again when in run mode (cypress run) e.g. on CI
retries: {
runMode: 2,
runMode: 3,
// do not retry in `cypress open`
openMode: 0,
},

View file

@ -24,7 +24,9 @@ export const getActionButtonForFile = (filename: string) => getActionsForFile(fi
export function getActionEntryForFileId(fileid: number, actionId: string) {
return getActionButtonForFileId(fileid)
.should('have.attr', 'aria-controls')
.then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
.then((menuId) => cy.get(`#${menuId}`)
.should('exist')
.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
}
/**
@ -35,7 +37,9 @@ export function getActionEntryForFileId(fileid: number, actionId: string) {
export function getActionEntryForFile(file: string, actionId: string) {
return getActionButtonForFile(file)
.should('have.attr', 'aria-controls')
.then((menuId) => cy.get(`#${menuId}`).find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
.then((menuId) => cy.get(`#${menuId}`)
.should('exist')
.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`))
}
/**
@ -65,9 +69,8 @@ export function getInlineActionEntryForFile(file: string, actionId: string) {
*/
export function triggerActionForFileId(fileid: number, actionId: string) {
getActionButtonForFileId(fileid)
.as('actionButton')
.scrollIntoView()
cy.get('@actionButton')
getActionButtonForFileId(fileid)
.click({ force: true }) // force to avoid issues with overlaying file list header
getActionEntryForFileId(fileid, actionId)
.find('button')
@ -82,9 +85,8 @@ export function triggerActionForFileId(fileid: number, actionId: string) {
*/
export function triggerActionForFile(filename: string, actionId: string) {
getActionButtonForFile(filename)
.as('actionButton')
.scrollIntoView()
cy.get('@actionButton')
getActionButtonForFile(filename)
.click({ force: true }) // force to avoid issues with overlaying file list header
getActionEntryForFile(filename, actionId)
.find('button')
@ -281,11 +283,23 @@ export function navigateToFolder(dirPath: string) {
}
/**
*
* Close the sidebar
*/
export function closeSidebar() {
// {force: true} as it might be hidden behind toasts
cy.get('[data-cy-sidebar] .app-sidebar__close').click({ force: true })
cy.get('[data-cy-sidebar] .app-sidebar__close')
.click({ force: true })
cy.get('[data-cy-sidebar]')
.should('not.be.visible')
// eslint-disable-next-line cypress/no-unnecessary-waiting -- wait for the animation to finish
cy.wait(500)
cy.url()
.should('not.contain', 'opendetails')
// close all toasts
cy.get('.toast-success')
.if()
.findAllByRole('button')
.click({ force: true, multiple: true })
}
/**

View file

@ -5,7 +5,7 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils.ts'
import { closeSidebar, getActionButtonForFile, getRowForFile, triggerActionForFile } from './FilesUtils.ts'
describe('files: Favorites', { testIsolation: true }, () => {
let user: User
@ -110,29 +110,54 @@ describe('files: Favorites', { testIsolation: true }, () => {
.contains('new folder')
.should('not.exist')
cy.intercept('PROPPATCH', '**/remote.php/dav/files/*/new%20folder').as('addToFavorites')
cy.intercept('POST', '**/apps/files/api/v1/files/new%20folder').as('addToFavorites')
// open sidebar
triggerActionForFile('new folder', 'details')
// open actions
cy.get('[data-cy-sidebar]')
.should('be.visible')
// open sidebar actions
cy.get('[data-cy-sidebar]')
.findByRole('button', { name: 'Actions' })
.click()
// trigger menu button
cy.findAllByRole('menu')
.findByRole('menuitem', { name: 'Add to favorites' })
.findByRole('menuitem', { name: 'Favorite' })
.should('be.visible')
.click()
cy.wait('@addToFavorites')
// close sidebar
closeSidebar()
// See favorites star
getRowForFile('new folder')
.findByRole('img', { name: 'Favorite' })
.should('be.visible')
// See folder in navigation
cy.get('[data-cy-files-navigation-item="favorites"]')
cy.reload()
getRowForFile('new folder')
.should('be.visible')
.contains('new folder')
.should('exist')
// can unfavorite
triggerActionForFile('new folder', 'details')
cy.get('[data-cy-sidebar]')
.should('be.visible')
cy.get('[data-cy-sidebar]')
.findByRole('button', { name: 'Actions' })
.click()
// trigger menu button
cy.findAllByRole('menu')
.findByRole('menuitem', { name: 'Unfavorite' })
.should('be.visible')
.click()
cy.wait('@addToFavorites')
closeSidebar()
getRowForFile('new folder')
.findByRole('img', { name: 'Favorite' })
.should('not.exist')
})
})

View file

@ -119,7 +119,7 @@ describe('Files: Sidebar', { testIsolation: true }, () => {
triggerActionForFile('other', 'delete')
cy.wait('@deleteFile')
cy.get('[data-cy-sidebar]').should('not.exist')
cy.get('[data-cy-sidebar]').should('not.be.visible')
// Ensure the URL is changed
cy.url().should('not.contain', `apps/files/files/${otherFileId}`)
})

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { triggerActionForFile } from '../files/FilesUtils.ts'
import { closeSidebar, triggerActionForFile } from '../files/FilesUtils.ts'
export interface ShareSetting {
read: boolean
@ -18,6 +18,7 @@ export interface ShareSetting {
export function createShare(fileName: string, username: string, shareSettings: Partial<ShareSetting> = {}) {
openSharingPanel(fileName)
cy.intercept('POST', '**/ocs/v2.php/apps/files_sharing/api/v1/shares').as('createShare')
cy.get('#app-sidebar-vue').within(() => {
cy.intercept({ times: 1, method: 'GET', url: '**/apps/files_sharing/api/v1/sharees?*' }).as('userSearch')
@ -30,13 +31,20 @@ export function createShare(fileName: string, username: string, shareSettings: P
// HACK: Save the share and then update it, as permissions changes are currently not saved for new share.
cy.get('[data-cy-files-sharing-share-editor-action="save"]').click({ scrollBehavior: 'nearest' })
cy.wait('@createShare')
closeSidebar()
updateShare(fileName, 0, shareSettings)
}
export function openSharingDetails(index: number) {
cy.get('#app-sidebar-vue').within(() => {
cy.get('[data-cy-files-sharing-share-actions]').eq(index).click({ force: true })
cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]').click()
cy.findAllByRole('button', { name: /open sharing details/i })
.should('have.length.at.least', index + 1)
.eq(index)
.click({ force: true })
cy.get('[data-cy-files-sharing-share-permissions-bundle="custom"]')
.click()
})
}
@ -51,10 +59,16 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
cy.get('[data-cy-files-sharing-share-permissions-checkbox="download"]').find('input').as('downloadCheckbox')
if (shareSettings.download) {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@downloadCheckbox').check({ force: true, scrollBehavior: 'nearest' })
cy.get('@downloadCheckbox')
.check({ force: true, scrollBehavior: 'nearest' })
cy.get('@downloadCheckbox')
.should('be.checked')
} else {
// Force:true because the checkbox is hidden by the pretty UI.
cy.get('@downloadCheckbox').uncheck({ force: true, scrollBehavior: 'nearest' })
cy.get('@downloadCheckbox')
.uncheck({ force: true, scrollBehavior: 'nearest' })
cy.get('@downloadCheckbox')
.should('not.be.checked')
}
}
@ -118,14 +132,16 @@ export function updateShare(fileName: string, index: number, shareSettings: Part
cy.wait('@updateShare')
})
// close all toasts
cy.get('.toast-success').findAllByRole('button').click({ force: true, multiple: true })
closeSidebar()
}
export function openSharingPanel(fileName: string) {
triggerActionForFile(fileName, 'details')
cy.get('[data-cy-sidebar]')
.as('sidebar')
.should('be.visible')
cy.get('@sidebar')
.find('[aria-controls="tab-sharing"]')
.click()
}

View file

@ -90,11 +90,13 @@ describe('files_sharing: Expiry date', () => {
prepareDirectory(dir)
updateShare(dir, 0, { expiryDate: fortnight })
validateExpiryDate(dir, fortnightString)
closeSidebar()
cy.log('Upadate share and validate expiry date is kept')
updateShare(dir, 0, { note: 'Only note changed' })
validateExpiryDate(dir, fortnightString)
cy.log('Reload page and validate expiry date is kept')
cy.visit('/apps/files')
validateExpiryDate(dir, fortnightString)
})

View file

@ -29,8 +29,8 @@ describe('Limit to sharing to people in the same group', () => {
cy.createRandomUser()
.then((user) => {
alice = user
cy.createRandomUser()
})
cy.createRandomUser()
.then((user) => {
bob = user
@ -49,9 +49,12 @@ describe('Limit to sharing to people in the same group', () => {
cy.login(alice)
cy.visit('/apps/files')
createShare(randomFileName1, bob.userId)
cy.logout()
cy.login(bob)
cy.visit('/apps/files')
createShare(randomFileName2, alice.userId)
cy.logout()
})
})

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,13 @@ 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]')
.as('sidebar')
.should('be.visible')
cy.get('@sidebar')
.find('[aria-controls="tab-files_versions"]')
.click()
// Wait for the versions list to be fetched
cy.wait('@getVersions')
@ -85,6 +89,7 @@ export function setupTestSharedFileFromUser(owner: User, randomFileName: string,
cy.login(owner)
cy.visit('/apps/files')
createShare(randomFileName, recipient.userId, shareOptions)
cy.login(recipient)
cy.visit('/apps/files')
return cy.wrap(recipient)

View file

@ -6,95 +6,56 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomString } from '../../support/utils/randomString.ts'
import { getRowForFile, navigateToFolder } from '../files/FilesUtils.ts'
import { navigateToFolder } from '../files/FilesUtils.ts'
import { deleteVersion, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts'
describe('Versions restoration', () => {
describe('Versions deletion', () => {
const folderName = 'shared_folder'
const randomFileName = randomString(10) + '.txt'
const randomFilePath = `/${folderName}/${randomFileName}`
let user: User
let versionCount = 0
before(() => {
beforeEach(() => {
cy.createRandomUser()
.then((_user) => {
user = _user
cy.mkdir(user, `/${folderName}`)
uploadThreeVersions(user, randomFilePath)
uploadThreeVersions(user, randomFilePath)
versionCount = 6
versionCount = 3
cy.login(user)
cy.visit('/apps/files')
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
})
})
it('Delete initial version', () => {
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
cy.get('[data-files-versions-version]')
.should('have.length', versionCount)
deleteVersion(--versionCount)
cy.get('[data-files-versions-version]')
.should('have.length', versionCount)
})
it('Delete versions of shared file with delete permission', () => {
setupTestSharedFileFromUser(user, folderName, { delete: true })
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
cy.get('[data-files-versions-version]').should('have.length', versionCount)
deleteVersion(2)
versionCount--
deleteVersion(--versionCount)
cy.get('[data-files-versions-version]').should('have.length', versionCount)
})
context('Delete versions of shared file', () => {
it('Works with delete permission', () => {
setupTestSharedFileFromUser(user, folderName, { delete: true })
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
it('Delete versions of shared file without delete permission', () => {
setupTestSharedFileFromUser(user, folderName, { delete: false })
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
cy.get('[data-files-versions-version]').should('have.length', versionCount)
deleteVersion(2)
versionCount--
cy.get('[data-files-versions-version]').should('have.length', versionCount)
})
it('Does not work without delete permission', () => {
setupTestSharedFileFromUser(user, folderName, { delete: false })
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
doesNotHaveAction(0, 'delete')
doesNotHaveAction(1, 'delete')
doesNotHaveAction(2, 'delete')
})
it('Does not work without delete permission through direct API access', () => {
let fileId: string | undefined
let versionId: string | undefined
setupTestSharedFileFromUser(user, folderName, { delete: false })
.then((recipient) => {
navigateToFolder(folderName)
openVersionsPanel(randomFilePath)
getRowForFile(randomFileName)
.should('be.visible')
.invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
cy.get('[data-files-versions-version]')
.eq(1)
.invoke('attr', 'data-files-versions-version')
.then(($versionId) => { versionId = $versionId })
cy.logout()
cy.then(() => {
const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
return cy.request({
method: 'DELETE',
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
failOnStatusCode: false,
})
}).then(({ status }) => {
expect(status).to.equal(403)
})
})
})
doesNotHaveAction(0, 'delete')
doesNotHaveAction(1, 'delete')
doesNotHaveAction(2, 'delete')
})
})

View file

@ -6,91 +6,54 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomString } from '../../support/utils/randomString.ts'
import { getRowForFile } from '../files/FilesUtils.ts'
import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts'
describe('Versions download', () => {
let randomFileName = ''
let user: User
before(() => {
randomFileName = randomString(10) + '.txt'
cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')
cy.createRandomUser()
.then((_user) => {
user = _user
uploadThreeVersions(user, randomFileName)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel(randomFileName)
})
})
before(() => cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download'))
after(() => {
cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download')
})
beforeEach(() => {
randomFileName = randomString(10) + '.txt'
cy.createRandomUser()
.then((_user) => {
user = _user
uploadThreeVersions(user, randomFileName)
})
})
it('Download versions and assert their content', () => {
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel(randomFileName)
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v2')
assertVersionContent(2, 'v1')
})
context('Download versions of shared file', () => {
it('Works with download permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { download: true })
openVersionsPanel(randomFileName)
it('Download versions of shared file with download permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { download: true })
openVersionsPanel(randomFileName)
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v2')
assertVersionContent(2, 'v1')
})
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v2')
assertVersionContent(2, 'v1')
})
it('Does not show action without download permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { download: false })
openVersionsPanel(randomFileName)
it('Does not show action without download permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { download: false })
openVersionsPanel(randomFileName)
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="download"]').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="download"]').should('not.exist')
doesNotHaveAction(1, 'download')
doesNotHaveAction(2, 'download')
})
it('Does not work without download permission through direct API access', () => {
let fileId: string | undefined
let versionId: string | undefined
setupTestSharedFileFromUser(user, randomFileName, { download: false })
.then((recipient) => {
openVersionsPanel(randomFileName)
getRowForFile(randomFileName)
.should('be.visible')
.invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
cy.get('[data-files-versions-version]')
.eq(1)
.invoke('attr', 'data-files-versions-version')
.then(($versionId) => { versionId = $versionId })
cy.logout()
cy.then(() => {
const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
return cy.request({
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
failOnStatusCode: false,
})
}).then(({ status }) => {
expect(status).to.equal(403)
})
})
})
doesNotHaveAction(1, 'download')
doesNotHaveAction(2, 'download')
})
})

View file

@ -6,27 +6,30 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomString } from '../../support/utils/randomString.ts'
import { getRowForFile } from '../files/FilesUtils.ts'
import { navigateToFolder } from '../files/FilesUtils.ts'
import { doesNotHaveAction, nameVersion, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts'
describe('Versions naming', () => {
let randomFileName = ''
let user: User
before(() => {
beforeEach(() => {
randomFileName = randomString(10) + '.txt'
cy.createRandomUser()
.then((_user) => {
user = _user
uploadThreeVersions(user, randomFileName)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel(randomFileName)
cy.mkdir(_user, '/share')
uploadThreeVersions(user, `share/${randomFileName}`)
})
})
it('Names the versions', () => {
cy.login(user)
cy.visit('/apps/files')
navigateToFolder('share')
openVersionsPanel(randomFileName)
nameVersion(2, 'v1')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(2).contains('v1')
@ -44,92 +47,45 @@ describe('Versions naming', () => {
})
})
context('Name versions of shared file', () => {
context('with edit permission', () => {
before(() => {
setupTestSharedFileFromUser(user, randomFileName, { update: true })
openVersionsPanel(randomFileName)
})
it('Name versions of shared file with edit permission', () => {
setupTestSharedFileFromUser(user, 'share', { update: true })
it('Names the versions', () => {
nameVersion(2, 'v1 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
navigateToFolder('share')
openVersionsPanel(randomFileName)
nameVersion(1, 'v2 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared')
})
nameVersion(0, 'v3 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)')
})
})
nameVersion(2, 'v1 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
context('without edit permission', () => {
let recipient: User
nameVersion(1, 'v2 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared')
})
beforeEach(() => {
setupTestSharedFileFromUser(user, randomFileName, { update: false })
.then(($recipient) => {
recipient = $recipient
openVersionsPanel(randomFileName)
})
})
it('Does not show action', () => {
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').should('not.exist')
doesNotHaveAction(1, 'label')
doesNotHaveAction(2, 'label')
})
it('Does not work without update permission through direct API access', () => {
let fileId: string | undefined
let versionId: string | undefined
getRowForFile(randomFileName)
.should('be.visible')
.invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
cy.get('[data-files-versions-version]')
.eq(1)
.invoke('attr', 'data-files-versions-version')
.then(($versionId) => { versionId = $versionId })
cy.logout()
cy.then(() => {
const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '')
return cy.request({
method: 'PROPPATCH',
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
},
body: `<?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>not authorized labeling</nc:version-label>
</d:prop>
</d:set>
</d:propertyupdate>`,
failOnStatusCode: false,
})
}).then(({ status }) => {
expect(status).to.equal(403)
})
})
nameVersion(0, 'v3 - shared')
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)')
})
})
it('Name versions without edit permission fails', () => {
setupTestSharedFileFromUser(user, 'share', { update: false })
navigateToFolder('share')
openVersionsPanel(randomFileName)
cy.get('[data-files-versions-version]')
.eq(0)
.as('firstVersion')
.find('.action-item__menutoggle')
.should('not.exist')
cy.get('@firstVersion')
.find('[data-cy-version-action="label"]')
.should('not.exist')
doesNotHaveAction(1, 'label')
doesNotHaveAction(2, 'label')
})
})

View file

@ -6,31 +6,31 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomString } from '../../support/utils/randomString.ts'
import { getRowForFile } from '../files/FilesUtils.ts'
import { navigateToFolder } from '../files/FilesUtils.ts'
import { assertVersionContent, doesNotHaveAction, openVersionsPanel, restoreVersion, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts'
describe('Versions restoration', () => {
let randomFileName = ''
let user: User
before(() => {
beforeEach(() => {
randomFileName = randomString(10) + '.txt'
cy.createRandomUser()
.then((_user) => {
user = _user
uploadThreeVersions(user, randomFileName)
cy.mkdir(_user, '/share')
uploadThreeVersions(user, `share/${randomFileName}`)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel(randomFileName)
})
})
it('Current version does not have restore action', () => {
doesNotHaveAction(0, 'restore')
})
it('Restores initial version', () => {
navigateToFolder('share')
openVersionsPanel(randomFileName)
// Current version does not have restore action
doesNotHaveAction(0, 'restore')
restoreVersion(2)
cy.get('#tab-files_versions').within(() => {
@ -38,81 +38,38 @@ describe('Versions restoration', () => {
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
})
it('Downloads versions and assert there content', () => {
// Downloads versions and assert there content
assertVersionContent(0, 'v1')
assertVersionContent(1, 'v3')
assertVersionContent(2, 'v2')
})
context('Restore versions of shared file', () => {
it('Works with update permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { update: true })
openVersionsPanel(randomFileName)
it('Restore versions of shared file with update permission', () => {
setupTestSharedFileFromUser(user, 'share', { update: true })
navigateToFolder('share')
openVersionsPanel(randomFileName)
it('Restores initial version', () => {
restoreVersion(2)
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 3)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
})
it('Downloads versions and assert there content', () => {
assertVersionContent(0, 'v1')
assertVersionContent(1, 'v3')
assertVersionContent(2, 'v2')
})
restoreVersion(2)
cy.get('#tab-files_versions').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 3)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
assertVersionContent(0, 'v1')
assertVersionContent(1, 'v3')
assertVersionContent(2, 'v2')
})
it('Does not show action without delete permission', () => {
setupTestSharedFileFromUser(user, randomFileName, { update: false })
openVersionsPanel(randomFileName)
it('Does not show action without delete permission', () => {
setupTestSharedFileFromUser(user, 'share', { update: false })
navigateToFolder('share')
openVersionsPanel(randomFileName)
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="restore"]').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="restore"]').should('not.exist')
doesNotHaveAction(1, 'restore')
doesNotHaveAction(2, 'restore')
})
it('Does not work without update permission through direct API access', () => {
let fileId: string | undefined
let versionId: string | undefined
setupTestSharedFileFromUser(user, randomFileName, { update: false })
.then((recipient) => {
openVersionsPanel(randomFileName)
getRowForFile(randomFileName)
.should('be.visible')
.invoke('attr', 'data-cy-files-list-row-fileid')
.then(($fileId) => { fileId = $fileId })
cy.get('[data-files-versions-version]')
.eq(1)
.invoke('attr', 'data-files-versions-version')
.then(($versionId) => { versionId = $versionId })
cy.logout()
cy.then(() => {
const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
return cy.request({
method: 'MOVE',
url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
auth: { user: recipient.userId, pass: recipient.password },
headers: {
cookie: '',
Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`,
},
failOnStatusCode: false,
})
}).then(({ status }) => {
expect(status).to.equal(403)
})
})
})
doesNotHaveAction(1, 'restore')
doesNotHaveAction(2, 'restore')
})
})

View file

@ -1,11 +1,13 @@
/**
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { closeSidebar, getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
import { getRowForFile } from '../files/FilesUtils.ts'
import { addTagToFile } from './utils.ts'
describe('Systemtags: Files integration', { testIsolation: true }, () => {
let user: User
@ -21,31 +23,7 @@ describe('Systemtags: Files integration', { testIsolation: true }, () => {
it('See first assigned tag in the file list', () => {
const tag = randomBytes(8).toString('base64')
cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'details')
cy.wait('@getNode')
cy.get('[data-cy-sidebar]')
.should('be.visible')
.findByRole('button', { name: 'Actions' })
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Tags' })
.should('be.visible')
.click()
cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
getCollaborativeTagsInput()
.type(`{selectAll}${tag}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Close the sidebar and reload to check the file list
closeSidebar()
addTagToFile('file.txt', tag)
cy.reload()
getRowForFile('file.txt')
@ -58,38 +36,8 @@ describe('Systemtags: Files integration', { testIsolation: true }, () => {
it('See two assigned tags are also shown in the file list', () => {
const tag1 = randomBytes(5).toString('base64')
const tag2 = randomBytes(5).toString('base64')
cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'details')
cy.wait('@getNode')
cy.get('[data-cy-sidebar]')
.should('be.visible')
.findByRole('button', { name: 'Actions' })
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Tags' })
.should('be.visible')
.click()
cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
// Assign first tag
getCollaborativeTagsInput()
.type(`{selectAll}${tag1}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Assign second tag
getCollaborativeTagsInput()
.type(`{selectAll}${tag2}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Close the sidebar and reload to check the file list
closeSidebar()
addTagToFile('file.txt', tag1)
addTagToFile('file.txt', tag2)
cy.reload()
getRowForFile('file.txt')
@ -104,44 +52,9 @@ describe('Systemtags: Files integration', { testIsolation: true }, () => {
const tag1 = randomBytes(4).toString('base64')
const tag2 = randomBytes(4).toString('base64')
const tag3 = randomBytes(4).toString('base64')
cy.intercept('PROPFIND', `**/remote.php/dav/files/${user.userId}/file.txt`).as('getNode')
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'details')
cy.wait('@getNode')
cy.get('[data-cy-sidebar]')
.should('be.visible')
.findByRole('button', { name: 'Actions' })
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Tags' })
.should('be.visible')
.click()
cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
// Assign first tag
getCollaborativeTagsInput()
.type(`{selectAll}${tag1}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Assign second tag
getCollaborativeTagsInput()
.type(`{selectAll}${tag2}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Assign third tag
getCollaborativeTagsInput()
.type(`{selectAll}${tag3}{enter}`)
cy.wait('@assignTag')
cy.wait('@getNode')
// Close the sidebar and reload to check the file list
closeSidebar()
addTagToFile('file.txt', tag1)
addTagToFile('file.txt', tag2)
addTagToFile('file.txt', tag3)
cy.reload()
getRowForFile('file.txt')
@ -163,10 +76,3 @@ describe('Systemtags: Files integration', { testIsolation: true }, () => {
})
})
})
function getCollaborativeTagsInput(): Cypress.Chainable<JQuery<HTMLElement>> {
return cy.get('[data-cy-sidebar]')
.findByRole('combobox', { name: /collaborative tags/i })
.should('be.visible')
.should('not.have.attr', 'disabled', { timeout: 5000 })
}

View file

@ -7,6 +7,7 @@ import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
import { createNewTagInDialog } from './utils.ts'
describe('Systemtags: Files sidebar integration', { testIsolation: true }, () => {
let user: User
@ -32,14 +33,9 @@ describe('Systemtags: Files sidebar integration', { testIsolation: true }, () =>
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Tags' })
cy.findByRole('menuitem', { name: 'Add tags' })
.click()
cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
cy.get('[data-cy-sidebar]')
.findByRole('combobox', { name: /collaborative tags/i })
.should('be.visible')
.type(`${tag}{enter}`)
cy.wait('@assignTag')
createNewTagInDialog(tag)
})
})

View file

@ -6,7 +6,8 @@
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { closeSidebar, getRowForFile, getRowForFileId, triggerActionForFile } from '../files/FilesUtils.ts'
import { getRowForFile } from '../files/FilesUtils.ts'
import { addTagToFile } from './utils.ts'
describe('Systemtags: Files view', { testIsolation: true }, () => {
let user: User
@ -22,51 +23,20 @@ describe('Systemtags: Files view', { testIsolation: true }, () => {
it('See first assigned tag in the file list', () => {
const tag = randomBytes(8).toString('base64')
let tagId
// Tag the file
tagNode(tag, 'folder')
.then((id) => { tagId = id })
addTagToFile('folder', tag)
// open the tags view
cy.visit('/apps/files/tags').then(() => {
// see the tag
getRowForFileId(tagId).should('be.visible')
getRowForFile('folder').should('not.exist')
getRowForFile('file.txt').should('not.exist')
cy.findByRole('cell', { name: tag })
.should('be.visible')
.click()
// see that the tag has its content
getRowForFileId(tagId).find('[data-cy-files-list-row-name-link]').click()
getRowForFile('folder').should('be.visible')
getRowForFile('file.txt').should('not.exist')
})
})
})
function getCollaborativeTagsInput(): Cypress.Chainable<JQuery<HTMLElement>> {
return cy.get('[data-cy-sidebar]')
.findByRole('combobox', { name: /collaborative tags/i })
.should('be.visible')
.should('not.have.attr', 'disabled', { timeout: 5000 })
}
function tagNode(tag: string, node: string): Cypress.Chainable<number> {
getRowForFile(node).should('be.visible')
triggerActionForFile(node, 'details')
cy.get('[data-cy-sidebar]')
.should('be.visible')
.findByRole('button', { name: 'Actions' })
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Tags' })
.should('be.visible')
.click()
cy.intercept('PUT', '**/remote.php/dav/systemtags-relations/files/**').as('assignTag')
getCollaborativeTagsInput()
.type(`{selectAll}${tag}{enter}`)
cy.wait('@assignTag')
closeSidebar()
return cy.get('@assignTag')
.then(({ request }) => request.body.id)
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
export function addTagToFile(fileName: string, newTag: string): void {
getRowForFile(fileName).should('be.visible')
triggerActionForFile(fileName, 'systemtags:bulk')
createNewTagInDialog(newTag)
}
export function createNewTagInDialog(newTag: string): void {
cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
cy.get('[data-cy-systemtags-picker-input]').type(newTag)
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0)
cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible')
cy.get('[data-cy-systemtags-picker-button-create]').click()
cy.wait('@createTag')
// Verify the new tag is selected by default
cy.get('[data-cy-systemtags-picker-tag]').contains(newTag)
.parents('[data-cy-systemtags-picker-tag]')
.findByRole('checkbox', { hidden: true }).should('be.checked')
// Apply changes
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
}

2
dist/1035-1035.js vendored Normal file
View file

@ -0,0 +1,2 @@
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[1035],{67246(e,t,n){n.d(t,{A:()=>i});var a=n(71354),s=n.n(a),r=n(76314),p=n.n(r)()(s());p.push([e.id,".search-empty-view__input[data-v-4e4bf1e2]{flex:0 1;min-width:min(400px,50vw)}.search-empty-view__wrapper[data-v-4e4bf1e2]{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline}","",{version:3,sources:["webpack://./apps/files/src/views/SearchEmptyView.vue"],names:[],mappings:"AAEC,2CACC,QAAA,CACA,yBAAA,CAGD,6CACC,YAAA,CACA,cAAA,CACA,QAAA,CACA,oBAAA",sourcesContent:["\n.search-empty-view {\n\t&__input {\n\t\tflex: 0 1;\n\t\tmin-width: min(400px, 50vw);\n\t}\n\n\t&__wrapper {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 10px;\n\t\talign-items: baseline;\n\t}\n}\n"],sourceRoot:""}]);const i=p},91035(e,t,n){n.r(t),n.d(t,{default:()=>N});var a=n(85471),s=n(9165),r=n(53334),p=n(46855),i=n(42507),l=n(6695),c=n(16879),o=n(88140),u=n(78246);const A=(0,a.pM)({__name:"SearchEmptyView",setup(e){const t=(0,u.j)((0,o.u)()),n=(0,p.A)(e=>{t.query=e},500);return{__sfc:!0,searchStore:t,debouncedUpdate:n,mdiMagnifyClose:s.WBH,t:r.t,NcEmptyContent:i.A,NcIconSvgWrapper:l.A,NcInputField:c.A}}});var d=n(85072),y=n.n(d),f=n(97825),m=n.n(f),C=n(77659),_=n.n(C),h=n(55056),w=n.n(h),v=n(10540),x=n.n(v),b=n(41113),g=n.n(b),S=n(67246),k={};k.styleTagTransform=g(),k.setAttributes=w(),k.insert=_().bind(null,"head"),k.domAPI=m(),k.insertStyleElement=x(),y()(S.A,k),S.A&&S.A.locals&&S.A.locals;const N=(0,n(14486).A)(A,function(){var e=this,t=e._self._c,n=e._self._setupProxy;return t(n.NcEmptyContent,{attrs:{name:n.t("files","No search results for “{query}”",{query:n.searchStore.query})},scopedSlots:e._u([{key:"icon",fn:function(){return[t(n.NcIconSvgWrapper,{attrs:{path:n.mdiMagnifyClose}})]},proxy:!0},{key:"action",fn:function(){return[t("div",{staticClass:"search-empty-view__wrapper"},[t(n.NcInputField,{staticClass:"search-empty-view__input",attrs:{label:n.t("files","Search for files"),"model-value":n.searchStore.query,type:"search"},on:{"update:model-value":n.debouncedUpdate}})],1)]},proxy:!0}])})},[],!1,null,"4e4bf1e2",null).exports}}]);
//# sourceMappingURL=1035-1035.js.map?v=da08d310d18692ca4e27

1
dist/1035-1035.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/1035-1035.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
1035-1035.js.license

2
dist/1082-1082.js vendored
View file

@ -1,2 +0,0 @@
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[1082],{1082(e,t,n){n.r(t),n.d(t,{default:()=>N});var a=n(85471),s=n(9165),r=n(53334),p=n(46855),i=n(42507),l=n(6695),c=n(16879),o=n(4114),u=n(82736);const A=(0,a.pM)({__name:"SearchEmptyView",setup(e){const t=(0,u.j)((0,o.u)()),n=(0,p.A)(e=>{t.query=e},500);return{__sfc:!0,searchStore:t,debouncedUpdate:n,mdiMagnifyClose:s.WBH,t:r.t,NcEmptyContent:i.A,NcIconSvgWrapper:l.A,NcInputField:c.A}}});var d=n(85072),y=n.n(d),f=n(97825),m=n.n(f),C=n(77659),_=n.n(C),h=n(55056),w=n.n(h),v=n(10540),x=n.n(v),b=n(41113),g=n.n(b),S=n(67246),k={};k.styleTagTransform=g(),k.setAttributes=w(),k.insert=_().bind(null,"head"),k.domAPI=m(),k.insertStyleElement=x(),y()(S.A,k),S.A&&S.A.locals&&S.A.locals;const N=(0,n(14486).A)(A,function(){var e=this,t=e._self._c,n=e._self._setupProxy;return t(n.NcEmptyContent,{attrs:{name:n.t("files","No search results for “{query}”",{query:n.searchStore.query})},scopedSlots:e._u([{key:"icon",fn:function(){return[t(n.NcIconSvgWrapper,{attrs:{path:n.mdiMagnifyClose}})]},proxy:!0},{key:"action",fn:function(){return[t("div",{staticClass:"search-empty-view__wrapper"},[t(n.NcInputField,{staticClass:"search-empty-view__input",attrs:{label:n.t("files","Search for files"),"model-value":n.searchStore.query,type:"search"},on:{"update:model-value":n.debouncedUpdate}})],1)]},proxy:!0}])})},[],!1,null,"4e4bf1e2",null).exports},67246(e,t,n){n.d(t,{A:()=>i});var a=n(71354),s=n.n(a),r=n(76314),p=n.n(r)()(s());p.push([e.id,".search-empty-view__input[data-v-4e4bf1e2]{flex:0 1;min-width:min(400px,50vw)}.search-empty-view__wrapper[data-v-4e4bf1e2]{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline}","",{version:3,sources:["webpack://./apps/files/src/views/SearchEmptyView.vue"],names:[],mappings:"AAEC,2CACC,QAAA,CACA,yBAAA,CAGD,6CACC,YAAA,CACA,cAAA,CACA,QAAA,CACA,oBAAA",sourcesContent:["\n.search-empty-view {\n\t&__input {\n\t\tflex: 0 1;\n\t\tmin-width: min(400px, 50vw);\n\t}\n\n\t&__wrapper {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 10px;\n\t\talign-items: baseline;\n\t}\n}\n"],sourceRoot:""}]);const i=p}}]);
//# sourceMappingURL=1082-1082.js.map?v=d6863ba280d66116c0a8

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
1082-1082.js.license

2
dist/1216-1216.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/1216-1216.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/1216-1216.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
1216-1216.js.license

View file

@ -1,2 +1,2 @@
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[6358],{16358(e,t,a){a.r(t),a.d(t,{default:()=>d});var s=a(53334),l=a(85471),o=a(94219),r=a(371),n=a(93663),i=a(82182);const c=(0,l.pM)({name:"CredentialsDialog",components:{NcDialog:o.A,NcNoteCard:r.A,NcTextField:i.A,NcPasswordField:n.A},setup:()=>({t:s.t}),data:()=>({login:"",password:""}),computed:{dialogButtons:()=>[{label:(0,s.t)("files_external","Confirm"),type:"submit",variant:"primary"}]}}),d=(0,a(14486).A)(c,function(){var e=this,t=e._self._c;return e._self._setupProxy,t("NcDialog",{staticClass:"external-storage-auth",attrs:{buttons:e.dialogButtons,"close-on-click-outside":"","data-cy-external-storage-auth":"","is-form":"",name:e.t("files_external","Storage credentials"),"out-transition":""},on:{submit:function(t){return e.$emit("close",{login:e.login,password:e.password})},"update:open":function(t){return e.$emit("close")}}},[t("NcNoteCard",{staticClass:"external-storage-auth__header",attrs:{text:e.t("files_external","To access the storage, you need to provide the authentication credentials."),type:"info"}}),e._v(" "),t("NcTextField",{ref:"login",staticClass:"external-storage-auth__login",attrs:{"data-cy-external-storage-auth-dialog-login":"",label:e.t("files_external","Login"),placeholder:e.t("files_external","Enter the storage login"),minlength:"2",name:"login",required:""},model:{value:e.login,callback:function(t){e.login=t},expression:"login"}}),e._v(" "),t("NcPasswordField",{ref:"password",staticClass:"external-storage-auth__password",attrs:{"data-cy-external-storage-auth-dialog-password":"",label:e.t("files_external","Password"),placeholder:e.t("files_external","Enter the storage password"),name:"password",required:""},model:{value:e.password,callback:function(t){e.password=t},expression:"password"}})],1)},[],!1,null,null,null).exports}}]);
//# sourceMappingURL=6358-6358.js.map?v=32b8826edce0bc146e33
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[1261],{11261(e,t,a){a.r(t),a.d(t,{default:()=>d});var s=a(53334),l=a(85471),o=a(94219),r=a(371),n=a(93663),i=a(82182);const c=(0,l.pM)({name:"CredentialsDialog",components:{NcDialog:o.A,NcNoteCard:r.A,NcTextField:i.A,NcPasswordField:n.A},setup:()=>({t:s.t}),data:()=>({login:"",password:""}),computed:{dialogButtons:()=>[{label:(0,s.t)("files_external","Confirm"),type:"submit",variant:"primary"}]}}),d=(0,a(14486).A)(c,function(){var e=this,t=e._self._c;return e._self._setupProxy,t("NcDialog",{staticClass:"external-storage-auth",attrs:{buttons:e.dialogButtons,"close-on-click-outside":"","data-cy-external-storage-auth":"","is-form":"",name:e.t("files_external","Storage credentials"),"out-transition":""},on:{submit:function(t){return e.$emit("close",{login:e.login,password:e.password})},"update:open":function(t){return e.$emit("close")}}},[t("NcNoteCard",{staticClass:"external-storage-auth__header",attrs:{text:e.t("files_external","To access the storage, you need to provide the authentication credentials."),type:"info"}}),e._v(" "),t("NcTextField",{ref:"login",staticClass:"external-storage-auth__login",attrs:{"data-cy-external-storage-auth-dialog-login":"",label:e.t("files_external","Login"),placeholder:e.t("files_external","Enter the storage login"),minlength:"2",name:"login",required:""},model:{value:e.login,callback:function(t){e.login=t},expression:"login"}}),e._v(" "),t("NcPasswordField",{ref:"password",staticClass:"external-storage-auth__password",attrs:{"data-cy-external-storage-auth-dialog-password":"",label:e.t("files_external","Password"),placeholder:e.t("files_external","Enter the storage password"),name:"password",required:""},model:{value:e.password,callback:function(t){e.password=t},expression:"password"}})],1)},[],!1,null,null,null).exports}}]);
//# sourceMappingURL=1261-1261.js.map?v=e5911953e2704a8c51c4

View file

@ -1 +1 @@
{"version":3,"file":"6358-6358.js?v=32b8826edce0bc146e33","mappings":"kKAAA,I,gEAMA,MCNiQ,GDMlPA,EAAAA,EAAAA,IAAgB,CAC3BC,KAAM,oBACNC,WAAY,CACRC,SAAQ,IACRC,WAAU,IACVC,YAAW,IACXC,gBAAeA,EAAAA,GAEnBC,MAAKA,KACM,CACHC,EAACA,EAAAA,IAGTC,KAAIA,KACO,CACHC,MAAO,GACPC,SAAU,KAGlBC,SAAU,CACNC,cAAaA,IACF,CAAC,CACAC,OAAON,EAAAA,EAAAA,GAAE,iBAAkB,WAC3BO,KAAM,SACNC,QAAS,eEZ7B,GAXgB,E,SAAA,GACd,EFRW,WAAkB,IAAIC,EAAIC,KAAKC,EAAGF,EAAIG,MAAMD,GAAgC,OAAtBF,EAAIG,MAAMC,YAAmBF,EAAG,WAAW,CAACG,YAAY,wBAAwBC,MAAM,CAAC,QAAUN,EAAIJ,cAAc,yBAAyB,GAAG,gCAAgC,GAAG,UAAU,GAAG,KAAOI,EAAIT,EAAE,iBAAkB,uBAAuB,iBAAiB,IAAIgB,GAAG,CAAC,OAAS,SAASC,GAAQ,OAAOR,EAAIS,MAAM,QAAS,CAAEhB,MAAOO,EAAIP,MAAOC,SAAUM,EAAIN,UAAW,EAAE,cAAc,SAASc,GAAQ,OAAOR,EAAIS,MAAM,QAAQ,IAAI,CAACP,EAAG,aAAa,CAACG,YAAY,gCAAgCC,MAAM,CAAC,KAAON,EAAIT,EAAE,iBAAkB,8EAA8E,KAAO,UAAUS,EAAIU,GAAG,KAAKR,EAAG,cAAc,CAACS,IAAI,QAAQN,YAAY,+BAA+BC,MAAM,CAAC,6CAA6C,GAAG,MAAQN,EAAIT,EAAE,iBAAkB,SAAS,YAAcS,EAAIT,EAAE,iBAAkB,2BAA2B,UAAY,IAAI,KAAO,QAAQ,SAAW,IAAIqB,MAAM,CAACC,MAAOb,EAAIP,MAAOqB,SAAS,SAAUC,GAAMf,EAAIP,MAAMsB,CAAG,EAAEC,WAAW,WAAWhB,EAAIU,GAAG,KAAKR,EAAG,kBAAkB,CAACS,IAAI,WAAWN,YAAY,kCAAkCC,MAAM,CAAC,gDAAgD,GAAG,MAAQN,EAAIT,EAAE,iBAAkB,YAAY,YAAcS,EAAIT,EAAE,iBAAkB,8BAA8B,KAAO,WAAW,SAAW,IAAIqB,MAAM,CAACC,MAAOb,EAAIN,SAAUoB,SAAS,SAAUC,GAAMf,EAAIN,SAASqB,CAAG,EAAEC,WAAW,eAAe,EACz7C,EACsB,IESpB,EACA,KACA,KACA,M","sources":["webpack:///nextcloud/apps/files_external/src/views/CredentialsDialog.vue","webpack:///nextcloud/apps/files_external/src/views/CredentialsDialog.vue?vue&type=script&lang=ts","webpack://nextcloud/./apps/files_external/src/views/CredentialsDialog.vue?7767"],"sourcesContent":["var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c('NcDialog',{staticClass:\"external-storage-auth\",attrs:{\"buttons\":_vm.dialogButtons,\"close-on-click-outside\":\"\",\"data-cy-external-storage-auth\":\"\",\"is-form\":\"\",\"name\":_vm.t('files_external', 'Storage credentials'),\"out-transition\":\"\"},on:{\"submit\":function($event){return _vm.$emit('close', { login: _vm.login, password: _vm.password })},\"update:open\":function($event){return _vm.$emit('close')}}},[_c('NcNoteCard',{staticClass:\"external-storage-auth__header\",attrs:{\"text\":_vm.t('files_external', 'To access the storage, you need to provide the authentication credentials.'),\"type\":\"info\"}}),_vm._v(\" \"),_c('NcTextField',{ref:\"login\",staticClass:\"external-storage-auth__login\",attrs:{\"data-cy-external-storage-auth-dialog-login\":\"\",\"label\":_vm.t('files_external', 'Login'),\"placeholder\":_vm.t('files_external', 'Enter the storage login'),\"minlength\":\"2\",\"name\":\"login\",\"required\":\"\"},model:{value:(_vm.login),callback:function ($$v) {_vm.login=$$v},expression:\"login\"}}),_vm._v(\" \"),_c('NcPasswordField',{ref:\"password\",staticClass:\"external-storage-auth__password\",attrs:{\"data-cy-external-storage-auth-dialog-password\":\"\",\"label\":_vm.t('files_external', 'Password'),\"placeholder\":_vm.t('files_external', 'Enter the storage password'),\"name\":\"password\",\"required\":\"\"},model:{value:(_vm.password),callback:function ($$v) {_vm.password=$$v},expression:\"password\"}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-4.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./CredentialsDialog.vue?vue&type=script&lang=ts\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-4.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./CredentialsDialog.vue?vue&type=script&lang=ts\"","import { render, staticRenderFns } from \"./CredentialsDialog.vue?vue&type=template&id=42ed5195\"\nimport script from \"./CredentialsDialog.vue?vue&type=script&lang=ts\"\nexport * from \"./CredentialsDialog.vue?vue&type=script&lang=ts\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports"],"names":["defineComponent","name","components","NcDialog","NcNoteCard","NcTextField","NcPasswordField","setup","t","data","login","password","computed","dialogButtons","label","type","variant","_vm","this","_c","_self","_setupProxy","staticClass","attrs","on","$event","$emit","_v","ref","model","value","callback","$$v","expression"],"ignoreList":[],"sourceRoot":""}
{"version":3,"file":"1261-1261.js?v=e5911953e2704a8c51c4","mappings":"kKAAA,I,gEAMA,MCNiQ,GDMlPA,EAAAA,EAAAA,IAAgB,CAC3BC,KAAM,oBACNC,WAAY,CACRC,SAAQ,IACRC,WAAU,IACVC,YAAW,IACXC,gBAAeA,EAAAA,GAEnBC,MAAKA,KACM,CACHC,EAACA,EAAAA,IAGTC,KAAIA,KACO,CACHC,MAAO,GACPC,SAAU,KAGlBC,SAAU,CACNC,cAAaA,IACF,CAAC,CACAC,OAAON,EAAAA,EAAAA,GAAE,iBAAkB,WAC3BO,KAAM,SACNC,QAAS,eEZ7B,GAXgB,E,SAAA,GACd,EFRW,WAAkB,IAAIC,EAAIC,KAAKC,EAAGF,EAAIG,MAAMD,GAAgC,OAAtBF,EAAIG,MAAMC,YAAmBF,EAAG,WAAW,CAACG,YAAY,wBAAwBC,MAAM,CAAC,QAAUN,EAAIJ,cAAc,yBAAyB,GAAG,gCAAgC,GAAG,UAAU,GAAG,KAAOI,EAAIT,EAAE,iBAAkB,uBAAuB,iBAAiB,IAAIgB,GAAG,CAAC,OAAS,SAASC,GAAQ,OAAOR,EAAIS,MAAM,QAAS,CAAEhB,MAAOO,EAAIP,MAAOC,SAAUM,EAAIN,UAAW,EAAE,cAAc,SAASc,GAAQ,OAAOR,EAAIS,MAAM,QAAQ,IAAI,CAACP,EAAG,aAAa,CAACG,YAAY,gCAAgCC,MAAM,CAAC,KAAON,EAAIT,EAAE,iBAAkB,8EAA8E,KAAO,UAAUS,EAAIU,GAAG,KAAKR,EAAG,cAAc,CAACS,IAAI,QAAQN,YAAY,+BAA+BC,MAAM,CAAC,6CAA6C,GAAG,MAAQN,EAAIT,EAAE,iBAAkB,SAAS,YAAcS,EAAIT,EAAE,iBAAkB,2BAA2B,UAAY,IAAI,KAAO,QAAQ,SAAW,IAAIqB,MAAM,CAACC,MAAOb,EAAIP,MAAOqB,SAAS,SAAUC,GAAMf,EAAIP,MAAMsB,CAAG,EAAEC,WAAW,WAAWhB,EAAIU,GAAG,KAAKR,EAAG,kBAAkB,CAACS,IAAI,WAAWN,YAAY,kCAAkCC,MAAM,CAAC,gDAAgD,GAAG,MAAQN,EAAIT,EAAE,iBAAkB,YAAY,YAAcS,EAAIT,EAAE,iBAAkB,8BAA8B,KAAO,WAAW,SAAW,IAAIqB,MAAM,CAACC,MAAOb,EAAIN,SAAUoB,SAAS,SAAUC,GAAMf,EAAIN,SAASqB,CAAG,EAAEC,WAAW,eAAe,EACz7C,EACsB,IESpB,EACA,KACA,KACA,M","sources":["webpack:///nextcloud/apps/files_external/src/views/CredentialsDialog.vue","webpack:///nextcloud/apps/files_external/src/views/CredentialsDialog.vue?vue&type=script&lang=ts","webpack://nextcloud/./apps/files_external/src/views/CredentialsDialog.vue?7767"],"sourcesContent":["var render = function render(){var _vm=this,_c=_vm._self._c,_setup=_vm._self._setupProxy;return _c('NcDialog',{staticClass:\"external-storage-auth\",attrs:{\"buttons\":_vm.dialogButtons,\"close-on-click-outside\":\"\",\"data-cy-external-storage-auth\":\"\",\"is-form\":\"\",\"name\":_vm.t('files_external', 'Storage credentials'),\"out-transition\":\"\"},on:{\"submit\":function($event){return _vm.$emit('close', { login: _vm.login, password: _vm.password })},\"update:open\":function($event){return _vm.$emit('close')}}},[_c('NcNoteCard',{staticClass:\"external-storage-auth__header\",attrs:{\"text\":_vm.t('files_external', 'To access the storage, you need to provide the authentication credentials.'),\"type\":\"info\"}}),_vm._v(\" \"),_c('NcTextField',{ref:\"login\",staticClass:\"external-storage-auth__login\",attrs:{\"data-cy-external-storage-auth-dialog-login\":\"\",\"label\":_vm.t('files_external', 'Login'),\"placeholder\":_vm.t('files_external', 'Enter the storage login'),\"minlength\":\"2\",\"name\":\"login\",\"required\":\"\"},model:{value:(_vm.login),callback:function ($$v) {_vm.login=$$v},expression:\"login\"}}),_vm._v(\" \"),_c('NcPasswordField',{ref:\"password\",staticClass:\"external-storage-auth__password\",attrs:{\"data-cy-external-storage-auth-dialog-password\":\"\",\"label\":_vm.t('files_external', 'Password'),\"placeholder\":_vm.t('files_external', 'Enter the storage password'),\"name\":\"password\",\"required\":\"\"},model:{value:(_vm.password),callback:function ($$v) {_vm.password=$$v},expression:\"password\"}})],1)\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","import mod from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./CredentialsDialog.vue?vue&type=script&lang=ts\"; export default mod; export * from \"-!../../../../node_modules/babel-loader/lib/index.js!../../../../node_modules/ts-loader/index.js??clonedRuleSet-6.use[1]!../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./CredentialsDialog.vue?vue&type=script&lang=ts\"","import { render, staticRenderFns } from \"./CredentialsDialog.vue?vue&type=template&id=42ed5195\"\nimport script from \"./CredentialsDialog.vue?vue&type=script&lang=ts\"\nexport * from \"./CredentialsDialog.vue?vue&type=script&lang=ts\"\n\n\n/* normalize component */\nimport normalizer from \"!../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports"],"names":["defineComponent","name","components","NcDialog","NcNoteCard","NcTextField","NcPasswordField","setup","t","data","login","password","computed","dialogButtons","label","type","variant","_vm","this","_c","_self","_setupProxy","staticClass","attrs","on","$event","$emit","_v","ref","model","value","callback","$$v","expression"],"ignoreList":[],"sourceRoot":""}

1
dist/1261-1261.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
1261-1261.js.license

View file

@ -1,2 +1,2 @@
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[8741],{35693(e,t,n){n.d(t,{A:()=>a});var o=n(71354),i=n.n(o),r=n(76314),s=n.n(r)()(i());s.push([e.id,"\n.note-to-recipient[data-v-086ca7fc] {\n\tmargin-inline: var(--row-height)\n}\n.note-to-recipient__text[data-v-086ca7fc] {\n\t/* respect new lines */\n\twhite-space: pre-line;\n}\n.note-to-recipient__heading[data-v-086ca7fc] {\n\tfont-weight: bold;\n}\n@media screen and (max-width: 512px) {\n.note-to-recipient[data-v-086ca7fc] {\n\t\tmargin-inline: var(--default-grid-baseline);\n}\n}\n","",{version:3,sources:["webpack://./apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue"],names:[],mappings:";AAwDA;CACA;AACA;AAEA;CACA,sBAAA;CACA,qBAAA;AACA;AAEA;CACA,iBAAA;AACA;AAEA;AACA;EACA,2CAAA;AACA;AACA",sourcesContent:["\x3c!--\n - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors\n - SPDX-License-Identifier: AGPL-3.0-or-later\n--\x3e\n<template>\n\t<NcNoteCard\n\t\tv-if=\"note.length > 0\"\n\t\tclass=\"note-to-recipient\"\n\t\ttype=\"info\">\n\t\t<p v-if=\"displayName\" class=\"note-to-recipient__heading\">\n\t\t\t{{ t('files_sharing', 'Note from') }}\n\t\t\t<NcUserBubble :user=\"user.id\" :display-name=\"user.displayName\" />\n\t\t</p>\n\t\t<p v-else class=\"note-to-recipient__heading\">\n\t\t\t{{ t('files_sharing', 'Note:') }}\n\t\t</p>\n\t\t<p class=\"note-to-recipient__text\" v-text=\"note\" />\n\t</NcNoteCard>\n</template>\n\n<script setup lang=\"ts\">\nimport type { Folder } from '@nextcloud/files'\n\nimport { getCurrentUser } from '@nextcloud/auth'\nimport { t } from '@nextcloud/l10n'\nimport { computed, ref } from 'vue'\nimport NcNoteCard from '@nextcloud/vue/components/NcNoteCard'\nimport NcUserBubble from '@nextcloud/vue/components/NcUserBubble'\n\nconst folder = ref<Folder>()\nconst note = computed<string>(() => folder.value?.attributes.note ?? '')\nconst displayName = computed<string>(() => folder.value?.attributes['owner-display-name'] ?? '')\nconst user = computed(() => {\n\tconst id = folder.value?.owner\n\tif (id !== getCurrentUser()?.uid) {\n\t\treturn {\n\t\t\tid,\n\t\t\tdisplayName: displayName.value,\n\t\t}\n\t}\n\treturn null\n})\n\n/**\n * Update the current folder\n *\n * @param newFolder the new folder to show note for\n */\nfunction updateFolder(newFolder: Folder) {\n\tfolder.value = newFolder\n}\n\ndefineExpose({ updateFolder })\n<\/script>\n\n<style scoped>\n.note-to-recipient {\n\tmargin-inline: var(--row-height)\n}\n\n.note-to-recipient__text {\n\t/* respect new lines */\n\twhite-space: pre-line;\n}\n\n.note-to-recipient__heading {\n\tfont-weight: bold;\n}\n\n@media screen and (max-width: 512px) {\n\t.note-to-recipient {\n\t\tmargin-inline: var(--default-grid-baseline);\n\t}\n}\n</style>\n"],sourceRoot:""}]);const a=s},38741(e,t,n){n.d(t,{default:()=>x});var o=n(85471),i=n(21777),r=n(53334),s=n(371),a=n(77764);const l=(0,o.pM)({__name:"FilesHeaderNoteToRecipient",setup(e,{expose:t}){const n=(0,o.KR)(),l=(0,o.EW)(()=>n.value?.attributes.note??""),c=(0,o.EW)(()=>n.value?.attributes["owner-display-name"]??""),d=(0,o.EW)(()=>{const e=n.value?.owner;return e!==(0,i.HW)()?.uid?{id:e,displayName:c.value}:null});function p(e){n.value=e}return t({updateFolder:p}),{__sfc:!0,folder:n,note:l,displayName:c,user:d,updateFolder:p,t:r.t,NcNoteCard:s.A,NcUserBubble:a.A}}});var c=n(85072),d=n.n(c),p=n(97825),u=n.n(p),A=n(77659),m=n.n(A),f=n(55056),_=n.n(f),h=n(10540),g=n.n(h),v=n(41113),N=n.n(v),C=n(35693),b={};b.styleTagTransform=N(),b.setAttributes=_(),b.insert=m().bind(null,"head"),b.domAPI=u(),b.insertStyleElement=g(),d()(C.A,b),C.A&&C.A.locals&&C.A.locals;const x=(0,n(14486).A)(l,function(){var e=this,t=e._self._c,n=e._self._setupProxy;return n.note.length>0?t(n.NcNoteCard,{staticClass:"note-to-recipient",attrs:{type:"info"}},[n.displayName?t("p",{staticClass:"note-to-recipient__heading"},[e._v("\n\t\t"+e._s(n.t("files_sharing","Note from"))+"\n\t\t"),t(n.NcUserBubble,{attrs:{user:n.user.id,"display-name":n.user.displayName}})],1):t("p",{staticClass:"note-to-recipient__heading"},[e._v("\n\t\t"+e._s(n.t("files_sharing","Note:"))+"\n\t")]),e._v(" "),t("p",{staticClass:"note-to-recipient__text",domProps:{textContent:e._s(n.note)}})]):e._e()},[],!1,null,"086ca7fc",null).exports}}]);
//# sourceMappingURL=8741-8741.js.map?v=77f54eaf74efd547e7d3
"use strict";(globalThis.webpackChunknextcloud_ui_legacy=globalThis.webpackChunknextcloud_ui_legacy||[]).push([[1404],{35693(e,t,n){n.d(t,{A:()=>a});var o=n(71354),i=n.n(o),r=n(76314),s=n.n(r)()(i());s.push([e.id,"\n.note-to-recipient[data-v-086ca7fc] {\n\tmargin-inline: var(--row-height)\n}\n.note-to-recipient__text[data-v-086ca7fc] {\n\t/* respect new lines */\n\twhite-space: pre-line;\n}\n.note-to-recipient__heading[data-v-086ca7fc] {\n\tfont-weight: bold;\n}\n@media screen and (max-width: 512px) {\n.note-to-recipient[data-v-086ca7fc] {\n\t\tmargin-inline: var(--default-grid-baseline);\n}\n}\n","",{version:3,sources:["webpack://./apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue"],names:[],mappings:";AAwDA;CACA;AACA;AAEA;CACA,sBAAA;CACA,qBAAA;AACA;AAEA;CACA,iBAAA;AACA;AAEA;AACA;EACA,2CAAA;AACA;AACA",sourcesContent:["\x3c!--\n - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors\n - SPDX-License-Identifier: AGPL-3.0-or-later\n--\x3e\n<template>\n\t<NcNoteCard\n\t\tv-if=\"note.length > 0\"\n\t\tclass=\"note-to-recipient\"\n\t\ttype=\"info\">\n\t\t<p v-if=\"displayName\" class=\"note-to-recipient__heading\">\n\t\t\t{{ t('files_sharing', 'Note from') }}\n\t\t\t<NcUserBubble :user=\"user.id\" :display-name=\"user.displayName\" />\n\t\t</p>\n\t\t<p v-else class=\"note-to-recipient__heading\">\n\t\t\t{{ t('files_sharing', 'Note:') }}\n\t\t</p>\n\t\t<p class=\"note-to-recipient__text\" v-text=\"note\" />\n\t</NcNoteCard>\n</template>\n\n<script setup lang=\"ts\">\nimport type { Folder } from '@nextcloud/files'\n\nimport { getCurrentUser } from '@nextcloud/auth'\nimport { t } from '@nextcloud/l10n'\nimport { computed, ref } from 'vue'\nimport NcNoteCard from '@nextcloud/vue/components/NcNoteCard'\nimport NcUserBubble from '@nextcloud/vue/components/NcUserBubble'\n\nconst folder = ref<Folder>()\nconst note = computed<string>(() => folder.value?.attributes.note ?? '')\nconst displayName = computed<string>(() => folder.value?.attributes['owner-display-name'] ?? '')\nconst user = computed(() => {\n\tconst id = folder.value?.owner\n\tif (id !== getCurrentUser()?.uid) {\n\t\treturn {\n\t\t\tid,\n\t\t\tdisplayName: displayName.value,\n\t\t}\n\t}\n\treturn null\n})\n\n/**\n * Update the current folder\n *\n * @param newFolder the new folder to show note for\n */\nfunction updateFolder(newFolder: Folder) {\n\tfolder.value = newFolder\n}\n\ndefineExpose({ updateFolder })\n<\/script>\n\n<style scoped>\n.note-to-recipient {\n\tmargin-inline: var(--row-height)\n}\n\n.note-to-recipient__text {\n\t/* respect new lines */\n\twhite-space: pre-line;\n}\n\n.note-to-recipient__heading {\n\tfont-weight: bold;\n}\n\n@media screen and (max-width: 512px) {\n\t.note-to-recipient {\n\t\tmargin-inline: var(--default-grid-baseline);\n\t}\n}\n</style>\n"],sourceRoot:""}]);const a=s},41404(e,t,n){n.d(t,{default:()=>x});var o=n(85471),i=n(21777),r=n(53334),s=n(371),a=n(77764);const l=(0,o.pM)({__name:"FilesHeaderNoteToRecipient",setup(e,{expose:t}){const n=(0,o.KR)(),l=(0,o.EW)(()=>n.value?.attributes.note??""),c=(0,o.EW)(()=>n.value?.attributes["owner-display-name"]??""),d=(0,o.EW)(()=>{const e=n.value?.owner;return e!==(0,i.HW)()?.uid?{id:e,displayName:c.value}:null});function p(e){n.value=e}return t({updateFolder:p}),{__sfc:!0,folder:n,note:l,displayName:c,user:d,updateFolder:p,t:r.t,NcNoteCard:s.A,NcUserBubble:a.A}}});var c=n(85072),d=n.n(c),p=n(97825),u=n.n(p),A=n(77659),m=n.n(A),f=n(55056),_=n.n(f),h=n(10540),g=n.n(h),v=n(41113),N=n.n(v),C=n(35693),b={};b.styleTagTransform=N(),b.setAttributes=_(),b.insert=m().bind(null,"head"),b.domAPI=u(),b.insertStyleElement=g(),d()(C.A,b),C.A&&C.A.locals&&C.A.locals;const x=(0,n(14486).A)(l,function(){var e=this,t=e._self._c,n=e._self._setupProxy;return n.note.length>0?t(n.NcNoteCard,{staticClass:"note-to-recipient",attrs:{type:"info"}},[n.displayName?t("p",{staticClass:"note-to-recipient__heading"},[e._v("\n\t\t"+e._s(n.t("files_sharing","Note from"))+"\n\t\t"),t(n.NcUserBubble,{attrs:{user:n.user.id,"display-name":n.user.displayName}})],1):t("p",{staticClass:"note-to-recipient__heading"},[e._v("\n\t\t"+e._s(n.t("files_sharing","Note:"))+"\n\t")]),e._v(" "),t("p",{staticClass:"note-to-recipient__text",domProps:{textContent:e._s(n.note)}})]):e._e()},[],!1,null,"086ca7fc",null).exports}}]);
//# sourceMappingURL=1404-1404.js.map?v=e021afe5d02634220086

File diff suppressed because one or more lines are too long

1
dist/1404-1404.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
1404-1404.js.license

View file

@ -1 +0,0 @@
1543-1543.js.license

2
dist/1879-1879.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
1879-1879.js.license

2
dist/23-23.js vendored Normal file

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more