mirror of
https://github.com/nextcloud/server.git
synced 2026-02-20 00:12:30 -05:00
refactor!(files): migrate sidebar API to use Node API
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
fb18804192
commit
4a9cdeb01f
28 changed files with 729 additions and 1321 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
69
apps/files/src/components/FilesSidebar/FilesSidebarTab.vue
Normal file
69
apps/files/src/components/FilesSidebar/FilesSidebarTab.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
86
apps/files/src/composables/usePreviewImage.ts
Normal file
86
apps/files/src/composables/usePreviewImage.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
25
apps/files/src/eventbus.d.ts
vendored
25
apps/files/src/eventbus.d.ts
vendored
|
|
@ -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
29
apps/files/src/global.d.ts
vendored
Normal 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'>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { createPinia } from 'pinia'
|
||||
|
||||
/**
|
||||
*
|
||||
* Get the Pinia instance for the Files app.
|
||||
*/
|
||||
export function getPinia() {
|
||||
if (window._nc_files_pinia) {
|
||||
|
|
|
|||
193
apps/files/src/store/sidebar.ts
Normal file
193
apps/files/src/store/sidebar.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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$/,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue