refactor!(files): migrate sidebar API to use Node API

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-12-28 17:24:58 +01:00
parent fb18804192
commit 4a9cdeb01f
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
28 changed files with 729 additions and 1321 deletions

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

@ -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

@ -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

@ -104,7 +104,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')

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

@ -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

@ -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

@ -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

@ -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

@ -281,11 +281,12 @@ 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]').should('not.be.visible')
}
/**

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}`)
})