aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/FilesApp.vue40
-rw-r--r--apps/files/src/actions/convertAction.ts81
-rw-r--r--apps/files/src/actions/convertUtils.ts139
-rw-r--r--apps/files/src/actions/deleteAction.spec.ts449
-rw-r--r--apps/files/src/actions/deleteAction.ts113
-rw-r--r--apps/files/src/actions/deleteUtils.ts141
-rw-r--r--apps/files/src/actions/downloadAction.spec.ts191
-rw-r--r--apps/files/src/actions/downloadAction.ts114
-rw-r--r--apps/files/src/actions/favoriteAction.spec.ts381
-rw-r--r--apps/files/src/actions/favoriteAction.ts119
-rw-r--r--apps/files/src/actions/moveOrCopyAction.ts373
-rw-r--r--apps/files/src/actions/moveOrCopyActionUtils.ts71
-rw-r--r--apps/files/src/actions/openFolderAction.spec.ts147
-rw-r--r--apps/files/src/actions/openFolderAction.ts50
-rw-r--r--apps/files/src/actions/openInFilesAction.spec.ts86
-rw-r--r--apps/files/src/actions/openInFilesAction.ts38
-rw-r--r--apps/files/src/actions/openLocallyAction.spec.ts170
-rw-r--r--apps/files/src/actions/openLocallyAction.ts114
-rw-r--r--apps/files/src/actions/renameAction.spec.ts100
-rw-r--r--apps/files/src/actions/renameAction.ts50
-rw-r--r--apps/files/src/actions/sidebarAction.spec.ts185
-rw-r--r--apps/files/src/actions/sidebarAction.ts74
-rw-r--r--apps/files/src/actions/viewInFolderAction.spec.ts193
-rw-r--r--apps/files/src/actions/viewInFolderAction.ts68
-rw-r--r--apps/files/src/components/BreadCrumbs.vue310
-rw-r--r--apps/files/src/components/CustomElementRender.vue54
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue262
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue165
-rw-r--r--apps/files/src/components/FileEntry.vue276
-rw-r--r--apps/files/src/components/FileEntry/CollectivesIcon.vue45
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue76
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue399
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue173
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue288
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue300
-rw-r--r--apps/files/src/components/FileEntryGrid.vue135
-rw-r--r--apps/files/src/components/FileEntryMixin.ts509
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilter.vue53
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterModified.vue107
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterToSearch.vue47
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterType.vue122
-rw-r--r--apps/files/src/components/FileListFilters.vue74
-rw-r--r--apps/files/src/components/FilesListHeader.vue100
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue164
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue237
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue337
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue91
-rw-r--r--apps/files/src/components/FilesListVirtual.vue1035
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue182
-rw-r--r--apps/files/src/components/FilesNavigationSearch.vue86
-rw-r--r--apps/files/src/components/LegacyView.vue27
-rw-r--r--apps/files/src/components/NavigationQuota.vue77
-rw-r--r--apps/files/src/components/NewNodeDialog.vue168
-rw-r--r--apps/files/src/components/PersonalSettings.vue24
-rw-r--r--apps/files/src/components/Setting.vue23
-rw-r--r--apps/files/src/components/SidebarTab.vue30
-rw-r--r--apps/files/src/components/TemplateFiller.vue122
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue68
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateRichTextField.vue77
-rw-r--r--apps/files/src/components/TemplatePreview.vue49
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue90
-rw-r--r--apps/files/src/components/VirtualList.vue424
-rw-r--r--apps/files/src/composables/useBeforeNavigation.ts20
-rw-r--r--apps/files/src/composables/useFileListHeaders.spec.ts41
-rw-r--r--apps/files/src/composables/useFileListHeaders.ts19
-rw-r--r--apps/files/src/composables/useFileListWidth.cy.ts56
-rw-r--r--apps/files/src/composables/useFileListWidth.ts50
-rw-r--r--apps/files/src/composables/useHotKeys.spec.ts213
-rw-r--r--apps/files/src/composables/useHotKeys.ts86
-rw-r--r--apps/files/src/composables/useNavigation.spec.ts106
-rw-r--r--apps/files/src/composables/useNavigation.ts53
-rw-r--r--apps/files/src/composables/useRouteParameters.ts58
-rw-r--r--apps/files/src/eventbus.d.ts36
-rw-r--r--apps/files/src/filters/FilenameFilter.ts75
-rw-r--r--apps/files/src/filters/HiddenFilesFilter.ts42
-rw-r--r--apps/files/src/filters/ModifiedFilter.ts114
-rw-r--r--apps/files/src/filters/SearchFilter.ts49
-rw-r--r--apps/files/src/filters/TypeFilter.ts192
-rw-r--r--apps/files/src/init.ts83
-rw-r--r--apps/files/src/legacy/filelistSearch.js42
-rw-r--r--apps/files/src/legacy/navigationMapper.js55
-rw-r--r--apps/files/src/logger.js27
-rw-r--r--apps/files/src/logger.ts10
-rw-r--r--apps/files/src/main-personal-settings.js37
-rw-r--r--apps/files/src/main.js39
-rw-r--r--apps/files/src/main.ts51
-rw-r--r--apps/files/src/mixins/actionsMixin.ts65
-rw-r--r--apps/files/src/mixins/filesSorting.ts52
-rw-r--r--apps/files/src/models/Setting.js43
-rw-r--r--apps/files/src/models/Tab.js40
-rw-r--r--apps/files/src/newMenu/newFolder.ts91
-rw-r--r--apps/files/src/newMenu/newFromTemplate.ts77
-rw-r--r--apps/files/src/newMenu/newTemplatesFolder.ts83
-rw-r--r--apps/files/src/plugins/search/folderSearch.ts61
-rw-r--r--apps/files/src/reference-files.ts43
-rw-r--r--apps/files/src/router/router.js57
-rw-r--r--apps/files/src/router/router.ts145
-rw-r--r--apps/files/src/services/DropService.ts198
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts143
-rw-r--r--apps/files/src/services/DropServiceUtils.ts178
-rw-r--r--apps/files/src/services/Favorites.ts40
-rw-r--r--apps/files/src/services/FileInfo.js71
-rw-r--r--apps/files/src/services/FileInfo.ts36
-rw-r--r--apps/files/src/services/Files.ts110
-rw-r--r--apps/files/src/services/FolderTree.ts95
-rw-r--r--apps/files/src/services/LivePhotos.ts19
-rw-r--r--apps/files/src/services/Navigation.ts217
-rw-r--r--apps/files/src/services/PersonalFiles.ts39
-rw-r--r--apps/files/src/services/PreviewService.ts21
-rw-r--r--apps/files/src/services/Recent.ts74
-rw-r--r--apps/files/src/services/RouterService.ts75
-rw-r--r--apps/files/src/services/Search.spec.ts61
-rw-r--r--apps/files/src/services/Search.ts43
-rw-r--r--apps/files/src/services/ServiceWorker.js31
-rw-r--r--apps/files/src/services/Settings.js21
-rw-r--r--apps/files/src/services/Sidebar.js21
-rw-r--r--apps/files/src/services/Templates.js30
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
-rw-r--r--apps/files/src/services/WebdavClient.ts19
-rw-r--r--apps/files/src/sidebar.ts (renamed from apps/files/src/sidebar.js)40
-rw-r--r--apps/files/src/store/actionsmenu.ts12
-rw-r--r--apps/files/src/store/active.ts86
-rw-r--r--apps/files/src/store/dragging.ts31
-rw-r--r--apps/files/src/store/files.ts198
-rw-r--r--apps/files/src/store/filters.ts133
-rw-r--r--apps/files/src/store/index.ts15
-rw-r--r--apps/files/src/store/keyboard.ts47
-rw-r--r--apps/files/src/store/paths.spec.ts166
-rw-r--r--apps/files/src/store/paths.ts165
-rw-r--r--apps/files/src/store/renaming.ts175
-rw-r--r--apps/files/src/store/search.ts153
-rw-r--r--apps/files/src/store/selection.ts44
-rw-r--r--apps/files/src/store/uploader.ts24
-rw-r--r--apps/files/src/store/userconfig.ts62
-rw-r--r--apps/files/src/store/viewConfig.ts96
-rw-r--r--apps/files/src/templates.js144
-rw-r--r--apps/files/src/types.ts148
-rw-r--r--apps/files/src/utils/actionUtils.ts74
-rw-r--r--apps/files/src/utils/davUtils.js53
-rw-r--r--apps/files/src/utils/davUtils.ts41
-rw-r--r--apps/files/src/utils/dragUtils.ts25
-rw-r--r--apps/files/src/utils/fileUtils.js47
-rw-r--r--apps/files/src/utils/fileUtils.ts47
-rw-r--r--apps/files/src/utils/filenameValidity.ts41
-rw-r--r--apps/files/src/utils/filesViews.spec.ts73
-rw-r--r--apps/files/src/utils/filesViews.ts30
-rw-r--r--apps/files/src/utils/hashUtils.ts17
-rw-r--r--apps/files/src/utils/newNodeDialog.ts40
-rw-r--r--apps/files/src/utils/permissions.ts37
-rw-r--r--apps/files/src/views/DialogConfirmFileExtension.cy.ts161
-rw-r--r--apps/files/src/views/DialogConfirmFileExtension.vue92
-rw-r--r--apps/files/src/views/FileReferencePickerElement.vue86
-rw-r--r--apps/files/src/views/FilesList.vue909
-rw-r--r--apps/files/src/views/Navigation.cy.ts260
-rw-r--r--apps/files/src/views/Navigation.vue323
-rw-r--r--apps/files/src/views/ReferenceFileWidget.vue306
-rw-r--r--apps/files/src/views/SearchEmptyView.vue53
-rw-r--r--apps/files/src/views/Settings.vue380
-rw-r--r--apps/files/src/views/Sidebar.vue373
-rw-r--r--apps/files/src/views/TemplatePicker.vue225
-rw-r--r--apps/files/src/views/favorites.spec.ts261
-rw-r--r--apps/files/src/views/favorites.ts183
-rw-r--r--apps/files/src/views/files.ts65
-rw-r--r--apps/files/src/views/folderTree.ts176
-rw-r--r--apps/files/src/views/personal-files.ts38
-rw-r--r--apps/files/src/views/recent.ts28
-rw-r--r--apps/files/src/views/search.ts51
167 files changed, 18791 insertions, 1672 deletions
diff --git a/apps/files/src/FilesApp.vue b/apps/files/src/FilesApp.vue
new file mode 100644
index 00000000000..6fc02113162
--- /dev/null
+++ b/apps/files/src/FilesApp.vue
@@ -0,0 +1,40 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcContent app-name="files">
+ <Navigation v-if="!isPublic" />
+ <FilesList :is-public="isPublic" />
+ </NcContent>
+</template>
+
+<script lang="ts">
+import { isPublicShare } from '@nextcloud/sharing/public'
+import { defineComponent } from 'vue'
+import NcContent from '@nextcloud/vue/components/NcContent'
+import Navigation from './views/Navigation.vue'
+import FilesList from './views/FilesList.vue'
+import { useHotKeys } from './composables/useHotKeys'
+
+export default defineComponent({
+ name: 'FilesApp',
+
+ components: {
+ NcContent,
+ FilesList,
+ Navigation,
+ },
+
+ setup() {
+ // Register global hotkeys
+ useHotKeys()
+
+ const isPublic = isPublicShare()
+
+ return {
+ isPublic,
+ }
+ },
+})
+</script>
diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts
new file mode 100644
index 00000000000..4992dea312b
--- /dev/null
+++ b/apps/files/src/actions/convertAction.ts
@@ -0,0 +1,81 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { FileAction, registerFileAction } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { t } from '@nextcloud/l10n'
+
+import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw'
+
+import { convertFile, convertFiles } from './convertUtils'
+
+type ConversionsProvider = {
+ from: string,
+ to: string,
+ displayName: string,
+}
+
+export const ACTION_CONVERT = 'convert'
+export const registerConvertActions = () => {
+ // Generate sub actions
+ const convertProviders = getCapabilities()?.files?.file_conversions as ConversionsProvider[] ?? []
+ const actions = convertProviders.map(({ to, from, displayName }) => {
+ return new FileAction({
+ id: `convert-${from}-${to}`,
+ displayName: () => t('files', 'Save as {displayName}', { displayName }),
+ iconSvgInline: () => generateIconSvg(to),
+ enabled: (nodes: Node[]) => {
+ // Check that all nodes have the same mime type
+ return nodes.every(node => from === node.mime)
+ },
+
+ async exec(node: Node) {
+ // If we're here, we know that the node has a fileid
+ convertFile(node.fileid as number, to)
+
+ // Silently terminate, we'll handle the UI in the background
+ return null
+ },
+
+ async execBatch(nodes: Node[]) {
+ const fileIds = nodes.map(node => node.fileid).filter(Boolean) as number[]
+ convertFiles(fileIds, to)
+
+ // Silently terminate, we'll handle the UI in the background
+ return Array(nodes.length).fill(null)
+ },
+
+ parent: ACTION_CONVERT,
+ })
+ })
+
+ // Register main action
+ registerFileAction(new FileAction({
+ id: ACTION_CONVERT,
+ displayName: () => t('files', 'Save as …'),
+ iconSvgInline: () => AutoRenewSvg,
+ enabled: (nodes: Node[], view: View) => {
+ return actions.some(action => action.enabled!(nodes, view))
+ },
+ async exec() {
+ return null
+ },
+ order: 25,
+ }))
+
+ // Register sub actions
+ actions.forEach(registerFileAction)
+}
+
+export const generateIconSvg = (mime: string) => {
+ // Generate icon based on mime type
+ const url = generateUrl('/core/mimeicon?mime=' + encodeURIComponent(mime))
+ return `<svg width="32" height="32" viewBox="0 0 32 32"
+ xmlns="http://www.w3.org/2000/svg">
+ <image href="${url}" height="32" width="32" />
+ </svg>`
+}
diff --git a/apps/files/src/actions/convertUtils.ts b/apps/files/src/actions/convertUtils.ts
new file mode 100644
index 00000000000..0ace3747d9c
--- /dev/null
+++ b/apps/files/src/actions/convertUtils.ts
@@ -0,0 +1,139 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { AxiosResponse, AxiosError } from '@nextcloud/axios'
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { showError, showLoading, showSuccess } from '@nextcloud/dialogs'
+import { n, t } from '@nextcloud/l10n'
+import axios, { isAxiosError } from '@nextcloud/axios'
+import PQueue from 'p-queue'
+
+import { fetchNode } from '../services/WebdavClient.ts'
+import logger from '../logger'
+
+type ConversionResponse = {
+ path: string
+ fileId: number
+}
+
+interface PromiseRejectedResult<T> {
+ status: 'rejected'
+ reason: T
+}
+
+type PromiseSettledResult<T, E> = PromiseFulfilledResult<T> | PromiseRejectedResult<E>;
+type ConversionSuccess = AxiosResponse<OCSResponse<ConversionResponse>>
+type ConversionError = AxiosError<OCSResponse<ConversionResponse>>
+
+const queue = new PQueue({ concurrency: 5 })
+const requestConversion = function(fileId: number, targetMimeType: string): Promise<AxiosResponse> {
+ return axios.post(generateOcsUrl('/apps/files/api/v1/convert'), {
+ fileId,
+ targetMimeType,
+ })
+}
+
+export const convertFiles = async function(fileIds: number[], targetMimeType: string) {
+ const conversions = fileIds.map(fileId => queue.add(() => requestConversion(fileId, targetMimeType)))
+
+ // Start conversion
+ const toast = showLoading(t('files', 'Converting files …'))
+
+ // Handle results
+ try {
+ const results = await Promise.allSettled(conversions) as PromiseSettledResult<ConversionSuccess, ConversionError>[]
+ const failed = results.filter(result => result.status === 'rejected') as PromiseRejectedResult<ConversionError>[]
+ if (failed.length > 0) {
+ const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message)
+ logger.error('Failed to convert files', { fileIds, targetMimeType, messages })
+
+ // If all failed files have the same error message, show it
+ if (new Set(messages).size === 1 && typeof messages[0] === 'string') {
+ showError(t('files', 'Failed to convert files: {message}', { message: messages[0] }))
+ return
+ }
+
+ if (failed.length === fileIds.length) {
+ showError(t('files', 'All files failed to be converted'))
+ return
+ }
+
+ // A single file failed and if we have a message for the failed file, show it
+ if (failed.length === 1 && messages[0]) {
+ showError(t('files', 'One file could not be converted: {message}', { message: messages[0] }))
+ return
+ }
+
+ // We already check above when all files failed
+ // if we're here, we have a mix of failed and successful files
+ showError(n('files', 'One file could not be converted', '%n files could not be converted', failed.length))
+ showSuccess(n('files', 'One file successfully converted', '%n files successfully converted', fileIds.length - failed.length))
+ return
+ }
+
+ // All files converted
+ showSuccess(t('files', 'Files successfully converted'))
+
+ // Extract files that are within the current directory
+ // in batch mode, you might have files from different directories
+ // ⚠️, let's get the actual current dir, as the one from the action
+ // might have changed as the user navigated away
+ const currentDir = window.OCP.Files.Router.query.dir as string
+ const newPaths = results
+ .filter(result => result.status === 'fulfilled')
+ .map(result => result.value.data.ocs.data.path)
+ .filter(path => path.startsWith(currentDir))
+
+ // Fetch the new files
+ logger.debug('Files to fetch', { newPaths })
+ const newFiles = await Promise.all(newPaths.map(path => fetchNode(path)))
+
+ // Inform the file list about the new files
+ newFiles.forEach(file => emit('files:node:created', file))
+
+ // Switch to the new files
+ const firstSuccess = results[0] as PromiseFulfilledResult<ConversionSuccess>
+ const newFileId = firstSuccess.value.data.ocs.data.fileId
+ window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId.toString() }, window.OCP.Files.Router.query)
+ } catch (error) {
+ // Should not happen as we use allSettled and handle errors above
+ showError(t('files', 'Failed to convert files'))
+ logger.error('Failed to convert files', { fileIds, targetMimeType, error })
+ } finally {
+ // Hide loading toast
+ toast.hideToast()
+ }
+}
+
+export const convertFile = async function(fileId: number, targetMimeType: string) {
+ const toast = showLoading(t('files', 'Converting file …'))
+
+ try {
+ const result = await queue.add(() => requestConversion(fileId, targetMimeType)) as AxiosResponse<OCSResponse<ConversionResponse>>
+ showSuccess(t('files', 'File successfully converted'))
+
+ // Inform the file list about the new file
+ const newFile = await fetchNode(result.data.ocs.data.path)
+ emit('files:node:created', newFile)
+
+ // Switch to the new file
+ const newFileId = result.data.ocs.data.fileId
+ window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId.toString() }, window.OCP.Files.Router.query)
+ } catch (error) {
+ // If the server returned an error message, show it
+ if (isAxiosError(error) && error.response?.data?.ocs?.meta?.message) {
+ showError(t('files', 'Failed to convert file: {message}', { message: error.response.data.ocs.meta.message }))
+ return
+ }
+
+ logger.error('Failed to convert file', { fileId, targetMimeType, error })
+ showError(t('files', 'Failed to convert file'))
+ } finally {
+ // Hide loading toast
+ toast.hideToast()
+ }
+}
diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts
new file mode 100644
index 00000000000..845d29962a7
--- /dev/null
+++ b/apps/files/src/actions/deleteAction.spec.ts
@@ -0,0 +1,449 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import axios from '@nextcloud/axios'
+import * as capabilities from '@nextcloud/capabilities'
+import * as eventBus from '@nextcloud/event-bus'
+
+import { action } from './deleteAction'
+import logger from '../logger'
+import { shouldAskForConfirmation } from './deleteUtils'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+vi.mock('@nextcloud/capabilities')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const trashbinView = {
+ id: 'trashbin',
+ name: 'Trashbin',
+} as View
+
+describe('Delete action conditions tests', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'shared',
+ },
+ })
+
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ const folder2 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'shared',
+ },
+ })
+
+ const folder3 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'external',
+ },
+ })
+
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('delete')
+ expect(action.displayName([file], view)).toBe('Delete file')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(100)
+ })
+
+ test('Default folder displayName', () => {
+ expect(action.displayName([folder], view)).toBe('Delete folder')
+ })
+
+ test('Default trashbin view displayName', () => {
+ expect(action.displayName([file], trashbinView)).toBe('Delete permanently')
+ })
+
+ test('Trashbin disabled displayName', () => {
+ vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
+ return {
+ files: {},
+ }
+ })
+ expect(action.displayName([file], view)).toBe('Delete permanently')
+ expect(capabilities.getCapabilities).toBeCalledTimes(1)
+ })
+
+ test('Shared root node displayName', () => {
+ expect(action.displayName([file2], view)).toBe('Leave this share')
+ expect(action.displayName([folder2], view)).toBe('Leave this share')
+ expect(action.displayName([file2, folder2], view)).toBe('Leave these shares')
+ })
+
+ test('External storage root node displayName', () => {
+ expect(action.displayName([folder3], view)).toBe('Disconnect storage')
+ expect(action.displayName([folder3, folder3], view)).toBe('Disconnect storages')
+ })
+
+ test('Shared and owned nodes displayName', () => {
+ expect(action.displayName([file, file2], view)).toBe('Delete and unshare')
+ })
+})
+
+describe('Delete action enabled tests', () => {
+ let initialState: HTMLInputElement
+
+ afterEach(() => {
+ document.body.removeChild(initialState)
+ })
+
+ beforeEach(() => {
+ initialState = document.createElement('input')
+ initialState.setAttribute('type', 'hidden')
+ initialState.setAttribute('id', 'initial-state-files_trashbin-config')
+ initialState.setAttribute('value', btoa(JSON.stringify({
+ allow_delete: true,
+ })))
+ document.body.appendChild(initialState)
+ })
+
+ test('Enabled with DELETE permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled without DELETE permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled if not all nodes can be deleted', () => {
+ const folder1 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/Foo/',
+ owner: 'test',
+ permissions: Permission.DELETE,
+ })
+ const folder2 = new Folder({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/Bar/',
+ owner: 'test',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder1], view)).toBe(true)
+ expect(action.enabled!([folder2], view)).toBe(false)
+ expect(action.enabled!([folder1, folder2], view)).toBe(false)
+ })
+
+ test('Disabled if not allowed', () => {
+ initialState.setAttribute('value', btoa(JSON.stringify({
+ allow_delete: false,
+ })))
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+})
+
+describe('Delete action execute tests', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+ test('Delete action', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.delete).toBeCalledTimes(1)
+ expect(axios.delete).toBeCalledWith('https://cloud.domain.com/remote.php/dav/files/test/foobar.txt')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Delete action batch', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ const confirmMock = vi.fn()
+ window.OC = { dialogs: { confirmDestructive: confirmMock } }
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.execBatch!([file1, file2], view, '/')
+
+ // Not enough nodes to trigger a confirmation dialog
+ expect(confirmMock).toBeCalledTimes(0)
+
+ expect(exec).toStrictEqual([true, true])
+ expect(axios.delete).toBeCalledTimes(2)
+ expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ })
+
+ test('Delete action batch large set', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ // Emulate the confirmation dialog to always confirm
+ const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true))
+ window.OC = { dialogs: { confirmDestructive: confirmMock } }
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file3 = new File({
+ id: 3,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file4 = new File({
+ id: 4,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file5 = new File({
+ id: 5,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.execBatch!([file1, file2, file3, file4, file5], view, '/')
+
+ // Enough nodes to trigger a confirmation dialog
+ expect(confirmMock).toBeCalledTimes(1)
+
+ expect(exec).toStrictEqual([true, true, true, true, true])
+ expect(axios.delete).toBeCalledTimes(5)
+ expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(3, 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(4, 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(5, 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt')
+
+ expect(eventBus.emit).toBeCalledTimes(5)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(3, 'files:node:deleted', file3)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(4, 'files:node:deleted', file4)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(5, 'files:node:deleted', file5)
+ })
+
+ test('Delete action batch dialog enabled', async () => {
+ // Enable the confirmation dialog
+ eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true })
+ expect(shouldAskForConfirmation()).toBe(true)
+
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
+ return {
+ files: {},
+ }
+ })
+
+ // Emulate the confirmation dialog to always confirm
+ const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true))
+ window.OC = { dialogs: { confirmDestructive: confirmMock } }
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.execBatch!([file1, file2], view, '/')
+
+ // Will trigger a confirmation dialog because trashbin app is disabled
+ expect(confirmMock).toBeCalledTimes(1)
+
+ expect(exec).toStrictEqual([true, true])
+ expect(axios.delete).toBeCalledTimes(2)
+ expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt')
+ expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+
+ eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false })
+ })
+
+ test('Delete fails', async () => {
+ vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(false)
+ expect(axios.delete).toBeCalledTimes(1)
+ expect(axios.delete).toBeCalledWith('https://cloud.domain.com/remote.php/dav/files/test/foobar.txt')
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+ expect(logger.error).toBeCalledTimes(1)
+ })
+
+ test('Delete is cancelled with dialog enabled', async () => {
+ // Enable the confirmation dialog
+ eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true })
+
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
+ return {
+ files: {},
+ }
+ })
+
+ // Emulate the confirmation dialog to always confirm
+ const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(false))
+ window.OC = { dialogs: { confirmDestructive: confirmMock } }
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
+ owner: 'test',
+ mime: 'text/plain',
+ permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
+ })
+
+ const exec = await action.execBatch!([file1], view, '/')
+
+ expect(confirmMock).toBeCalledTimes(1)
+
+ expect(exec).toStrictEqual([null])
+ expect(axios.delete).toBeCalledTimes(0)
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+
+ eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false })
+ })
+})
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
new file mode 100644
index 00000000000..fa4fdfe8cdc
--- /dev/null
+++ b/apps/files/src/actions/deleteAction.ts
@@ -0,0 +1,113 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { Permission, Node, View, FileAction } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import PQueue from 'p-queue'
+
+import CloseSvg from '@mdi/svg/svg/close.svg?raw'
+import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
+import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw'
+
+import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts'
+import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts'
+import logger from '../logger.ts'
+
+const queue = new PQueue({ concurrency: 5 })
+
+export const ACTION_DELETE = 'delete'
+
+export const action = new FileAction({
+ id: ACTION_DELETE,
+ displayName,
+ iconSvgInline: (nodes: Node[]) => {
+ if (canUnshareOnly(nodes)) {
+ return CloseSvg
+ }
+
+ if (canDisconnectOnly(nodes)) {
+ return NetworkOffSvg
+ }
+
+ return TrashCanSvg
+ },
+
+ enabled(nodes: Node[], view: View): boolean {
+ if (view.id === TRASHBIN_VIEW_ID) {
+ const config = loadState('files_trashbin', 'config', { allow_delete: true })
+ if (config.allow_delete === false) {
+ return false
+ }
+ }
+
+ return nodes.length > 0 && nodes
+ .map(node => node.permissions)
+ .every(permission => (permission & Permission.DELETE) !== 0)
+ },
+
+ async exec(node: Node, view: View) {
+ try {
+ let confirm = true
+
+ // Trick to detect if the action was called from a keyboard event
+ // we need to make sure the method calling have its named containing 'keydown'
+ // here we use `onKeydown` method from the FileEntryActions component
+ const callStack = new Error().stack || ''
+ const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown')
+
+ if (shouldAskForConfirmation() || isCalledFromEventListener) {
+ confirm = await askConfirmation([node], view)
+ }
+
+ // If the user cancels the deletion, we don't want to do anything
+ if (confirm === false) {
+ return null
+ }
+
+ await deleteNode(node)
+
+ return true
+ } catch (error) {
+ logger.error('Error while deleting a file', { error, source: node.source, node })
+ return false
+ }
+ },
+
+ async execBatch(nodes: Node[], view: View): Promise<(boolean | null)[]> {
+ let confirm = true
+
+ if (shouldAskForConfirmation()) {
+ confirm = await askConfirmation(nodes, view)
+ } else if (nodes.length >= 5 && !canUnshareOnly(nodes) && !canDisconnectOnly(nodes)) {
+ confirm = await askConfirmation(nodes, view)
+ }
+
+ // If the user cancels the deletion, we don't want to do anything
+ if (confirm === false) {
+ return Promise.all(nodes.map(() => null))
+ }
+
+ // Map each node to a promise that resolves with the result of exec(node)
+ const promises = nodes.map(node => {
+ // Create a promise that resolves with the result of exec(node)
+ const promise = new Promise<boolean>(resolve => {
+ queue.add(async () => {
+ try {
+ await deleteNode(node)
+ resolve(true)
+ } catch (error) {
+ logger.error('Error while deleting a file', { error, source: node.source, node })
+ resolve(false)
+ }
+ })
+ })
+ return promise
+ })
+
+ return Promise.all(promises)
+ },
+
+ destructive: true,
+ order: 100,
+})
diff --git a/apps/files/src/actions/deleteUtils.ts b/apps/files/src/actions/deleteUtils.ts
new file mode 100644
index 00000000000..1ca7859b6c5
--- /dev/null
+++ b/apps/files/src/actions/deleteUtils.ts
@@ -0,0 +1,141 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Capabilities } from '../types'
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { FileType } from '@nextcloud/files'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { n, t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import { useUserConfigStore } from '../store/userconfig'
+import { getPinia } from '../store'
+
+export const isTrashbinEnabled = () => (getCapabilities() as Capabilities)?.files?.undelete === true
+
+export const canUnshareOnly = (nodes: Node[]) => {
+ return nodes.every(node => node.attributes['is-mount-root'] === true
+ && node.attributes['mount-type'] === 'shared')
+}
+
+export const canDisconnectOnly = (nodes: Node[]) => {
+ return nodes.every(node => node.attributes['is-mount-root'] === true
+ && node.attributes['mount-type'] === 'external')
+}
+
+export const isMixedUnshareAndDelete = (nodes: Node[]) => {
+ if (nodes.length === 1) {
+ return false
+ }
+
+ const hasSharedItems = nodes.some(node => canUnshareOnly([node]))
+ const hasDeleteItems = nodes.some(node => !canUnshareOnly([node]))
+ return hasSharedItems && hasDeleteItems
+}
+
+export const isAllFiles = (nodes: Node[]) => {
+ return !nodes.some(node => node.type !== FileType.File)
+}
+
+export const isAllFolders = (nodes: Node[]) => {
+ return !nodes.some(node => node.type !== FileType.Folder)
+}
+
+export const displayName = (nodes: Node[], view: View) => {
+ /**
+ * If those nodes are all the root node of a
+ * share, we can only unshare them.
+ */
+ if (canUnshareOnly(nodes)) {
+ if (nodes.length === 1) {
+ return t('files', 'Leave this share')
+ }
+ return t('files', 'Leave these shares')
+ }
+
+ /**
+ * If those nodes are all the root node of an
+ * external storage, we can only disconnect it.
+ */
+ if (canDisconnectOnly(nodes)) {
+ if (nodes.length === 1) {
+ return t('files', 'Disconnect storage')
+ }
+ return t('files', 'Disconnect storages')
+ }
+
+ /**
+ * If we're in the trashbin, we can only delete permanently
+ */
+ if (view.id === 'trashbin' || !isTrashbinEnabled()) {
+ return t('files', 'Delete permanently')
+ }
+
+ /**
+ * If we're in the sharing view, we can only unshare
+ */
+ if (isMixedUnshareAndDelete(nodes)) {
+ return t('files', 'Delete and unshare')
+ }
+
+ /**
+ * If we're only selecting files, use proper wording
+ */
+ if (isAllFiles(nodes)) {
+ if (nodes.length === 1) {
+ return t('files', 'Delete file')
+ }
+ return t('files', 'Delete files')
+ }
+
+ /**
+ * If we're only selecting folders, use proper wording
+ */
+ if (isAllFolders(nodes)) {
+ if (nodes.length === 1) {
+ return t('files', 'Delete folder')
+ }
+ return t('files', 'Delete folders')
+ }
+
+ return t('files', 'Delete')
+}
+
+export const shouldAskForConfirmation = () => {
+ const userConfig = useUserConfigStore(getPinia())
+ return userConfig.userConfig.show_dialog_deletion !== false
+}
+
+export const askConfirmation = async (nodes: Node[], view: View) => {
+ const message = view.id === 'trashbin' || !isTrashbinEnabled()
+ ? n('files', 'You are about to permanently delete {count} item', 'You are about to permanently delete {count} items', nodes.length, { count: nodes.length })
+ : n('files', 'You are about to delete {count} item', 'You are about to delete {count} items', nodes.length, { count: nodes.length })
+
+ return new Promise<boolean>(resolve => {
+ // TODO: Use the new dialog API
+ window.OC.dialogs.confirmDestructive(
+ message,
+ t('files', 'Confirm deletion'),
+ {
+ type: window.OC.dialogs.YES_NO_BUTTONS,
+ confirm: displayName(nodes, view),
+ confirmClasses: 'error',
+ cancel: t('files', 'Cancel'),
+ },
+ (decision: boolean) => {
+ resolve(decision)
+ },
+ )
+ })
+}
+
+export const deleteNode = async (node: Node) => {
+ await axios.delete(node.encodedSource)
+
+ // Let's delete even if it's moved to the trashbin
+ // since it has been removed from the current view
+ // and changing the view will trigger a reload anyway.
+ emit('files:node:deleted', node)
+}
diff --git a/apps/files/src/actions/downloadAction.spec.ts b/apps/files/src/actions/downloadAction.spec.ts
new file mode 100644
index 00000000000..8d5612d982b
--- /dev/null
+++ b/apps/files/src/actions/downloadAction.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { action } from './downloadAction'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Download action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('download')
+ expect(action.displayName([], view)).toBe('Download')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBe(DefaultType.DEFAULT)
+ expect(action.order).toBe(30)
+ })
+})
+
+describe('Download action enabled tests', () => {
+ test('Enabled with READ permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled without READ permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled if not all nodes have READ permissions', () => {
+ const folder1 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+ const folder2 = new Folder({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/',
+ owner: 'admin',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder1], view)).toBe(true)
+ expect(action.enabled!([folder2], view)).toBe(false)
+ expect(action.enabled!([folder1, folder2], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+})
+
+describe('Download action execute tests', () => {
+ const link = {
+ click: vi.fn(),
+ } as unknown as HTMLAnchorElement
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.spyOn(document, 'createElement').mockImplementation(() => link)
+ })
+
+ test('Download single file', async () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(link.download).toBe('foobar.txt')
+ expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
+ expect(link.click).toHaveBeenCalledTimes(1)
+ })
+
+ test('Download single file with batch', async () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.execBatch!([file], view, '/')
+
+ // Silent action
+ expect(exec).toStrictEqual([null])
+ expect(link.download).toEqual('foobar.txt')
+ expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
+ expect(link.click).toHaveBeenCalledTimes(1)
+ })
+
+ test('Download single file with displayname set', async () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ displayname: 'baz.txt',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.execBatch!([file], view, '/')
+
+ // Silent action
+ expect(exec).toStrictEqual([null])
+ expect(link.download).toEqual('baz.txt')
+ expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt')
+ expect(link.click).toHaveBeenCalledTimes(1)
+ })
+
+ test('Download single folder', async () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(folder, view, '/')
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(link.download).toEqual('')
+ expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/FooBar/?accept=zip')
+ expect(link.click).toHaveBeenCalledTimes(1)
+ })
+
+ test('Download multiple nodes', async () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Dir/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Dir/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.execBatch!([file1, file2], view, '/Dir')
+
+ // Silent action
+ expect(exec).toStrictEqual([null, null])
+ expect(link.download).toEqual('')
+ expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/Dir/?accept=zip&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D')
+ expect(link.click).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts
new file mode 100644
index 00000000000..8abd87972ee
--- /dev/null
+++ b/apps/files/src/actions/downloadAction.ts
@@ -0,0 +1,114 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+import { FileAction, FileType, DefaultType } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { isDownloadable } from '../utils/permissions'
+
+import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw'
+
+/**
+ * Trigger downloading a file.
+ *
+ * @param url The url of the asset to download
+ * @param name Optionally the recommended name of the download (browsers might ignore it)
+ */
+function triggerDownload(url: string, name?: string) {
+ const hiddenElement = document.createElement('a')
+ hiddenElement.download = name ?? ''
+ hiddenElement.href = url
+ hiddenElement.click()
+}
+
+/**
+ * Find the longest common path prefix of both input paths
+ * @param first The first path
+ * @param second The second path
+ */
+function longestCommonPath(first: string, second: string): string {
+ const firstSegments = first.split('/').filter(Boolean)
+ const secondSegments = second.split('/').filter(Boolean)
+ let base = ''
+ for (const [index, segment] of firstSegments.entries()) {
+ if (index >= second.length) {
+ break
+ }
+ if (segment !== secondSegments[index]) {
+ break
+ }
+ const sep = base === '' ? '' : '/'
+ base = `${base}${sep}${segment}`
+ }
+ return base
+}
+
+const downloadNodes = function(nodes: Node[]) {
+ let url: URL
+
+ if (nodes.length === 1) {
+ if (nodes[0].type === FileType.File) {
+ return triggerDownload(nodes[0].encodedSource, nodes[0].displayname)
+ } else {
+ url = new URL(nodes[0].encodedSource)
+ url.searchParams.append('accept', 'zip')
+ }
+ } else {
+ url = new URL(nodes[0].encodedSource)
+ let base = url.pathname
+ for (const node of nodes.slice(1)) {
+ base = longestCommonPath(base, (new URL(node.encodedSource).pathname))
+ }
+ url.pathname = base
+
+ // The URL contains the path encoded so we need to decode as the query.append will re-encode it
+ const filenames = nodes.map((node) => decodeURIComponent(node.encodedSource.slice(url.href.length + 1)))
+ url.searchParams.append('accept', 'zip')
+ url.searchParams.append('files', JSON.stringify(filenames))
+ }
+
+ if (url.pathname.at(-1) !== '/') {
+ url.pathname = `${url.pathname}/`
+ }
+
+ return triggerDownload(url.href)
+}
+
+export const action = new FileAction({
+ id: 'download',
+ default: DefaultType.DEFAULT,
+
+ displayName: () => t('files', 'Download'),
+ iconSvgInline: () => ArrowDownSvg,
+
+ enabled(nodes: Node[], view: View) {
+ if (nodes.length === 0) {
+ return false
+ }
+
+ // We can only download dav files and folders.
+ if (nodes.some(node => !node.isDavResource)) {
+ return false
+ }
+
+ // Trashbin does not allow batch download
+ if (nodes.length > 1 && view.id === 'trashbin') {
+ return false
+ }
+
+ return nodes.every(isDownloadable)
+ },
+
+ async exec(node: Node) {
+ downloadNodes([node])
+ return null
+ },
+
+ async execBatch(nodes: Node[]) {
+ downloadNodes(nodes)
+ return new Array(nodes.length).fill(null)
+ },
+
+ order: 30,
+})
diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts
new file mode 100644
index 00000000000..96768c4887a
--- /dev/null
+++ b/apps/files/src/actions/favoriteAction.spec.ts
@@ -0,0 +1,381 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { action } from './favoriteAction'
+import axios from '@nextcloud/axios'
+import * as eventBus from '@nextcloud/event-bus'
+import * as favoriteAction from './favoriteAction'
+import logger from '../logger'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const favoriteView = {
+ id: 'favorites',
+ name: 'Favorites',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ window.OC = {
+ ...window.OC,
+ TAG_FAVORITE: '_$!<Favorite>!$_',
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Favorite action conditions tests', () => {
+ test('Default values', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('favorite')
+ expect(action.displayName([file], view)).toBe('Add to favorites')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(-50)
+ })
+
+ test('Display name is Remove from favorites if already in favorites', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ expect(action.displayName([file], view)).toBe('Remove from favorites')
+ })
+
+ test('Display name for multiple state files', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 1,
+ },
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 0,
+ },
+ })
+ const file3 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ expect(action.displayName([file1, file2, file3], view)).toBe('Add to favorites')
+ expect(action.displayName([file1, file2], view)).toBe('Add to favorites')
+ expect(action.displayName([file2, file3], view)).toBe('Add to favorites')
+ expect(action.displayName([file1, file3], view)).toBe('Remove from favorites')
+ })
+})
+
+describe('Favorite action enabled tests', () => {
+ test('Enabled for dav file', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled for non-dav ressources', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://domain.com/data/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+})
+
+describe('Favorite action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('Favorite triggers tag addition', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(true)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] })
+
+ // Check node change propagation
+ expect(file.attributes.favorite).toBe(1)
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:favorites:added', file)
+ })
+
+ test('Favorite triggers tag removal', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(true)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+ // Check node change propagation
+ expect(file.attributes.favorite).toBe(0)
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
+ })
+
+ test('Favorite triggers node removal if favorite view and root dir', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const exec = await action.exec(file, favoriteView, '/')
+
+ expect(exec).toBe(true)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+ // Check node change propagation
+ expect(file.attributes.favorite).toBe(0)
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:favorites:removed', file)
+ })
+
+ test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar/foobar.txt',
+ root: '/files/admin',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const exec = await action.exec(file, favoriteView, '/')
+
+ expect(exec).toBe(true)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/Foo/Bar/foobar.txt', { tags: [] })
+
+ // Check node change propagation
+ expect(file.attributes.favorite).toBe(0)
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file)
+ })
+
+ test('Favorite fails and show error', async () => {
+ const error = new Error('Mock error')
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 0,
+ },
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(false)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] })
+
+ // Check node change propagation
+ expect(logger.error).toBeCalledTimes(1)
+ expect(logger.error).toBeCalledWith('Error while adding a file to favourites', { error, source: file.source, node: file })
+ expect(file.attributes.favorite).toBe(0)
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+
+ test('Removing from favorites fails and show error', async () => {
+ const error = new Error('Mock error')
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw error })
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+
+ const file = new File({
+ id: 1,
+ source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(exec).toBe(false)
+
+ // Check POST request
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] })
+
+ // Check node change propagation
+ expect(logger.error).toBeCalledTimes(1)
+ expect(logger.error).toBeCalledWith('Error while removing a file from favourites', { error, source: file.source, node: file })
+ expect(file.attributes.favorite).toBe(1)
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+})
+
+describe('Favorite action batch execute tests', () => {
+ beforeEach(() => { vi.restoreAllMocks() })
+
+ test('Favorite action batch execute with mixed files', async () => {
+ vi.spyOn(favoriteAction, 'favoriteNode')
+ vi.spyOn(axios, 'post')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 1,
+ },
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 0,
+ },
+ })
+
+ // Mixed states triggers favorite action
+ const exec = await action.execBatch!([file1, file2], view, '/')
+ expect(exec).toStrictEqual([true, true])
+ expect([file1, file2].every(file => file.attributes.favorite === 1)).toBe(true)
+
+ expect(axios.post).toBeCalledTimes(2)
+ expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: ['_$!<Favorite>!$_'] })
+ expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: ['_$!<Favorite>!$_'] })
+ })
+
+ test('Remove from favorite action batch execute with favorites only files', async () => {
+ vi.spyOn(favoriteAction, 'favoriteNode')
+ vi.spyOn(axios, 'post')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 1,
+ },
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ // Mixed states triggers favorite action
+ const exec = await action.execBatch!([file1, file2], view, '/')
+ expect(exec).toStrictEqual([true, true])
+ expect([file1, file2].every(file => file.attributes.favorite === 0)).toBe(true)
+
+ expect(axios.post).toBeCalledTimes(2)
+ expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: [] })
+ expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: [] })
+ })
+})
diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts
new file mode 100644
index 00000000000..b0e1e3a0817
--- /dev/null
+++ b/apps/files/src/actions/favoriteAction.ts
@@ -0,0 +1,119 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { Permission, FileAction } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { encodePath } from '@nextcloud/paths'
+import { generateUrl } from '@nextcloud/router'
+import { isPublicShare } from '@nextcloud/sharing/public'
+import axios from '@nextcloud/axios'
+import PQueue from 'p-queue'
+import Vue from 'vue'
+
+import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
+import StarSvg from '@mdi/svg/svg/star.svg?raw'
+
+import logger from '../logger.ts'
+
+export const ACTION_FAVORITE = 'favorite'
+
+const queue = new PQueue({ concurrency: 5 })
+
+// If any of the nodes is not favorited, we display the favorite action.
+const shouldFavorite = (nodes: Node[]): boolean => {
+ return nodes.some(node => node.attributes.favorite !== 1)
+}
+
+export const favoriteNode = async (node: Node, view: View, willFavorite: boolean): Promise<boolean> => {
+ try {
+ // TODO: migrate to webdav tags plugin
+ const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path)
+ await axios.post(url, {
+ tags: willFavorite
+ ? [window.OC.TAG_FAVORITE]
+ : [],
+ })
+
+ // Let's delete if we are in the favourites view
+ // AND if it is removed from the user favorites
+ // AND it's in the root of the favorites view
+ if (view.id === 'favorites' && !willFavorite && node.dirname === '/') {
+ emit('files:node:deleted', node)
+ }
+
+ // Update the node webdav attribute
+ Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0)
+
+ // Dispatch event to whoever is interested
+ if (willFavorite) {
+ emit('files:favorites:added', node)
+ } else {
+ emit('files:favorites:removed', node)
+ }
+
+ return true
+ } catch (error) {
+ const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites'
+ logger.error('Error while ' + action, { error, source: node.source, node })
+ return false
+ }
+}
+
+export const action = new FileAction({
+ id: ACTION_FAVORITE,
+ displayName(nodes: Node[]) {
+ return shouldFavorite(nodes)
+ ? t('files', 'Add to favorites')
+ : t('files', 'Remove from favorites')
+ },
+ iconSvgInline: (nodes: Node[]) => {
+ return shouldFavorite(nodes)
+ ? StarOutlineSvg
+ : StarSvg
+ },
+
+ enabled(nodes: Node[]) {
+ // Not enabled for public shares
+ if (isPublicShare()) {
+ return false
+ }
+
+ // We can only favorite nodes if they are located in files
+ return nodes.every(node => node.root?.startsWith?.('/files'))
+ // and we have permissions
+ && nodes.every(node => node.permissions !== Permission.NONE)
+ },
+
+ async exec(node: Node, view: View) {
+ const willFavorite = shouldFavorite([node])
+ return await favoriteNode(node, view, willFavorite)
+ },
+ async execBatch(nodes: Node[], view: View) {
+ const willFavorite = shouldFavorite(nodes)
+
+ // Map each node to a promise that resolves with the result of exec(node)
+ const promises = nodes.map(node => {
+ // Create a promise that resolves with the result of exec(node)
+ const promise = new Promise<boolean>(resolve => {
+ queue.add(async () => {
+ try {
+ await favoriteNode(node, view, willFavorite)
+ resolve(true)
+ } catch (error) {
+ logger.error('Error while adding file to favorite', { error, source: node.source, node })
+ resolve(false)
+ }
+ })
+ })
+ return promise
+ })
+
+ return Promise.all(promises)
+ },
+
+ order: -50,
+})
diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts
new file mode 100644
index 00000000000..06e32c98090
--- /dev/null
+++ b/apps/files/src/actions/moveOrCopyAction.ts
@@ -0,0 +1,373 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Folder, Node, View } from '@nextcloud/files'
+import type { IFilePickerButton } from '@nextcloud/dialogs'
+import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
+import type { MoveCopyResult } from './moveOrCopyActionUtils'
+
+import { isAxiosError } from '@nextcloud/axios'
+import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { openConflictPicker, hasConflict } from '@nextcloud/upload'
+import { basename, join } from 'path'
+import Vue from 'vue'
+
+import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
+import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
+
+import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
+import { getContents } from '../services/Files'
+import logger from '../logger'
+
+/**
+ * Return the action that is possible for the given nodes
+ * @param {Node[]} nodes The nodes to check against
+ * @return {MoveCopyAction} The action that is possible for the given nodes
+ */
+const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
+ if (canMove(nodes)) {
+ if (canCopy(nodes)) {
+ return MoveCopyAction.MOVE_OR_COPY
+ }
+ return MoveCopyAction.MOVE
+ }
+
+ // Assuming we can copy as the enabled checks for copy permissions
+ return MoveCopyAction.COPY
+}
+
+/**
+ * Create a loading notification toast
+ * @param mode The move or copy mode
+ * @param source Name of the node that is copied / moved
+ * @param destination Destination path
+ * @return {() => void} Function to hide the notification
+ */
+function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
+ const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
+
+ let toast: ReturnType<typeof showInfo>|undefined
+ toast = showInfo(
+ `<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
+ {
+ isHTML: true,
+ timeout: TOAST_PERMANENT_TIMEOUT,
+ onRemove: () => { toast?.hideToast(); toast = undefined },
+ },
+ )
+ return () => toast && toast.hideToast()
+}
+
+/**
+ * Handle the copy/move of a node to a destination
+ * This can be imported and used by other scripts/components on server
+ * @param {Node} node The node to copy/move
+ * @param {Folder} destination The destination to copy/move the node to
+ * @param {MoveCopyAction} method The method to use for the copy/move
+ * @param {boolean} overwrite Whether to overwrite the destination if it exists
+ * @return {Promise<void>} A promise that resolves when the copy/move is done
+ */
+export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
+ if (!destination) {
+ return
+ }
+
+ if (destination.type !== FileType.Folder) {
+ throw new Error(t('files', 'Destination is not a folder'))
+ }
+
+ // Do not allow to MOVE a node to the same folder it is already located
+ if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
+ throw new Error(t('files', 'This file/folder is already in that directory'))
+ }
+
+ /**
+ * Example:
+ * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
+ * Allow move of /foo does not start with /foo/bar/file.txt so allow
+ * - node: /foo , destination: /foo/bar
+ * Do not allow as it would copy foo within itself
+ * - node: /foo/bar.txt, destination: /foo
+ * Allow copy a file to the same directory
+ * - node: "/foo/bar", destination: "/foo/bar 1"
+ * Allow to move or copy but we need to check with trailing / otherwise it would report false positive
+ */
+ if (`${destination.path}/`.startsWith(`${node.path}/`)) {
+ throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
+ }
+
+ // Set loading state
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ const actionFinished = createLoadingNotification(method, node.basename, destination.path)
+
+ const queue = getQueue()
+ return await queue.add(async () => {
+ const copySuffix = (index: number) => {
+ if (index === 1) {
+ return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
+ }
+ return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
+ }
+
+ try {
+ const client = davGetClient()
+ const currentPath = join(davRootPath, node.path)
+ const destinationPath = join(davRootPath, destination.path)
+
+ if (method === MoveCopyAction.COPY) {
+ let target = node.basename
+ // If we do not allow overwriting then find an unique name
+ if (!overwrite) {
+ const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
+ target = getUniqueName(
+ node.basename,
+ otherNodes.map((n) => n.basename),
+ {
+ suffix: copySuffix,
+ ignoreFileExtension: node.type === FileType.Folder,
+ },
+ )
+ }
+ await client.copyFile(currentPath, join(destinationPath, target))
+ // If the node is copied into current directory the view needs to be updated
+ if (node.dirname === destination.path) {
+ const { data } = await client.stat(
+ join(destinationPath, target),
+ {
+ details: true,
+ data: davGetDefaultPropfind(),
+ },
+ ) as ResponseDataDetailed<FileStat>
+ emit('files:node:created', davResultToNode(data))
+ }
+ } else {
+ // show conflict file popup if we do not allow overwriting
+ if (!overwrite) {
+ const otherNodes = await getContents(destination.path)
+ if (hasConflict([node], otherNodes.contents)) {
+ try {
+ // Let the user choose what to do with the conflicting files
+ const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
+ // two empty arrays: either only old files or conflict skipped -> no action required
+ if (!selected.length && !renamed.length) {
+ return
+ }
+ } catch (error) {
+ // User cancelled
+ return
+ }
+ }
+ }
+ // getting here means either no conflict, file was renamed to keep both files
+ // in a conflict, or the selected file was chosen to be kept during the conflict
+ try {
+ await client.moveFile(currentPath, join(destinationPath, node.basename))
+ } catch (error) {
+ const parser = new DOMParser()
+ const text = await (error as WebDAVClientError).response?.text()
+ const message = parser.parseFromString(text ?? '', 'text/xml')
+ .querySelector('message')?.textContent
+ if (message) {
+ showError(message)
+ }
+ throw error
+ }
+ // Delete the node as it will be fetched again
+ // when navigating to the destination folder
+ emit('files:node:deleted', node)
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ if (error.response?.status === 412) {
+ throw new Error(t('files', 'A file or folder with that name already exists in this folder'))
+ } else if (error.response?.status === 423) {
+ throw new Error(t('files', 'The files are locked'))
+ } else if (error.response?.status === 404) {
+ throw new Error(t('files', 'The file does not exist anymore'))
+ } else if (error.message) {
+ throw new Error(error.message)
+ }
+ }
+ logger.debug(error as Error)
+ throw new Error()
+ } finally {
+ Vue.set(node, 'status', '')
+ actionFinished()
+ }
+ })
+}
+
+/**
+ * Open a file picker for the given action
+ * @param action The action to open the file picker for
+ * @param dir The directory to start the file picker in
+ * @param nodes The nodes to move/copy
+ * @return The picked destination or false if cancelled by user
+ */
+async function openFilePickerForAction(
+ action: MoveCopyAction,
+ dir = '/',
+ nodes: Node[],
+): Promise<MoveCopyResult | false> {
+ const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>()
+ const fileIDs = nodes.map(node => node.fileid).filter(Boolean)
+ const filePicker = getFilePickerBuilder(t('files', 'Choose destination'))
+ .allowDirectories(true)
+ .setFilter((n: Node) => {
+ // We don't want to show the current nodes in the file picker
+ return !fileIDs.includes(n.fileid)
+ })
+ .setMimeTypeFilter([])
+ .setMultiSelect(false)
+ .startAt(dir)
+ .setButtonFactory((selection: Node[], path: string) => {
+ const buttons: IFilePickerButton[] = []
+ const target = basename(path)
+
+ const dirnames = nodes.map(node => node.dirname)
+ const paths = nodes.map(node => node.path)
+
+ if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
+ buttons.push({
+ label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'),
+ type: 'primary',
+ icon: CopyIconSvg,
+ disabled: selection.some((node) => (node.permissions & Permission.CREATE) === 0),
+ async callback(destination: Node[]) {
+ resolve({
+ destination: destination[0] as Folder,
+ action: MoveCopyAction.COPY,
+ } as MoveCopyResult)
+ },
+ })
+ }
+
+ // Invalid MOVE targets (but valid copy targets)
+ if (dirnames.includes(path)) {
+ // This file/folder is already in that directory
+ return buttons
+ }
+
+ if (paths.includes(path)) {
+ // You cannot move a file/folder onto itself
+ return buttons
+ }
+
+ if (selection.some((node) => (node.permissions & Permission.CREATE) === 0)) {
+ // Missing 'CREATE' permissions for selected destination
+ return buttons
+ }
+
+ if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
+ buttons.push({
+ label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'),
+ type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary',
+ icon: FolderMoveSvg,
+ async callback(destination: Node[]) {
+ resolve({
+ destination: destination[0] as Folder,
+ action: MoveCopyAction.MOVE,
+ } as MoveCopyResult)
+ },
+ })
+ }
+
+ return buttons
+ })
+ .build()
+
+ filePicker.pick()
+ .catch((error: Error) => {
+ logger.debug(error as Error)
+ if (error instanceof FilePickerClosed) {
+ resolve(false)
+ } else {
+ reject(new Error(t('files', 'Move or copy operation failed')))
+ }
+ })
+
+ return promise
+}
+
+export const ACTION_COPY_MOVE = 'move-copy'
+export const action = new FileAction({
+ id: ACTION_COPY_MOVE,
+ displayName(nodes: Node[]) {
+ switch (getActionForNodes(nodes)) {
+ case MoveCopyAction.MOVE:
+ return t('files', 'Move')
+ case MoveCopyAction.COPY:
+ return t('files', 'Copy')
+ case MoveCopyAction.MOVE_OR_COPY:
+ return t('files', 'Move or copy')
+ }
+ },
+ iconSvgInline: () => FolderMoveSvg,
+ enabled(nodes: Node[], view: View) {
+ // We can not copy or move in single file shares
+ if (view.id === 'public-file-share') {
+ return false
+ }
+ // We only support moving/copying files within the user folder
+ if (!nodes.every(node => node.root?.startsWith('/files/'))) {
+ return false
+ }
+ return nodes.length > 0 && (canMove(nodes) || canCopy(nodes))
+ },
+
+ async exec(node: Node, view: View, dir: string) {
+ const action = getActionForNodes([node])
+ let result
+ try {
+ result = await openFilePickerForAction(action, dir, [node])
+ } catch (e) {
+ logger.error(e as Error)
+ return false
+ }
+ if (result === false) {
+ return null
+ }
+
+ try {
+ await handleCopyMoveNodeTo(node, result.destination, result.action)
+ return true
+ } catch (error) {
+ if (error instanceof Error && !!error.message) {
+ showError(error.message)
+ // Silent action as we handle the toast
+ return null
+ }
+ return false
+ }
+ },
+
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ const action = getActionForNodes(nodes)
+ const result = await openFilePickerForAction(action, dir, nodes)
+ // Handle cancellation silently
+ if (result === false) {
+ return nodes.map(() => null)
+ }
+
+ const promises = nodes.map(async node => {
+ try {
+ await handleCopyMoveNodeTo(node, result.destination, result.action)
+ return true
+ } catch (error) {
+ logger.error(`Failed to ${result.action} node`, { node, error })
+ return false
+ }
+ })
+
+ // We need to keep the selection on error!
+ // So we do not return null, and for batch action
+ // we let the front handle the error.
+ return await Promise.all(promises)
+ },
+
+ order: 15,
+})
diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts
new file mode 100644
index 00000000000..0372e8f4bc7
--- /dev/null
+++ b/apps/files/src/actions/moveOrCopyActionUtils.ts
@@ -0,0 +1,71 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Folder, Node } from '@nextcloud/files'
+import type { ShareAttribute } from '../../../files_sharing/src/sharing'
+
+import { Permission } from '@nextcloud/files'
+import { isPublicShare } from '@nextcloud/sharing/public'
+import PQueue from 'p-queue'
+import { loadState } from '@nextcloud/initial-state'
+
+const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE)
+
+// This is the processing queue. We only want to allow 3 concurrent requests
+let queue: PQueue
+
+// Maximum number of concurrent operations
+const MAX_CONCURRENCY = 5
+
+/**
+ * Get the processing queue
+ */
+export const getQueue = () => {
+ if (!queue) {
+ queue = new PQueue({ concurrency: MAX_CONCURRENCY })
+ }
+ return queue
+}
+
+export enum MoveCopyAction {
+ MOVE = 'Move',
+ COPY = 'Copy',
+ MOVE_OR_COPY = 'move-or-copy',
+}
+
+export type MoveCopyResult = {
+ destination: Folder
+ action: MoveCopyAction.COPY | MoveCopyAction.MOVE
+}
+
+export const canMove = (nodes: Node[]) => {
+ const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
+ return Boolean(minPermission & Permission.DELETE)
+}
+
+export const canDownload = (nodes: Node[]) => {
+ return nodes.every(node => {
+ const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute>
+ return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
+
+ })
+}
+
+export const canCopy = (nodes: Node[]) => {
+ // a shared file cannot be copied if the download is disabled
+ if (!canDownload(nodes)) {
+ return false
+ }
+ // it cannot be copied if the user has only view permissions
+ if (nodes.some((node) => node.permissions === Permission.NONE)) {
+ return false
+ }
+ // on public shares all files have the same permission so copy is only possible if write permission is granted
+ if (isPublicShare()) {
+ return Boolean(sharePermissions & Permission.CREATE)
+ }
+ // otherwise permission is granted
+ return true
+}
diff --git a/apps/files/src/actions/openFolderAction.spec.ts b/apps/files/src/actions/openFolderAction.spec.ts
new file mode 100644
index 00000000000..066ad5d86d8
--- /dev/null
+++ b/apps/files/src/actions/openFolderAction.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Node, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
+
+import { action } from './openFolderAction'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+describe('Open folder action conditions tests', () => {
+ test('Default values', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('open-folder')
+ expect(action.displayName([folder], view)).toBe('Open folder FooBar')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBe(DefaultType.HIDDEN)
+ expect(action.order).toBe(-100)
+ })
+})
+
+describe('Open folder action enabled tests', () => {
+ test('Enabled for folders', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder], view)).toBe(true)
+ })
+
+ test('Disabled for non-dav ressources', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://domain.com/data/FooBar/',
+ owner: 'admin',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder], view)).toBe(false)
+ })
+
+ test('Disabled if more than one node', () => {
+ const folder1 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+ const folder2 = new Folder({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder1, folder2], view)).toBe(false)
+ })
+
+ test('Disabled for files', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled without READ permissions', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder], view)).toBe(false)
+ })
+})
+
+describe('Open folder action execute tests', () => {
+ test('Open folder', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(folder, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/FooBar' })
+ })
+
+ test('Open folder fails without node', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const exec = await action.exec(null as unknown as Node, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+
+ test('Open folder fails without Folder', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts
new file mode 100644
index 00000000000..8719f7a93fb
--- /dev/null
+++ b/apps/files/src/actions/openFolderAction.ts
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { Permission, Node, FileType, View, FileAction, DefaultType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+
+export const action = new FileAction({
+ id: 'open-folder',
+ displayName(files: Node[]) {
+ // Only works on single node
+ const displayName = files[0].displayname
+ return t('files', 'Open folder {displayName}', { displayName })
+ },
+ iconSvgInline: () => FolderSvg,
+
+ enabled(nodes: Node[]) {
+ // Only works on single node
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ const node = nodes[0]
+
+ if (!node.isDavRessource) {
+ return false
+ }
+
+ return node.type === FileType.Folder
+ && (node.permissions & Permission.READ) !== 0
+ },
+
+ async exec(node: Node, view: View) {
+ if (!node || node.type !== FileType.Folder) {
+ return false
+ }
+
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { view: view.id, fileid: String(node.fileid) },
+ { dir: node.path },
+ )
+ return null
+ },
+
+ // Main action if enabled, meaning folders only
+ default: DefaultType.HIDDEN,
+ order: -100,
+})
diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts
new file mode 100644
index 00000000000..3ccd15fa2d2
--- /dev/null
+++ b/apps/files/src/actions/openInFilesAction.spec.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { action } from './openInFilesAction'
+import { describe, expect, test, vi } from 'vitest'
+import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const recentView = {
+ id: 'recent',
+ name: 'Recent',
+} as View
+
+describe('Open in files action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('open-in-files')
+ expect(action.displayName([], recentView)).toBe('Open in Files')
+ expect(action.iconSvgInline([], recentView)).toBe('')
+ expect(action.default).toBe(DefaultType.HIDDEN)
+ expect(action.order).toBe(-1000)
+ expect(action.inline).toBeUndefined()
+ })
+})
+
+describe('Open in files action enabled tests', () => {
+ test('Enabled with on valid view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], recentView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+})
+
+describe('Open in files action execute tests', () => {
+ test('Open in files', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ root: '/files/admin',
+ permissions: Permission.ALL,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' })
+ })
+
+ test('Open in files with folder', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ root: '/files/admin',
+ permissions: Permission.ALL,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo/Bar', openfile: 'true' })
+ })
+})
diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts
new file mode 100644
index 00000000000..9e10b1ac74e
--- /dev/null
+++ b/apps/files/src/actions/openInFilesAction.ts
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+
+import { t } from '@nextcloud/l10n'
+import { FileType, FileAction, DefaultType } from '@nextcloud/files'
+import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search'
+
+export const action = new FileAction({
+ id: 'open-in-files',
+ displayName: () => t('files', 'Open in Files'),
+ iconSvgInline: () => '',
+
+ enabled(nodes, view) {
+ return view.id === 'recent' || view.id === SEARCH_VIEW_ID
+ },
+
+ async exec(node: Node) {
+ let dir = node.dirname
+ if (node.type === FileType.Folder) {
+ dir = dir + '/' + node.basename
+ }
+
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: String(node.fileid) },
+ { dir, openfile: 'true' },
+ )
+ return null
+ },
+
+ // Before openFolderAction
+ order: -1000,
+ default: DefaultType.HIDDEN,
+})
diff --git a/apps/files/src/actions/openLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts
new file mode 100644
index 00000000000..860bd6233f4
--- /dev/null
+++ b/apps/files/src/actions/openLocallyAction.spec.ts
@@ -0,0 +1,170 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import axios from '@nextcloud/axios'
+import * as nextcloudDialogs from '@nextcloud/dialogs'
+import { action } from './openLocallyAction'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+// Mock web root variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = '';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).OCA = { Viewer: { open: vi.fn() } }
+})
+
+describe('Open locally action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('edit-locally')
+ expect(action.displayName([], view)).toBe('Open locally')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(25)
+ })
+})
+
+describe('Open locally action enabled tests', () => {
+ test('Enabled for file with UPDATE permission', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled for non-dav resources', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://domain.com/data/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled if more than one node', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file1, file2], view)).toBe(false)
+ })
+
+ test('Disabled for files', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled without UPDATE permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+})
+
+describe('Open locally action execute tests', () => {
+ let spyShowDialog
+ beforeEach(() => {
+ vi.resetAllMocks()
+ spyShowDialog = vi.spyOn(nextcloudDialogs.Dialog.prototype, 'show')
+ .mockImplementation(() => Promise.resolve())
+ })
+
+ test('Open locally opens proper URL', async () => {
+ vi.spyOn(axios, 'post').mockImplementation(async () => ({
+ data: { ocs: { data: { token: 'foobar' } } },
+ }))
+ const showError = vi.spyOn(nextcloudDialogs, 'showError')
+ const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ const file = new File({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.UPDATE,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(spyShowDialog).toBeCalled()
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
+ expect(showError).toBeCalledTimes(0)
+ expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self')
+ })
+
+ test('Open locally fails and shows error', async () => {
+ vi.spyOn(axios, 'post').mockImplementation(async () => ({}))
+ const showError = vi.spyOn(nextcloudDialogs, 'showError')
+
+ const file = new File({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.UPDATE,
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ expect(spyShowDialog).toBeCalled()
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
+ expect(showError).toBeCalledTimes(1)
+ expect(showError).toBeCalledWith('Failed to redirect to client')
+ expect(window.location.href).toBe('http://nextcloud.local/')
+ })
+})
diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts
new file mode 100644
index 00000000000..986b304210c
--- /dev/null
+++ b/apps/files/src/actions/openLocallyAction.ts
@@ -0,0 +1,114 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { encodePath } from '@nextcloud/paths'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { FileAction, Permission, type Node } from '@nextcloud/files'
+import { showError, DialogBuilder } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
+import IconWeb from '@mdi/svg/svg/web.svg?raw'
+import { isPublicShare } from '@nextcloud/sharing/public'
+
+export const action = new FileAction({
+ id: 'edit-locally',
+ displayName: () => t('files', 'Open locally'),
+ iconSvgInline: () => LaptopSvg,
+
+ // Only works on single files
+ enabled(nodes: Node[]) {
+ // Only works on single node
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ // does not work with shares
+ if (isPublicShare()) {
+ return false
+ }
+
+ return (nodes[0].permissions & Permission.UPDATE) !== 0
+ },
+
+ async exec(node: Node) {
+ await attemptOpenLocalClient(node.path)
+ return null
+ },
+
+ order: 25,
+})
+
+/**
+ * Try to open the path in the Nextcloud client.
+ *
+ * If this fails a dialog is shown with 3 options:
+ * 1. Retry: If it fails no further dialog is shown.
+ * 2. Open online: The viewer is used to open the file.
+ * 3. Close the dialog and nothing happens (abort).
+ *
+ * @param path - The path to open
+ */
+async function attemptOpenLocalClient(path: string) {
+ await openLocalClient(path)
+ const result = await confirmLocalEditDialog()
+ if (result === 'local') {
+ await openLocalClient(path)
+ } else if (result === 'online') {
+ window.OCA.Viewer.open({ path })
+ }
+}
+
+/**
+ * Try to open a file in the Nextcloud client.
+ * There is no way to get notified if this action was successfull.
+ *
+ * @param path - Path to open
+ */
+async function openLocalClient(path: string): Promise<void> {
+ const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json'
+
+ try {
+ const result = await axios.post(link, { path })
+ const uid = getCurrentUser()?.uid
+ let url = `nc://open/${uid}@` + window.location.host + encodePath(path)
+ url += '?token=' + result.data.ocs.data.token
+
+ window.open(url, '_self')
+ } catch (error) {
+ showError(t('files', 'Failed to redirect to client'))
+ }
+}
+
+/**
+ * Open the confirmation dialog.
+ */
+async function confirmLocalEditDialog(): Promise<'online'|'local'|false> {
+ let result: 'online'|'local'|false = false
+ const dialog = (new DialogBuilder())
+ .setName(t('files', 'Open file locally'))
+ .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.'))
+ .setButtons([
+ {
+ label: t('files', 'Retry and close'),
+ type: 'secondary',
+ callback: () => {
+ result = 'local'
+ },
+ },
+ {
+ label: t('files', 'Open online'),
+ icon: IconWeb,
+ type: 'primary',
+ callback: () => {
+ result = 'online'
+ },
+ },
+ ])
+ .build()
+
+ await dialog.show()
+ return result
+}
diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts
new file mode 100644
index 00000000000..1f9c9209d41
--- /dev/null
+++ b/apps/files/src/actions/renameAction.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { action } from './renameAction'
+import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
+import * as eventBus from '@nextcloud/event-bus'
+import { describe, expect, test, vi, beforeEach } from 'vitest'
+import { useFilesStore } from '../store/files'
+import { getPinia } from '../store/index.ts'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+beforeEach(() => {
+ const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
+ const files = useFilesStore(getPinia())
+ files.setRoot({ service: 'files', root })
+})
+
+describe('Rename action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('rename')
+ expect(action.displayName([], view)).toBe('Rename')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(10)
+ })
+})
+
+describe('Rename action enabled tests', () => {
+ test('Enabled for node with UPDATE permission', () => {
+ const file = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.UPDATE | Permission.DELETE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled for node without DELETE permission', () => {
+ const file = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled if more than one node', () => {
+ window.OCA = { Files: { Sidebar: {} } }
+
+ const file1 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file1, file2], view)).toBe(false)
+ })
+})
+
+describe('Rename action exec tests', () => {
+ test('Rename', async () => {
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+
+ // Silent action
+ expect(exec).toBe(null)
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:node:rename', file)
+ })
+})
diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts
new file mode 100644
index 00000000000..715ecb7563e
--- /dev/null
+++ b/apps/files/src/actions/renameAction.ts
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { emit } from '@nextcloud/event-bus'
+import { Permission, type Node, FileAction, View } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw'
+import { getPinia } from '../store'
+import { useFilesStore } from '../store/files'
+import { dirname } from 'path'
+
+export const ACTION_RENAME = 'rename'
+
+export const action = new FileAction({
+ id: ACTION_RENAME,
+ displayName: () => t('files', 'Rename'),
+ iconSvgInline: () => PencilSvg,
+
+ enabled: (nodes: Node[], view: View) => {
+ if (nodes.length === 0) {
+ return false
+ }
+
+ // Disable for single file shares
+ if (view.id === 'public-file-share') {
+ return false
+ }
+
+ const node = nodes[0]
+ const filesStore = useFilesStore(getPinia())
+ const parentNode = node.dirname === '/'
+ ? filesStore.getRoot(view.id)
+ : filesStore.getNode(dirname(node.source))
+ const parentPermissions = parentNode?.permissions || Permission.NONE
+
+ // Only enable if the node have the delete permission
+ // and if the parent folder allows creating files
+ return Boolean(node.permissions & Permission.DELETE)
+ && Boolean(parentPermissions & Permission.CREATE)
+ },
+
+ async exec(node: Node) {
+ // Renaming is a built-in feature of the files app
+ emit('files:node:rename', node)
+ return null
+ },
+
+ order: 10,
+})
diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts
new file mode 100644
index 00000000000..9085bf595ad
--- /dev/null
+++ b/apps/files/src/actions/sidebarAction.spec.ts
@@ -0,0 +1,185 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, FileAction, Folder } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
+
+import { action } from './sidebarAction'
+import logger from '../logger'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+describe('Open sidebar action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('details')
+ expect(action.displayName([], view)).toBe('Details')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(-50)
+ })
+})
+
+describe('Open sidebar action enabled tests', () => {
+ test('Enabled for ressources within user root folder', () => {
+ window.OCA = { Files: { Sidebar: {} } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled without permissions', () => {
+ window.OCA = { Files: { Sidebar: {} } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+
+ })
+
+ test('Disabled if more than one node', () => {
+ window.OCA = { Files: { Sidebar: {} } }
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file1, file2], view)).toBe(false)
+ })
+
+ test('Disabled if no Sidebar', () => {
+ window.OCA = {}
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled for non-dav ressources', () => {
+ window.OCA = { Files: { Sidebar: {} } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://domain.com/documents/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+})
+
+describe('Open sidebar action exec tests', () => {
+ test('Open sidebar', async () => {
+ const openMock = vi.fn()
+ const defaultTabMock = vi.fn()
+ window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
+
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // 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,
+ )
+ })
+
+ test('Open sidebar for folder', async () => {
+ const openMock = vi.fn()
+ const defaultTabMock = vi.fn()
+ window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
+
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar',
+ owner: 'admin',
+ mime: 'httpd/unix-directory',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // 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,
+ )
+ })
+
+ test('Open sidebar fails', async () => {
+ const openMock = vi.fn(() => { throw new Error('Mock error') })
+ const defaultTabMock = vi.fn()
+ window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } }
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ const exec = await action.exec(file, view, '/')
+ expect(exec).toBe(false)
+ expect(openMock).toBeCalledTimes(1)
+ expect(logger.error).toBeCalledTimes(1)
+ })
+})
diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts
new file mode 100644
index 00000000000..8f020b4ee8d
--- /dev/null
+++ b/apps/files/src/actions/sidebarAction.ts
@@ -0,0 +1,74 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { Permission, FileAction } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { isPublicShare } from '@nextcloud/sharing/public'
+
+import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw'
+
+import logger from '../logger.ts'
+
+export const ACTION_DETAILS = 'details'
+
+export const action = new FileAction({
+ id: ACTION_DETAILS,
+ displayName: () => t('files', 'Details'),
+ iconSvgInline: () => InformationSvg,
+
+ // Sidebar currently supports user folder only, /files/USER
+ enabled: (nodes: Node[]) => {
+ 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
+ },
+
+ async exec(node: Node, view: View, dir: string) {
+ try {
+ // If the sidebar is already open for the current file, do nothing
+ if (window.OCA.Files.Sidebar.file === node.path) {
+ 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, opendetails: 'true' },
+ true,
+ )
+
+ return null
+ } catch (error) {
+ logger.error('Error while opening sidebar', { error })
+ return false
+ }
+ },
+
+ order: -50,
+})
diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts
new file mode 100644
index 00000000000..bd618c8a89f
--- /dev/null
+++ b/apps/files/src/actions/viewInFolderAction.spec.ts
@@ -0,0 +1,193 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Node, Permission, View, FileAction } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
+import { action } from './viewInFolderAction'
+
+const view = {
+ id: 'trashbin',
+ name: 'Trashbin',
+} as View
+
+const viewFiles = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+describe('View in folder action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('view-in-folder')
+ expect(action.displayName([], view)).toBe('View in folder')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(80)
+ expect(action.enabled).toBeDefined()
+ })
+})
+
+describe('View in folder action enabled tests', () => {
+ test('Enabled for trashbin', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(true)
+ })
+
+ test('Disabled for files', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], viewFiles)).toBe(false)
+ })
+
+ test('Disabled without permissions', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.NONE,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled for non-dav ressources', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://domain.com/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+
+ test('Disabled if more than one node', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file1, file2], view)).toBe(false)
+ })
+
+ test('Disabled for folders', () => {
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder], view)).toBe(false)
+ })
+
+ test('Disabled for files outside the user root folder', () => {
+ const file = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/trashbin/admin/trash/image.jpg.d1731053878',
+ owner: 'admin',
+ permissions: Permission.READ,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], view)).toBe(false)
+ })
+})
+
+describe('View in folder action execute tests', () => {
+ test('View in folder', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/' })
+ })
+
+ test('View in (sub) folder', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar/foobar.txt',
+ root: '/files/admin',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo/Bar' })
+ })
+
+ test('View in folder fails without node', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const exec = await action.exec(null as unknown as Node, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+
+ test('View in folder fails without File', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ })
+
+ const exec = await action.exec(folder, view, '/')
+ expect(exec).toBe(false)
+ expect(goToRouteMock).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts
new file mode 100644
index 00000000000..b22393c1152
--- /dev/null
+++ b/apps/files/src/actions/viewInFolderAction.ts
@@ -0,0 +1,68 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { isPublicShare } from '@nextcloud/sharing/public'
+import { FileAction, FileType, Permission } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+
+import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
+
+export const action = new FileAction({
+ id: 'view-in-folder',
+ displayName() {
+ return t('files', 'View in folder')
+ },
+ iconSvgInline: () => FolderMoveSvg,
+
+ enabled(nodes: Node[], view: View) {
+ // Not enabled for public shares
+ if (isPublicShare()) {
+ return false
+ }
+
+ // Only works outside of the main files view
+ if (view.id === 'files') {
+ return false
+ }
+
+ // Only works on single node
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ const node = nodes[0]
+
+ if (!node.isDavRessource) {
+ return false
+ }
+
+ // Can only view files that are in the user root folder
+ if (!node.root?.startsWith('/files')) {
+ return false
+ }
+
+ if (node.permissions === Permission.NONE) {
+ return false
+ }
+
+ return node.type === FileType.File
+ },
+
+ async exec(node: Node) {
+ if (!node || node.type !== FileType.File) {
+ return false
+ }
+
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { view: 'files', fileid: String(node.fileid) },
+ { dir: node.dirname },
+ )
+ return null
+ },
+
+ order: 80,
+})
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
new file mode 100644
index 00000000000..8458fd65f3d
--- /dev/null
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -0,0 +1,310 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcBreadcrumbs data-cy-files-content-breadcrumbs
+ :aria-label="t('files', 'Current directory path')"
+ class="files-list__breadcrumbs"
+ :class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }">
+ <!-- Current path sections -->
+ <NcBreadcrumb v-for="(section, index) in sections"
+ :key="section.dir"
+ v-bind="section"
+ dir="auto"
+ :to="section.to"
+ :force-icon-text="index === 0 && fileListWidth >= 486"
+ :title="titleForSection(index, section)"
+ :aria-description="ariaForSection(section)"
+ @click.native="onClick(section.to)"
+ @dragover.native="onDragOver($event, section.dir)"
+ @drop="onDrop($event, section.dir)">
+ <template v-if="index === 0" #icon>
+ <NcIconSvgWrapper :size="20"
+ :svg="viewIcon" />
+ </template>
+ </NcBreadcrumb>
+
+ <!-- Forward the actions slot -->
+ <template #actions>
+ <slot name="actions" />
+ </template>
+ </NcBreadcrumbs>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { FileSource } from '../types.ts'
+
+import { basename } from 'path'
+import { defineComponent } from 'vue'
+import { Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import HomeSvg from '@mdi/svg/svg/home.svg?raw'
+import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb'
+import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import { useNavigation } from '../composables/useNavigation.ts'
+import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { showError } from '@nextcloud/dialogs'
+import { useDragAndDropStore } from '../store/dragging.ts'
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useUploaderStore } from '../store/uploader.ts'
+import logger from '../logger'
+
+export default defineComponent({
+ name: 'BreadCrumbs',
+
+ components: {
+ NcBreadcrumbs,
+ NcBreadcrumb,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ path: {
+ type: String,
+ default: '/',
+ },
+ },
+
+ setup() {
+ const draggingStore = useDragAndDropStore()
+ const filesStore = useFilesStore()
+ const pathsStore = usePathsStore()
+ const selectionStore = useSelectionStore()
+ const uploaderStore = useUploaderStore()
+ const fileListWidth = useFileListWidth()
+ const { currentView, views } = useNavigation()
+
+ return {
+ draggingStore,
+ filesStore,
+ pathsStore,
+ selectionStore,
+ uploaderStore,
+
+ currentView,
+ fileListWidth,
+ views,
+ }
+ },
+
+ computed: {
+ dirs(): string[] {
+ const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`)
+ // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
+ const paths: string[] = this.path.split('/').filter(Boolean).map(cumulativePath('/'))
+ // Strip away trailing slash
+ return ['/', ...paths.map((path: string) => path.replace(/^(.+)\/$/, '$1'))]
+ },
+
+ sections() {
+ return this.dirs.map((dir: string, index: number) => {
+ const source = this.getFileSourceFromPath(dir)
+ const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
+ return {
+ dir,
+ exact: true,
+ name: this.getDirDisplayName(dir),
+ to: this.getTo(dir, node),
+ // disable drop on current directory
+ disableDrop: index === this.dirs.length - 1,
+ }
+ })
+ },
+
+ isUploadInProgress(): boolean {
+ return this.uploaderStore.queue.length !== 0
+ },
+
+ // Hide breadcrumbs if an upload is ongoing
+ wrapUploadProgressBar(): boolean {
+ // if an upload is ongoing, and on small screens / mobile, then
+ // show the progress bar for the upload below breadcrumbs
+ return this.isUploadInProgress && this.fileListWidth < 512
+ },
+
+ // used to show the views icon for the first breadcrumb
+ viewIcon(): string {
+ return this.currentView?.icon ?? HomeSvg
+ },
+
+ selectedFiles() {
+ return this.selectionStore.selected as FileSource[]
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging as FileSource[]
+ },
+ },
+
+ methods: {
+ getNodeFromSource(source: FileSource): Node | undefined {
+ return this.filesStore.getNode(source)
+ },
+ getFileSourceFromPath(path: string): FileSource | null {
+ return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
+ },
+ getDirDisplayName(path: string): string {
+ if (path === '/') {
+ return this.currentView?.name || t('files', 'Home')
+ }
+
+ const source = this.getFileSourceFromPath(path)
+ const node = source ? this.getNodeFromSource(source) : undefined
+ return node?.displayname || basename(path)
+ },
+
+ getTo(dir: string, node?: Node): Record<string, unknown> {
+ if (dir === '/') {
+ return {
+ ...this.$route,
+ params: { view: this.currentView?.id },
+ query: {},
+ }
+ }
+ if (node === undefined) {
+ const view = this.views.find(view => view.params?.dir === dir)
+ return {
+ ...this.$route,
+ params: { fileid: view?.params?.fileid ?? '' },
+ query: { dir },
+ }
+ }
+ return {
+ ...this.$route,
+ params: { fileid: String(node.fileid) },
+ query: { dir: node.path },
+ }
+ },
+
+ onClick(to) {
+ if (to?.query?.dir === this.$route.query.dir) {
+ this.$emit('reload')
+ }
+ },
+
+ onDragOver(event: DragEvent, path: string) {
+ if (!event.dataTransfer) {
+ return
+ }
+
+ // Cannot drop on the current directory
+ if (path === this.dirs[this.dirs.length - 1]) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+
+ async onDrop(event: DragEvent, path: string) {
+ // skip if native drop like text drag and drop from files names
+ if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
+ return
+ }
+
+ // Do not stop propagation, so the main content
+ // drop event can be triggered too and clear the
+ // dragover state on the DragAndDropNotice component.
+ event.preventDefault()
+
+ // Caching the selection
+ const selection = this.draggingFiles
+ const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ const canDrop = (folder.permissions & Permission.CREATE) !== 0
+ const isCopy = event.ctrlKey
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (!canDrop || event.button !== 0) {
+ return
+ }
+
+ logger.debug('Dropped', { event, folder, selection, fileTree })
+
+ // Check whether we're uploading files
+ if (fileTree.contents.length > 0) {
+ await onDropExternalFiles(fileTree, folder, contents.contents)
+ return
+ }
+
+ // Else we're moving/copying files
+ const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
+ await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (selection.some(source => this.selectedFiles.includes(source))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ titleForSection(index, section) {
+ if (section?.to?.query?.dir === this.$route.query.dir) {
+ return t('files', 'Reload current directory')
+ } else if (index === 0) {
+ return t('files', 'Go to the "{dir}" directory', section)
+ }
+ return null
+ },
+
+ ariaForSection(section) {
+ if (section?.to?.query?.dir === this.$route.query.dir) {
+ return t('files', 'Reload current directory')
+ }
+ return null
+ },
+
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.files-list__breadcrumbs {
+ // Take as much space as possible
+ flex: 1 1 100% !important;
+ width: 100%;
+ height: 100%;
+ margin-block: 0;
+ margin-inline: 10px;
+ min-width: 0;
+
+ :deep() {
+ a {
+ cursor: pointer !important;
+ }
+ }
+
+ &--with-progress {
+ flex-direction: column !important;
+ align-items: flex-start !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue
new file mode 100644
index 00000000000..b08d3ba5ee5
--- /dev/null
+++ b/apps/files/src/components/CustomElementRender.vue
@@ -0,0 +1,54 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <span />
+</template>
+
+<script lang="ts">
+/**
+ * This component is used to render custom
+ * elements provided by an API. Vue doesn't allow
+ * to directly render an HTMLElement, so we can do
+ * this magic here.
+ */
+export default {
+ name: 'CustomElementRender',
+ props: {
+ source: {
+ type: Object,
+ required: true,
+ },
+ currentView: {
+ type: Object,
+ required: true,
+ },
+ render: {
+ type: Function,
+ required: true,
+ },
+ },
+ watch: {
+ source() {
+ this.updateRootElement()
+ },
+ currentView() {
+ this.updateRootElement()
+ },
+ },
+ mounted() {
+ this.updateRootElement()
+ },
+ methods: {
+ async updateRootElement() {
+ const element = await this.render(this.source, this.currentView)
+ if (element) {
+ this.$el.replaceChildren(element)
+ } else {
+ this.$el.replaceChildren()
+ }
+ },
+ },
+}
+</script>
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
new file mode 100644
index 00000000000..c7684d5c205
--- /dev/null
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -0,0 +1,262 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div v-show="dragover"
+ data-cy-files-drag-drop-area
+ class="files-list__drag-drop-notice"
+ @drop="onDrop">
+ <div class="files-list__drag-drop-notice-wrapper">
+ <template v-if="canUpload && !isQuotaExceeded">
+ <TrayArrowDownIcon :size="48" />
+ <h3 class="files-list-drag-drop-notice__title">
+ {{ t('files', 'Drag and drop files here to upload') }}
+ </h3>
+ </template>
+
+ <!-- Not permitted to drop files here -->
+ <template v-else>
+ <h3 class="files-list-drag-drop-notice__title">
+ {{ cantUploadLabel }}
+ </h3>
+ </template>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import type { Folder } from '@nextcloud/files'
+
+import { Permission } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { UploadStatus } from '@nextcloud/upload'
+import { defineComponent, type PropType } from 'vue'
+import debounce from 'debounce'
+
+import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
+
+import { useNavigation } from '../composables/useNavigation'
+import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+import logger from '../logger.ts'
+import type { RawLocation } from 'vue-router'
+
+export default defineComponent({
+ name: 'DragAndDropNotice',
+
+ components: {
+ TrayArrowDownIcon,
+ },
+
+ props: {
+ currentFolder: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+
+ return {
+ currentView,
+ }
+ },
+
+ data() {
+ return {
+ dragover: false,
+ }
+ },
+
+ computed: {
+ /**
+ * Check if the current folder has create permissions
+ */
+ canUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+ },
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ cantUploadLabel() {
+ if (this.isQuotaExceeded) {
+ return this.t('files', 'Your have used your space quota and cannot upload files anymore')
+ } else if (!this.canUpload) {
+ return this.t('files', 'You do not have permission to upload or create files here.')
+ }
+ return null
+ },
+
+ /**
+ * Debounced function to reset the drag over state
+ * Required as Firefox has a bug where no dragleave is emitted:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=656164
+ */
+ resetDragOver() {
+ return debounce(() => {
+ this.dragover = false
+ }, 3000)
+ },
+ },
+
+ mounted() {
+ // Add events on parent to cover both the table and DragAndDrop notice
+ const mainContent = window.document.getElementById('app-content-vue') as HTMLElement
+ mainContent.addEventListener('dragover', this.onDragOver)
+ mainContent.addEventListener('dragleave', this.onDragLeave)
+ mainContent.addEventListener('drop', this.onContentDrop)
+ },
+
+ beforeDestroy() {
+ const mainContent = window.document.getElementById('app-content-vue') as HTMLElement
+ mainContent.removeEventListener('dragover', this.onDragOver)
+ mainContent.removeEventListener('dragleave', this.onDragLeave)
+ mainContent.removeEventListener('drop', this.onContentDrop)
+ },
+
+ methods: {
+ onDragOver(event: DragEvent) {
+ // Needed to keep the drag/drop events chain working
+ event.preventDefault()
+
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ // Only handle uploading of outside files (not Nextcloud files)
+ this.dragover = true
+ this.resetDragOver()
+ }
+ },
+
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ // Avoid flickering
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) {
+ return
+ }
+
+ if (this.dragover) {
+ this.dragover = false
+ this.resetDragOver.clear()
+ }
+ },
+
+ onContentDrop(event: DragEvent) {
+ logger.debug('Drag and drop cancelled, dropped on empty space', { event })
+ event.preventDefault()
+ if (this.dragover) {
+ this.dragover = false
+ this.resetDragOver.clear()
+ }
+ },
+
+ async onDrop(event: DragEvent) {
+ // cantUploadLabel is null if we can upload
+ if (this.cantUploadLabel) {
+ showError(this.cantUploadLabel)
+ return
+ }
+
+ if (this.$el.querySelector('tbody')?.contains(event.target as Node)) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Caching the selection
+ const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(this.currentFolder.path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (event.button) {
+ return
+ }
+
+ logger.debug('Dropped', { event, folder, fileTree })
+
+ // Check whether we're uploading files
+ const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
+
+ // Scroll to last successful upload in current directory if terminated
+ const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
+ && !upload.file.webkitRelativePath.includes('/')
+ && upload.response?.headers?.['oc-fileid']
+ // Only use the last ID if it's in the current folder
+ && upload.source.replace(folder.source, '').split('/').length === 2)
+
+ if (lastUpload !== undefined) {
+ logger.debug('Scrolling to last upload in current folder', { lastUpload })
+ const location: RawLocation = {
+ path: this.$route.path,
+ // Keep params but change file id
+ params: {
+ ...this.$route.params,
+ fileid: String(lastUpload.response!.headers['oc-fileid']),
+ },
+ query: {
+ ...this.$route.query,
+ },
+ }
+ // Remove open file from query
+ delete location.query.openfile
+ this.$router.push(location)
+ }
+
+ this.dragover = false
+ this.resetDragOver.clear()
+ },
+
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.files-list__drag-drop-notice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ // Breadcrumbs height + row thead height
+ min-height: calc(58px + 44px);
+ margin: 0;
+ user-select: none;
+ color: var(--color-text-maxcontrast);
+ background-color: var(--color-main-background);
+ border-color: black;
+
+ h3 {
+ margin-inline-start: 16px;
+ color: inherit;
+ }
+
+ &-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 15vh;
+ max-height: 70%;
+ padding: 0 5vw;
+ border: 2px var(--color-border-dark) dashed;
+ border-radius: var(--border-radius-large);
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
new file mode 100644
index 00000000000..72fd98d43fb
--- /dev/null
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -0,0 +1,165 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="files-list-drag-image">
+ <span class="files-list-drag-image__icon">
+ <span ref="previewImg" />
+ <FolderIcon v-if="isSingleFolder" />
+ <FileMultipleIcon v-else />
+ </span>
+ <span class="files-list-drag-image__name">{{ name }}</span>
+ </div>
+</template>
+
+<script lang="ts">
+import { FileType, Node, formatFileSize } from '@nextcloud/files'
+import Vue from 'vue'
+
+import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+
+import { getSummaryFor } from '../utils/fileUtils.ts'
+
+export default Vue.extend({
+ name: 'DragAndDropPreview',
+
+ components: {
+ FileMultipleIcon,
+ FolderIcon,
+ },
+
+ data() {
+ return {
+ nodes: [] as Node[],
+ }
+ },
+
+ computed: {
+ isSingleNode() {
+ return this.nodes.length === 1
+ },
+ isSingleFolder() {
+ return this.isSingleNode
+ && this.nodes[0].type === FileType.Folder
+ },
+
+ name() {
+ if (!this.size) {
+ return this.summary
+ }
+ return `${this.summary} – ${this.size}`
+ },
+ size() {
+ const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0)
+ const size = parseInt(totalSize, 10) || 0
+ if (typeof size !== 'number' || size < 0) {
+ return null
+ }
+ return formatFileSize(size, true)
+ },
+ summary(): string {
+ if (this.isSingleNode) {
+ const node = this.nodes[0]
+ return node.attributes?.displayname || node.basename
+ }
+
+ return getSummaryFor(this.nodes)
+ },
+ },
+
+ methods: {
+ update(nodes: Node[]) {
+ this.nodes = nodes
+ this.$refs.previewImg.replaceChildren()
+
+ // Clone icon node from the list
+ nodes.slice(0, 3).forEach(node => {
+ const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`)
+ if (preview) {
+ const previewElmt = this.$refs.previewImg as HTMLElement
+ previewElmt.appendChild(preview.parentNode.cloneNode(true))
+ }
+ })
+
+ this.$nextTick(() => {
+ this.$emit('loaded', this.$el)
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+$size: 28px;
+$stack-shift: 6px;
+
+.files-list-drag-image {
+ position: absolute;
+ top: -9999px;
+ inset-inline-start: -9999px;
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ height: $size + $stack-shift;
+ padding: $stack-shift $stack-shift * 2;
+ background: var(--color-main-background);
+
+ &__icon,
+ .files-list__row-icon-preview-container {
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ justify-content: center;
+ width: $size - $stack-shift;
+ height: $size - $stack-shift;;
+ border-radius: var(--border-radius);
+ }
+
+ &__icon {
+ overflow: visible;
+ margin-inline-end: $stack-shift * 2;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ .material-design-icon {
+ color: var(--color-text-maxcontrast);
+ &.folder-icon {
+ color: var(--color-primary-element);
+ }
+ }
+
+ // Previews container
+ > span {
+ display: flex;
+
+ // Stack effect if more than one element
+ // Max 3 elements
+ > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container {
+ margin-top: $stack-shift;
+ margin-inline-start: $stack-shift * 2 - $size;
+ & + .files-list__row-icon-preview-container {
+ margin-top: $stack-shift * 2;
+ }
+ }
+
+ // If we have manually clone the preview,
+ // let's hide any fallback icons
+ &:not(:empty) + * {
+ display: none;
+ }
+ }
+ }
+
+ &__name {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
new file mode 100644
index 00000000000..d66c3fa0ed7
--- /dev/null
+++ b/apps/files/src/components/FileEntry.vue
@@ -0,0 +1,276 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <tr :class="{
+ 'files-list__row--dragover': dragover,
+ 'files-list__row--loading': isLoading,
+ 'files-list__row--active': isActive,
+ }"
+ data-cy-files-list-row
+ :data-cy-files-list-row-fileid="fileid"
+ :data-cy-files-list-row-name="source.basename"
+ :draggable="canDrag"
+ class="files-list__row"
+ v-on="rowListeners">
+ <!-- Failed indicator -->
+ <span v-if="isFailedSource" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes"
+ :source="source" />
+
+ <!-- Link to file -->
+ <td class="files-list__row-name" data-cy-files-list-row-name>
+ <!-- Icon or preview -->
+ <FileEntryPreview ref="preview"
+ :source="source"
+ :dragover="dragover"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :basename="basename"
+ :extension="extension"
+ :nodes="nodes"
+ :source="source"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+ </td>
+
+ <!-- Actions -->
+ <FileEntryActions v-show="!isRenamingSmallScreen"
+ ref="actions"
+ :class="`files-list__row-actions-${uniqueId}`"
+ :opened.sync="openedMenu"
+ :source="source" />
+
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ :title="mime"
+ class="files-list__row-mime"
+ data-cy-files-list-row-mime
+ @click="openDetailsIfAvailable">
+ <span>{{ mime }}</span>
+ </td>
+
+ <!-- Size -->
+ <td v-if="!compact && isSizeAvailable"
+ :style="sizeOpacity"
+ class="files-list__row-size"
+ data-cy-files-list-row-size
+ @click="openDetailsIfAvailable">
+ <span>{{ size }}</span>
+ </td>
+
+ <!-- Mtime -->
+ <td v-if="!compact && isMtimeAvailable"
+ :style="mtimeOpacity"
+ class="files-list__row-mtime"
+ data-cy-files-list-row-mtime
+ @click="openDetailsIfAvailable">
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
+ <span v-else>{{ t('files', 'Unknown date') }}</span>
+ </td>
+
+ <!-- View columns -->
+ <td v-for="column in columns"
+ :key="column.id"
+ :class="`files-list__row-${currentView.id}-${column.id}`"
+ class="files-list__row-column-custom"
+ :data-cy-files-list-row-column-custom="column.id"
+ @click="openDetailsIfAvailable">
+ <CustomElementRender :current-view="currentView"
+ :render="column.render"
+ :source="source" />
+ </td>
+ </tr>
+</template>
+
+<script lang="ts">
+import { FileType, formatFileSize } from '@nextcloud/files'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useDragAndDropStore } from '../store/dragging.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useRenamingStore } from '../store/renaming.ts'
+import { useSelectionStore } from '../store/selection.ts'
+
+import CustomElementRender from './CustomElementRender.vue'
+import FileEntryActions from './FileEntry/FileEntryActions.vue'
+import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryMixin from './FileEntryMixin.ts'
+import FileEntryName from './FileEntry/FileEntryName.vue'
+import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
+
+export default defineComponent({
+ name: 'FileEntry',
+
+ components: {
+ CustomElementRender,
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ NcDateTime,
+ },
+
+ mixins: [
+ FileEntryMixin,
+ ],
+
+ props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ const filesStore = useFilesStore()
+ const renamingStore = useRenamingStore()
+ const selectionStore = useSelectionStore()
+ const filesListWidth = useFileListWidth()
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const {
+ directory: currentDir,
+ fileId: currentFileId,
+ } = useRouteParameters()
+
+ return {
+ actionsMenuStore,
+ draggingStore,
+ filesStore,
+ renamingStore,
+ selectionStore,
+
+ currentDir,
+ currentFileId,
+ currentView,
+ filesListWidth,
+ }
+ },
+
+ computed: {
+ /**
+ * Conditionally add drag and drop listeners
+ * Do not add drag start and over listeners on renaming to allow to drag and drop text
+ */
+ rowListeners() {
+ const conditionals = this.isRenaming
+ ? {}
+ : {
+ dragstart: this.onDragStart,
+ dragover: this.onDragOver,
+ }
+
+ return {
+ ...conditionals,
+ contextmenu: this.onRightClick,
+ dragleave: this.onDragLeave,
+ dragend: this.onDragEnd,
+ drop: this.onDrop,
+ }
+ },
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512 || this.compact) {
+ return []
+ }
+ return this.currentView.columns || []
+ },
+
+ mime() {
+ if (this.source.type === FileType.Folder) {
+ return this.t('files', 'Folder')
+ }
+
+ if (!this.source.mime || this.source.mime === 'application/octet-stream') {
+ return t('files', 'Unknown file type')
+ }
+
+ if (window.OC?.MimeTypeList?.names?.[this.source.mime]) {
+ return window.OC.MimeTypeList.names[this.source.mime]
+ }
+
+ const baseType = this.source.mime.split('/')[0]
+ const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || ''
+ if (baseType === 'image') {
+ return t('files', '{ext} image', { ext })
+ }
+ if (baseType === 'video') {
+ return t('files', '{ext} video', { ext })
+ }
+ if (baseType === 'audio') {
+ return t('files', '{ext} audio', { ext })
+ }
+ if (baseType === 'text') {
+ return t('files', '{ext} text', { ext })
+ }
+
+ return this.source.mime
+ },
+ size() {
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return this.t('files', 'Pending')
+ }
+ return formatFileSize(size, true)
+ },
+
+ sizeOpacity() {
+ const maxOpacitySize = 10 * 1024 * 1024
+
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return {}
+ }
+
+ const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2)))
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
+ }
+ },
+ },
+
+ created() {
+ useHotKey('Enter', this.triggerDefaultAction, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ methods: {
+ formatFileSize,
+
+ triggerDefaultAction() {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
+ }
+
+ this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir)
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue
new file mode 100644
index 00000000000..e22b30f4378
--- /dev/null
+++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue
@@ -0,0 +1,45 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <span :aria-hidden="!title"
+ :aria-label="title"
+ class="material-design-icon collectives-icon"
+ role="img"
+ v-bind="$attrs"
+ @click="$emit('click', $event)">
+ <svg :fill="fillColor"
+ class="material-design-icon__svg"
+ :width="size"
+ :height="size"
+ viewBox="0 0 16 16">
+ <path d="M2.9,8.8c0-1.2,0.4-2.4,1.2-3.3L0.3,6c-0.2,0-0.3,0.3-0.1,0.4l2.7,2.6C2.9,9,2.9,8.9,2.9,8.8z" />
+ <path d="M8,3.7c0.7,0,1.3,0.1,1.9,0.4L8.2,0.6c-0.1-0.2-0.3-0.2-0.4,0L6.1,4C6.7,3.8,7.3,3.7,8,3.7z" />
+ <path d="M3.7,11.5L3,15.2c0,0.2,0.2,0.4,0.4,0.3l3.3-1.7C5.4,13.4,4.4,12.6,3.7,11.5z" />
+ <path d="M15.7,6l-3.7-0.5c0.7,0.9,1.2,2,1.2,3.3c0,0.1,0,0.2,0,0.3l2.7-2.6C15.9,6.3,15.9,6.1,15.7,6z" />
+ <path d="M12.3,11.5c-0.7,1.1-1.8,1.9-3,2.2l3.3,1.7c0.2,0.1,0.4-0.1,0.4-0.3L12.3,11.5z" />
+ <path d="M9.6,10.1c-0.4,0.5-1,0.8-1.6,0.8c-1.1,0-2-0.9-2.1-2C5.9,7.7,6.8,6.7,8,6.7c0.6,0,1.1,0.3,1.5,0.7 c0.1,0.1,0.1,0.1,0.2,0.1h1.4c0.2,0,0.4-0.2,0.3-0.5c-0.7-1.3-2.1-2.2-3.8-2.1C5.8,5,4.3,6.6,4.1,8.5C4,10.8,5.8,12.7,8,12.7 c1.6,0,2.9-0.9,3.5-2.3c0.1-0.2-0.1-0.4-0.3-0.4H9.9C9.8,10,9.7,10,9.6,10.1z" />
+ </svg>
+ </span>
+</template>
+
+<script>
+export default {
+ name: 'CollectivesIcon',
+ props: {
+ title: {
+ type: String,
+ default: '',
+ },
+ fillColor: {
+ type: String,
+ default: 'currentColor',
+ },
+ size: {
+ type: Number,
+ default: 24,
+ },
+ },
+}
+</script>
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
new file mode 100644
index 00000000000..c66cb8fbd7f
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -0,0 +1,76 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" />
+</template>
+
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import StarSvg from '@mdi/svg/svg/star.svg?raw'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+/**
+ * A favorite icon to be used for overlaying favorite entries like the file preview / icon
+ * It has a stroke around the star icon to ensure enough contrast for accessibility.
+ *
+ * If the background has a hover state you might want to also apply it to the stroke like this:
+ * ```scss
+ * .parent:hover :deep(.favorite-marker-icon svg path) {
+ * stroke: var(--color-background-hover);
+ * }
+ * ```
+ */
+export default defineComponent({
+ name: 'FavoriteIcon',
+ components: {
+ NcIconSvgWrapper,
+ },
+ data() {
+ return {
+ StarSvg,
+ }
+ },
+ async mounted() {
+ await this.$nextTick()
+ // MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it
+ const el = this.$el.querySelector('svg')
+ el?.setAttribute?.('viewBox', '-4 -4 30 30')
+ },
+ methods: {
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.favorite-marker-icon {
+ color: var(--color-favorite);
+ // Override NcIconSvgWrapper defaults (clickable area)
+ min-width: unset !important;
+ min-height: unset !important;
+
+ :deep() {
+ svg {
+ // We added a stroke for a11y so we must increase the size to include the stroke
+ width: 20px !important;
+ height: 20px !important;
+
+ // Override NcIconSvgWrapper defaults of 20px
+ max-width: unset !important;
+ max-height: unset !important;
+
+ // Sow a border around the icon for better contrast
+ path {
+ stroke: var(--color-main-background);
+ stroke-width: 8px;
+ stroke-linejoin: round;
+ paint-order: stroke;
+ }
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
new file mode 100644
index 00000000000..5c537d878fe
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -0,0 +1,399 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <td class="files-list__row-actions"
+ data-cy-files-list-row-actions>
+ <!-- Render actions -->
+ <CustomElementRender v-for="action in enabledRenderActions"
+ :key="action.id"
+ :class="'files-list__row-action-' + action.id"
+ :current-view="currentView"
+ :render="action.renderInline"
+ :source="source"
+ class="files-list__row-action--inline" />
+
+ <!-- Menu actions -->
+ <NcActions ref="actionsMenu"
+ :boundaries-element="getBoundariesElement"
+ :container="getBoundariesElement"
+ :force-name="true"
+ type="tertiary"
+ :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
+ :inline="enabledInlineActions.length"
+ :open="openedMenu"
+ @close="onMenuClose"
+ @closed="onMenuClosed">
+ <!-- Non-destructive actions list -->
+ <!-- Please keep this block in sync with the destructive actions block below -->
+ <NcActionButton v-for="action, index in renderedNonDestructiveActions"
+ :key="action.id"
+ :ref="`action-${action.id}`"
+ class="files-list__row-action"
+ :class="{
+ [`files-list__row-action-${action.id}`]: true,
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+
+ <!-- Destructive actions list -->
+ <template v-if="renderedDestructiveActions.length > 0">
+ <NcActionSeparator />
+ <NcActionButton v-for="action, index in renderedDestructiveActions"
+ :key="action.id"
+ :ref="`action-${action.id}`"
+ class="files-list__row-action"
+ :class="{
+ [`files-list__row-action-${action.id}`]: true,
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ 'files-list__row-action--destructive': true,
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+
+ <!-- Submenu actions list-->
+ <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
+ <!-- Back to top-level button -->
+ <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
+ <template #icon>
+ <ArrowLeftIcon />
+ </template>
+ {{ t('files', 'Back') }}
+ </NcActionButton>
+ <NcActionSeparator />
+
+ <!-- Submenu actions -->
+ <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
+ :key="action.id"
+ :class="`files-list__row-action-${action.id}`"
+ class="files-list__row-action--submenu"
+ close-after-click
+ :data-cy-files-list-row-action="action.id"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+ </td>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { FileAction, Node } from '@nextcloud/files'
+
+import { DefaultType, NodeStatus } from '@nextcloud/files'
+import { defineComponent, inject } from 'vue'
+import { t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+
+import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
+import CustomElementRender from '../CustomElementRender.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { executeAction } from '../../utils/actionUtils.ts'
+import { useActiveStore } from '../../store/active.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import actionsMixins from '../../mixins/actionsMixin.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryActions',
+
+ components: {
+ ArrowLeftIcon,
+ CustomElementRender,
+ NcActionButton,
+ NcActions,
+ NcActionSeparator,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ },
+
+ mixins: [actionsMixins],
+
+ props: {
+ opened: {
+ type: Boolean,
+ default: false,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory: currentDir } = useRouteParameters()
+
+ const activeStore = useActiveStore()
+ const filesListWidth = useFileListWidth()
+ const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
+ return {
+ activeStore,
+ currentDir,
+ currentView,
+ enabledFileActions,
+ filesListWidth,
+ t,
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.activeStore?.activeNode?.source === this.source.source
+ },
+
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ // Enabled action that are displayed inline
+ enabledInlineActions() {
+ if (this.filesListWidth < 768 || this.gridMode) {
+ return []
+ }
+ return this.enabledFileActions.filter(action => {
+ try {
+ return action?.inline?.(this.source, this.currentView)
+ } catch (error) {
+ logger.error('Error while checking if action is inline', { action, error })
+ return false
+ }
+ })
+ },
+
+ // Enabled action that are displayed inline with a custom render function
+ enabledRenderActions() {
+ if (this.gridMode) {
+ return []
+ }
+ return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
+ },
+
+ // Actions shown in the menu
+ enabledMenuActions() {
+ // If we're in a submenu, only render the inline
+ // actions before the filtered submenu
+ if (this.openedSubmenu) {
+ return this.enabledInlineActions
+ }
+
+ const actions = [
+ // Showing inline first for the NcActions inline prop
+ ...this.enabledInlineActions,
+ // Then the rest
+ ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
+ ].filter((value, index, self) => {
+ // Then we filter duplicates to prevent inline actions to be shown twice
+ return index === self.findIndex(action => action.id === value.id)
+ })
+
+ // Generate list of all top-level actions ids
+ const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[]
+
+ // Filter actions that are not top-level AND have a valid parent
+ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
+ },
+
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
+ },
+
+ openedMenu: {
+ get() {
+ return this.opened
+ },
+ set(value) {
+ this.$emit('update:opened', value)
+ },
+ },
+
+ /**
+ * Making this a function in case the files-list
+ * reference changes in the future. That way we're
+ * sure there is one at the time we call it.
+ */
+ getBoundariesElement() {
+ return document.querySelector('.app-content > .files-list')
+ },
+ },
+
+ watch: {
+ // Close any submenu when the menu state changes
+ openedMenu() {
+ this.openedSubmenu = null
+ },
+ },
+
+ created() {
+ useHotKey('Escape', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey('a', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ methods: {
+ actionDisplayName(action: FileAction) {
+ try {
+ if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
+ // if an inline action is rendered in the menu for
+ // lack of space we use the title first if defined
+ const title = action.title([this.source], this.currentView)
+ if (title) return title
+ }
+ return action.displayName([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ // Not ideal, but better than nothing
+ return action.id
+ }
+ },
+
+ isLoadingAction(action: FileAction) {
+ if (!this.isActive) {
+ return false
+ }
+ return this.activeStore?.activeAction?.id === action.id
+ },
+
+ async onActionClick(action) {
+ // If the action is a submenu, we open it
+ if (this.enabledSubmenuActions[action.id]) {
+ this.openedSubmenu = action
+ return
+ }
+
+ // Make sure we set the node as active
+ this.activeStore.activeNode = this.source
+
+ // Execute the action
+ await executeAction(action)
+ },
+
+ onKeyDown(event: KeyboardEvent) {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
+ }
+
+ // ESC close the action menu if opened
+ if (event.key === 'Escape' && this.openedMenu) {
+ this.openedMenu = false
+ }
+
+ // a open the action menu
+ if (event.key === 'a' && !this.openedMenu) {
+ this.openedMenu = true
+ }
+ },
+
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
+ this.openedSubmenu = null
+ },
+
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+// Allow right click to define the position of the menu
+// only if defined
+main.app-content[style*="mouse-pos-x"] .v-popper__popper {
+ transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important;
+
+ // If the menu is too close to the bottom, we move it up
+ &[data-popper-placement="top"] {
+ // 34px added to align with the top of the cursor
+ transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important;
+ }
+ // Hide arrow if floating
+ .v-popper__arrow-container {
+ display: none;
+ }
+}
+</style>
+
+<style scoped lang="scss">
+.files-list__row-action {
+ --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
+
+ // inline icons can have clickable area size so they still fit into the row
+ &.files-list__row-action--inline {
+ --max-icon-size: var(--default-clickable-area);
+ }
+
+ // Some icons exceed the default size so we need to enforce a max width and height
+ .files-list__row-action-icon :deep(svg) {
+ max-height: var(--max-icon-size) !important;
+ max-width: var(--max-icon-size) !important;
+ }
+
+ &.files-list__row-action--destructive {
+ ::deep(button) {
+ color: var(--color-error) !important;
+ }
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
new file mode 100644
index 00000000000..5b80a971118
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -0,0 +1,173 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <td class="files-list__row-checkbox"
+ @keyup.esc.exact="resetSelection">
+ <NcLoadingIcon v-if="isLoading" :name="loadingLabel" />
+ <NcCheckboxRadioSwitch v-else
+ :aria-label="ariaLabel"
+ :checked="isSelected"
+ data-cy-files-list-row-checkbox
+ @update:checked="onSelectionChange" />
+ </td>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../../types.ts'
+
+import { FileType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { useActiveStore } from '../../store/active.ts'
+import { useKeyboardStore } from '../../store/keyboard.ts'
+import { useSelectionStore } from '../../store/selection.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryCheckbox',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ },
+
+ props: {
+ fileid: {
+ type: Number,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ },
+
+ setup() {
+ const selectionStore = useSelectionStore()
+ const keyboardStore = useKeyboardStore()
+ const activeStore = useActiveStore()
+
+ return {
+ activeStore,
+ keyboardStore,
+ selectionStore,
+ t,
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.activeStore.activeNode?.source === this.source.source
+ },
+
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.source.source)
+ },
+ index() {
+ return this.nodes.findIndex((node: Node) => node.source === this.source.source)
+ },
+ isFile() {
+ return this.source.type === FileType.File
+ },
+ ariaLabel() {
+ return this.isFile
+ ? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename })
+ : t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename })
+ },
+ loadingLabel() {
+ return this.isFile
+ ? t('files', 'File is loading')
+ : t('files', 'Folder is loading')
+ },
+ },
+
+ created() {
+ // ctrl+space toggle selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ })
+
+ // ctrl+shift+space toggle range selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ shift: true,
+ })
+ },
+
+ methods: {
+ onSelectionChange(selected: boolean) {
+ const newSelectedIndex = this.index
+ const lastSelectedIndex = this.selectionStore.lastSelectedIndex
+
+ // Get the last selected and select all files in between
+ if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
+ const isAlreadySelected = this.selectedFiles.includes(this.source.source)
+
+ const start = Math.min(newSelectedIndex, lastSelectedIndex)
+ const end = Math.max(lastSelectedIndex, newSelectedIndex)
+
+ const lastSelection = this.selectionStore.lastSelection
+ const filesToSelect = this.nodes
+ .map(file => file.source)
+ .slice(start, end + 1)
+ .filter(Boolean) as FileSource[]
+
+ // If already selected, update the new selection _without_ the current file
+ const selection = [...lastSelection, ...filesToSelect]
+ .filter(source => !isAlreadySelected || source !== this.source.source)
+
+ logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
+ // Keep previous lastSelectedIndex to be use for further shift selections
+ this.selectionStore.set(selection)
+ return
+ }
+
+ const selection = selected
+ ? [...this.selectedFiles, this.source.source]
+ : this.selectedFiles.filter(source => source !== this.source.source)
+
+ logger.debug('Updating selection', { selection })
+ this.selectionStore.set(selection)
+ this.selectionStore.setLastIndex(newSelectedIndex)
+ },
+
+ resetSelection() {
+ this.selectionStore.reset()
+ },
+
+ onToggleSelect() {
+ // Don't react if the node is not active
+ if (!this.isActive) {
+ return
+ }
+
+ logger.debug('Toggling selection for file', { source: this.source })
+ this.onSelectionChange(!this.isSelected)
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue
new file mode 100644
index 00000000000..418f9581eb6
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -0,0 +1,288 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <!-- Rename input -->
+ <form v-if="isRenaming"
+ ref="renameForm"
+ v-on-click-outside="onRename"
+ :aria-label="t('files', 'Rename file')"
+ class="files-list__row-rename"
+ @submit.prevent.stop="onRename">
+ <NcTextField ref="renameInput"
+ :label="renameLabel"
+ :autofocus="true"
+ :minlength="1"
+ :required="true"
+ :value.sync="newName"
+ enterkeyhint="done"
+ @keyup.esc="stopRenaming" />
+ </form>
+
+ <component :is="linkTo.is"
+ v-else
+ ref="basename"
+ class="files-list__row-name-link"
+ data-cy-files-list-row-name-link
+ v-bind="linkTo.params">
+ <!-- Filename -->
+ <span class="files-list__row-name-text" dir="auto">
+ <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues-->
+ <span class="files-list__row-name-" v-text="basename" />
+ <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" />
+ </span>
+ </component>
+</template>
+
+<script lang="ts">
+import type { FileAction, Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { FileType, NodeStatus } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent, inject } from 'vue'
+
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import { getFilenameValidity } from '../../utils/filenameValidity.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation.ts'
+import { useRenamingStore } from '../../store/renaming.ts'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryName',
+
+ components: {
+ NcTextField,
+ },
+
+ props: {
+ /**
+ * The filename without extension
+ */
+ basename: {
+ type: String,
+ required: true,
+ },
+ /**
+ * The extension of the filename
+ */
+ extension: {
+ type: String,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory } = useRouteParameters()
+ const filesListWidth = useFileListWidth()
+ const renamingStore = useRenamingStore()
+ const userConfigStore = useUserConfigStore()
+
+ const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
+
+ return {
+ currentView,
+ defaultFileAction,
+ directory,
+ filesListWidth,
+
+ renamingStore,
+ userConfigStore,
+ }
+ },
+
+ computed: {
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+ newName: {
+ get(): string {
+ return this.renamingStore.newNodeName
+ },
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
+ },
+ },
+
+ renameLabel() {
+ const matchLabel: Record<FileType, string> = {
+ [FileType.File]: t('files', 'Filename'),
+ [FileType.Folder]: t('files', 'Folder name'),
+ }
+ return matchLabel[this.source.type]
+ },
+
+ linkTo() {
+ if (this.source.status === NodeStatus.FAILED) {
+ return {
+ is: 'span',
+ params: {
+ title: t('files', 'This node is unavailable'),
+ },
+ }
+ }
+
+ if (this.defaultFileAction) {
+ const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
+ return {
+ is: 'button',
+ params: {
+ 'aria-label': displayName,
+ title: displayName,
+ tabindex: '0',
+ },
+ }
+ }
+
+ // nothing interactive here, there is no default action
+ // so if not even the download action works we only can show the list entry
+ return {
+ is: 'span',
+ }
+ },
+ },
+
+ watch: {
+ /**
+ * If renaming starts, select the filename
+ * in the input, without the extension.
+ * @param renaming
+ */
+ isRenaming: {
+ immediate: true,
+ handler(renaming: boolean) {
+ if (renaming) {
+ this.startRenaming()
+ }
+ },
+ },
+
+ newName() {
+ // Check validity of the new name
+ const newName = this.newName.trim?.() || ''
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ return
+ }
+
+ let validity = getFilenameValidity(newName)
+ // Checking if already exists
+ if (validity === '' && this.checkIfNodeExists(newName)) {
+ validity = t('files', 'Another entry with the same name already exists.')
+ }
+ this.$nextTick(() => {
+ if (this.isRenaming) {
+ input.setCustomValidity(validity)
+ input.reportValidity()
+ }
+ })
+ },
+ },
+
+ methods: {
+ checkIfNodeExists(name: string) {
+ return this.nodes.find(node => node.basename === name && node !== this.source)
+ },
+
+ startRenaming() {
+ this.$nextTick(() => {
+ // Using split to get the true string length
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ logger.error('Could not find the rename input')
+ return
+ }
+ input.focus()
+ const length = this.source.basename.length - (this.source.extension ?? '').length
+ input.setSelectionRange(0, length)
+
+ // Trigger a keyup event to update the input validity
+ input.dispatchEvent(new Event('keyup'))
+ })
+ },
+
+ stopRenaming() {
+ if (!this.isRenaming) {
+ return
+ }
+
+ // Reset the renaming store
+ this.renamingStore.$reset()
+ },
+
+ // Rename and move the file
+ async onRename() {
+ const newName = this.newName.trim?.() || ''
+ const form = this.$refs.renameForm as HTMLFormElement
+ if (!form.checkValidity()) {
+ showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
+ return
+ }
+
+ const oldName = this.source.basename
+ if (newName === oldName) {
+ this.stopRenaming()
+ return
+ }
+
+ try {
+ const status = await this.renamingStore.rename()
+ if (status) {
+ showSuccess(
+ t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
+ )
+ this.$nextTick(() => {
+ const nameContainer = this.$refs.basename as HTMLElement | undefined
+ nameContainer?.focus()
+ })
+ } else {
+ // Was cancelled - meaning the renaming state is just reset
+ }
+ } catch (error) {
+ logger.error(error as Error)
+ showError((error as Error).message)
+ // And ensure we reset to the renaming state
+ this.startRenaming()
+ }
+ },
+
+ t,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+button.files-list__row-name-link {
+ background-color: unset;
+ border: none;
+ font-weight: normal;
+
+ &:active {
+ // No active styles - handled by the row entry
+ background-color: unset !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
new file mode 100644
index 00000000000..3d0fffe7584
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -0,0 +1,300 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <span class="files-list__row-icon">
+ <template v-if="source.type === 'folder'">
+ <FolderOpenIcon v-if="dragover" v-once />
+ <template v-else>
+ <FolderIcon v-once />
+ <OverlayIcon :is="folderOverlay"
+ v-if="folderOverlay"
+ class="files-list__row-icon-overlay" />
+ </template>
+ </template>
+
+ <!-- Decorative images, should not be aria documented -->
+ <span v-else-if="previewUrl" class="files-list__row-icon-preview-container">
+ <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
+ ref="canvas"
+ class="files-list__row-icon-blurhash"
+ aria-hidden="true" />
+ <img v-if="backgroundFailed !== true"
+ :key="source.fileid"
+ ref="previewImg"
+ alt=""
+ class="files-list__row-icon-preview"
+ :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
+ loading="lazy"
+ :src="previewUrl"
+ @error="onBackgroundError"
+ @load="onBackgroundLoad">
+ </span>
+
+ <FileIcon v-else v-once />
+
+ <!-- Favorite icon -->
+ <span v-if="isFavorite" class="files-list__row-icon-favorite">
+ <FavoriteIcon v-once />
+ </span>
+
+ <OverlayIcon :is="fileOverlay"
+ v-if="fileOverlay"
+ class="files-list__row-icon-overlay files-list__row-icon-overlay--file" />
+ </span>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { UserConfig } from '../../types.ts'
+
+import { Node, 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 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'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
+import KeyIcon from 'vue-material-design-icons/Key.vue'
+import LinkIcon from 'vue-material-design-icons/Link.vue'
+import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue'
+import TagIcon from 'vue-material-design-icons/Tag.vue'
+import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
+
+import CollectivesIcon from './CollectivesIcon.vue'
+import FavoriteIcon from './FavoriteIcon.vue'
+
+import { isLivePhoto } from '../../services/LivePhotos'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryPreview',
+
+ components: {
+ AccountGroupIcon,
+ AccountPlusIcon,
+ CollectivesIcon,
+ FavoriteIcon,
+ FileIcon,
+ FolderIcon,
+ FolderOpenIcon,
+ KeyIcon,
+ LinkIcon,
+ NetworkIcon,
+ TagIcon,
+ },
+
+ props: {
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ dragover: {
+ type: Boolean,
+ default: false,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const userConfigStore = useUserConfigStore()
+ const isPublic = isPublicShare()
+ const publicSharingToken = getSharingToken()
+
+ return {
+ userConfigStore,
+
+ isPublic,
+ publicSharingToken,
+ }
+ },
+
+ data() {
+ return {
+ backgroundFailed: undefined as boolean | undefined,
+ backgroundLoaded: false,
+ }
+ },
+
+ computed: {
+ isFavorite(): boolean {
+ return this.source.attributes.favorite === 1
+ },
+
+ userConfig(): UserConfig {
+ 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 (e) {
+ return null
+ }
+ },
+
+ fileOverlay() {
+ if (isLivePhoto(this.source)) {
+ return PlayCircleIcon
+ }
+
+ return null
+ },
+
+ folderOverlay() {
+ if (this.source.type !== FileType.Folder) {
+ return null
+ }
+
+ // Encrypted folders
+ if (this.source?.attributes?.['is-encrypted'] === 1) {
+ return KeyIcon
+ }
+
+ // System tags
+ if (this.source?.attributes?.['is-tag']) {
+ return TagIcon
+ }
+
+ // Link and mail shared folders
+ const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
+ if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
+ return LinkIcon
+ }
+
+ // Shared folders
+ if (shareTypes.length > 0) {
+ return AccountPlusIcon
+ }
+
+ switch (this.source?.attributes?.['mount-type']) {
+ case 'external':
+ case 'external-session':
+ return NetworkIcon
+ case 'group':
+ return AccountGroupIcon
+ case 'collective':
+ return CollectivesIcon
+ case 'shared':
+ return AccountPlusIcon
+ }
+
+ return null
+ },
+
+ hasBlurhash() {
+ return this.source.attributes['metadata-blurhash'] !== undefined
+ },
+ },
+
+ mounted() {
+ if (this.hasBlurhash && this.$refs.canvas) {
+ this.drawBlurhash()
+ }
+ },
+
+ methods: {
+ // Called from FileEntry
+ reset() {
+ // Reset background state to cancel any ongoing requests
+ this.backgroundFailed = undefined
+ this.backgroundLoaded = false
+ const previewImg = this.$refs.previewImg as HTMLImageElement | undefined
+ if (previewImg) {
+ previewImg.src = ''
+ }
+ },
+
+ onBackgroundLoad() {
+ this.backgroundFailed = false
+ this.backgroundLoaded = true
+ },
+
+ onBackgroundError(event) {
+ // Do not fail if we just reset the background
+ if (event.target?.src === '') {
+ return
+ }
+ this.backgroundFailed = true
+ this.backgroundLoaded = false
+ },
+
+ drawBlurhash() {
+ const canvas = this.$refs.canvas as HTMLCanvasElement
+
+ const width = canvas.width
+ const height = canvas.height
+
+ const pixels = decode(this.source.attributes['metadata-blurhash'], width, height)
+
+ const ctx = canvas.getContext('2d')
+ if (ctx === null) {
+ logger.error('Cannot create context for blurhash canvas')
+ return
+ }
+
+ const imageData = ctx.createImageData(width, height)
+ imageData.data.set(pixels)
+ ctx.putImageData(imageData, 0, 0)
+ },
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
new file mode 100644
index 00000000000..1bd0572f53b
--- /dev/null
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -0,0 +1,135 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+ data-cy-files-list-row
+ :data-cy-files-list-row-fileid="fileid"
+ :data-cy-files-list-row-name="source.basename"
+ :draggable="canDrag"
+ class="files-list__row"
+ @contextmenu="onRightClick"
+ @dragover="onDragOver"
+ @dragleave="onDragLeave"
+ @dragstart="onDragStart"
+ @dragend="onDragEnd"
+ @drop="onDrop">
+ <!-- Failed indicator -->
+ <span v-if="isFailedSource" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes"
+ :source="source" />
+
+ <!-- Link to file -->
+ <td class="files-list__row-name" data-cy-files-list-row-name>
+ <!-- Icon or preview -->
+ <FileEntryPreview ref="preview"
+ :dragover="dragover"
+ :grid-mode="true"
+ :source="source"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :basename="basename"
+ :extension="extension"
+ :grid-mode="true"
+ :nodes="nodes"
+ :source="source"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+ </td>
+
+ <!-- Mtime -->
+ <td v-if="!compact && isMtimeAvailable"
+ :style="mtimeOpacity"
+ class="files-list__row-mtime"
+ data-cy-files-list-row-mtime
+ @click="openDetailsIfAvailable">
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
+ </td>
+
+ <!-- Actions -->
+ <FileEntryActions ref="actions"
+ :class="`files-list__row-actions-${uniqueId}`"
+ :grid-mode="true"
+ :opened.sync="openedMenu"
+ :source="source" />
+ </tr>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useDragAndDropStore } from '../store/dragging.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useRenamingStore } from '../store/renaming.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import FileEntryMixin from './FileEntryMixin.ts'
+import FileEntryActions from './FileEntry/FileEntryActions.vue'
+import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryName from './FileEntry/FileEntryName.vue'
+import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
+
+export default defineComponent({
+ name: 'FileEntryGrid',
+
+ components: {
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ NcDateTime,
+ },
+
+ mixins: [
+ FileEntryMixin,
+ ],
+
+ inheritAttrs: false,
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ const filesStore = useFilesStore()
+ const renamingStore = useRenamingStore()
+ const selectionStore = useSelectionStore()
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const {
+ directory: currentDir,
+ fileId: currentFileId,
+ } = useRouteParameters()
+
+ return {
+ actionsMenuStore,
+ draggingStore,
+ filesStore,
+ renamingStore,
+ selectionStore,
+
+ currentDir,
+ currentFileId,
+ currentView,
+ }
+ },
+
+ data() {
+ return {
+ gridMode: true,
+ }
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
new file mode 100644
index 00000000000..735490c45b3
--- /dev/null
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -0,0 +1,509 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { PropType } from 'vue'
+import type { FileSource } from '../types.ts'
+
+import { extname } from 'path'
+import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { isPublicShare } from '@nextcloud/sharing/public'
+import { showError } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import { vOnClickOutside } from '@vueuse/components'
+import Vue, { computed, defineComponent } from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
+import { getDragAndDropPreview } from '../utils/dragUtils.ts'
+import { hashCode } from '../utils/hashUtils.ts'
+import { isDownloadable } from '../utils/permissions.ts'
+import logger from '../logger.ts'
+
+Vue.directive('onClickOutside', vOnClickOutside)
+
+const actions = getFileActions()
+
+export default defineComponent({
+ props: {
+ source: {
+ type: [Folder, NcFile, Node] as PropType<Node>,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ compact: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ provide() {
+ return {
+ defaultFileAction: computed(() => this.defaultFileAction),
+ enabledFileActions: computed(() => this.enabledFileActions),
+ }
+ },
+
+ data() {
+ return {
+ dragover: false,
+ gridMode: false,
+ }
+ },
+
+ computed: {
+ fileid() {
+ return this.source.fileid ?? 0
+ },
+
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ /**
+ * The display name of the current node
+ * Either the nodes filename or a custom display name (e.g. for shares)
+ */
+ displayName() {
+ // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0
+ return this.source.displayname || this.source.basename
+ },
+ /**
+ * The display name without extension
+ */
+ basename() {
+ if (this.extension === '') {
+ return this.displayName
+ }
+ return this.displayName.slice(0, 0 - this.extension.length)
+ },
+ /**
+ * The extension of the file
+ */
+ extension() {
+ if (this.source.type === FileType.Folder) {
+ return ''
+ }
+
+ return extname(this.displayName)
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging as FileSource[]
+ },
+ selectedFiles() {
+ return this.selectionStore.selected as FileSource[]
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.source.source)
+ },
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+
+ isActive() {
+ return String(this.fileid) === String(this.currentFileId)
+ },
+
+ /**
+ * Check if the source is in a failed state after an API request
+ */
+ isFailedSource() {
+ return this.source.status === NodeStatus.FAILED
+ },
+
+ canDrag(): boolean {
+ if (this.isRenaming) {
+ return false
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
+ const canDrag = (node: Node): boolean => {
+ return (node?.permissions & Permission.UPDATE) !== 0
+ }
+
+ // If we're dragging a selection, we need to check all files
+ if (this.selectedFiles.length > 0) {
+ const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[]
+ return nodes.every(canDrag)
+ }
+ return canDrag(this.source)
+ },
+
+ canDrop(): boolean {
+ if (this.source.type !== FileType.Folder) {
+ return false
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
+ // If the current folder is also being dragged, we can't drop it on itself
+ if (this.draggingFiles.includes(this.source.source)) {
+ return false
+ }
+
+ return (this.source.permissions & Permission.CREATE) !== 0
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId.toString()
+ },
+ set(opened) {
+ // If the menu is opened on another file entry, we ignore closed events
+ if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) {
+ return
+ }
+
+ // If opened, we specify the current file id
+ // else we set it to null to close the menu
+ this.actionsMenuStore.opened = opened
+ ? this.uniqueId.toString()
+ : null
+ },
+ },
+
+ mtime() {
+ // If the mtime is not a valid date, return it as is
+ if (this.source.mtime && !isNaN(this.source.mtime.getDate())) {
+ return this.source.mtime
+ }
+
+ if (this.source.crtime && !isNaN(this.source.crtime.getDate())) {
+ return this.source.crtime
+ }
+
+ return null
+ },
+
+ mtimeOpacity() {
+ if (!this.mtime) {
+ return {}
+ }
+
+ // The time when we start reducing the opacity
+ const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
+ // everything older than the maxOpacityTime will have the same value
+ const timeDiff = Date.now() - this.mtime.getTime()
+ if (timeDiff < 0) {
+ // this means we have an invalid mtime which is in the future!
+ return {}
+ }
+
+ // inversed time difference from 0 to maxOpacityTime (which would mean today)
+ const opacityTime = Math.max(0, maxOpacityTime - timeDiff)
+ // 100 = today, 0 = 31 days ago or older
+ const percentage = Math.round(opacityTime * 100 / maxOpacityTime)
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`,
+ }
+ },
+
+ /**
+ * Sorted actions that are enabled for this node
+ */
+ enabledFileActions() {
+ if (this.source.status === NodeStatus.FAILED) {
+ return []
+ }
+
+ return actions
+ .filter(action => {
+ if (!action.enabled) {
+ return true
+ }
+
+ // In case something goes wrong, since we don't want to break
+ // the entire list, we filter out actions that throw an error.
+ try {
+ return action.enabled([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while checking action', { action, error })
+ return false
+ }
+ })
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ defaultFileAction() {
+ return this.enabledFileActions.find((action) => action.default !== undefined)
+ },
+ },
+
+ watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ * @param newSource The new value of the source prop
+ * @param oldSource The previous value
+ */
+ source(newSource: Node, oldSource: Node) {
+ if (newSource.source !== oldSource.source) {
+ this.resetState()
+ }
+ },
+
+ openedMenu() {
+ // Checking if the menu is really closed and not
+ // just a change in the open state to another file entry.
+ if (this.actionsMenuStore.opened === null) {
+ // Reset any right menu position potentially set
+ logger.debug('All actions menu closed, resetting right menu position...')
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ if (root !== null) {
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
+ }
+ },
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ resetState() {
+ // Reset the preview state
+ this.$refs?.preview?.reset?.()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // Ignore right click if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
+ // The grid mode is compact enough to not care about
+ // the actions menu mouse position
+ if (!this.gridMode) {
+ // Actions menu is contained within the app content
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ const contentRect = root.getBoundingClientRect()
+ // Using Math.min/max to prevent the menu from going out of the AppContent
+ // 200 = max width of the menu
+ logger.debug('Setting actions menu position...')
+ root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px')
+ root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px')
+ } else {
+ // Reset any right menu position potentially set
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId.toString()
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ execDefaultAction(event: MouseEvent) {
+ // Ignore click if we are renaming
+ if (this.isRenaming) {
+ return
+ }
+
+ // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4)
+ if (Boolean(event.button & 2) || event.button > 4) {
+ return
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
+ // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab
+ // also if there is no default action use this as a fallback
+ const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1
+ if (metaKeyPressed || !this.defaultFileAction) {
+ // If no download permission, then we can not allow to download (direct link) the files
+ if (isPublicShare() && !isDownloadable(this.source)) {
+ return
+ }
+
+ const url = isPublicShare()
+ ? this.source.encodedSource
+ : generateUrl('/f/{fileId}', { fileId: this.fileid })
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Open the file in a new tab if the meta key or the middle mouse button is clicked
+ window.open(url, metaKeyPressed ? '_blank' : '_self')
+ return
+ }
+
+ // every special case handled so just execute the default action
+ event.preventDefault()
+ event.stopPropagation()
+ // Execute the first default action if any
+ this.defaultFileAction.exec(this.source, this.currentView, this.currentDir)
+ },
+
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ onDragOver(event: DragEvent) {
+ this.dragover = this.canDrop
+ if (!this.canDrop) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ return
+ }
+
+ this.dragover = false
+ },
+
+ async onDragStart(event: DragEvent) {
+ event.stopPropagation()
+ if (!this.canDrag || !this.fileid) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
+ logger.debug('Drag started', { event })
+
+ // Make sure that we're not dragging a file like the preview
+ event.dataTransfer?.clearData?.()
+
+ // Reset any renaming
+ this.renamingStore.$reset()
+
+ // Dragging set of files, if we're dragging a file
+ // that is already selected, we use the entire selection
+ if (this.selectedFiles.includes(this.source.source)) {
+ this.draggingStore.set(this.selectedFiles)
+ } else {
+ this.draggingStore.set([this.source.source])
+ }
+
+ const nodes = this.draggingStore.dragging
+ .map(source => this.filesStore.getNode(source)) as Node[]
+
+ const image = await getDragAndDropPreview(nodes)
+ event.dataTransfer?.setDragImage(image, -10, -10)
+ },
+ onDragEnd() {
+ this.draggingStore.reset()
+ this.dragover = false
+ logger.debug('Drag ended')
+ },
+
+ async onDrop(event: DragEvent) {
+ // skip if native drop like text drag and drop from files names
+ if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Caching the selection
+ const selection = this.draggingFiles
+ const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(this.source.path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (!this.canDrop || event.button) {
+ return
+ }
+
+ const isCopy = event.ctrlKey
+ this.dragover = false
+
+ logger.debug('Dropped', { event, folder, selection, fileTree })
+
+ // Check whether we're uploading files
+ if (selection.length === 0 && fileTree.contents.length > 0) {
+ await onDropExternalFiles(fileTree, folder, contents.contents)
+ return
+ }
+
+ // Else we're moving/copying files
+ const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
+ await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (selection.some(source => this.selectedFiles.includes(source))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ t,
+ },
+})
diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue
new file mode 100644
index 00000000000..bd3ac867ed5
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilter.vue
@@ -0,0 +1,53 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcActions force-menu
+ :type="isActive ? 'secondary' : 'tertiary'"
+ :menu-name="filterName">
+ <template #icon>
+ <slot name="icon" />
+ </template>
+ <slot />
+
+ <template v-if="isActive">
+ <NcActionSeparator />
+ <NcActionButton class="files-list-filter__clear-button"
+ close-after-click
+ @click="$emit('reset-filter')">
+ {{ t('files', 'Clear filter') }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+
+defineProps<{
+ isActive: boolean
+ filterName: string
+}>()
+
+defineEmits<{
+ (event: 'reset-filter'): void
+}>()
+</script>
+
+<style scoped>
+.files-list-filter__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+}
+
+:deep(.button-vue) {
+ font-weight: normal !important;
+
+ * {
+ font-weight: normal !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
new file mode 100644
index 00000000000..3a843b2bc3e
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
@@ -0,0 +1,107 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter :is-active="isActive"
+ :filter-name="t('files', 'Modified')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCalendarRangeOutline" />
+ </template>
+ <NcActionButton v-for="preset of timePresets"
+ :key="preset.id"
+ type="radio"
+ close-after-click
+ :model-value.sync="selectedOption"
+ :value="preset.id">
+ {{ preset.label }}
+ </NcActionButton>
+ <!-- TODO: Custom time range -->
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
+
+import { mdiCalendarRangeOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ timePresets: {
+ type: Array as PropType<ITimePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ // icons used in template
+ mdiCalendarRangeOutline,
+ }
+ },
+
+ data() {
+ return {
+ selectedOption: null as string | null,
+ timeRangeEnd: null as number | null,
+ timeRangeStart: null as number | null,
+ }
+ },
+
+ computed: {
+ /**
+ * Is the filter currently active
+ */
+ isActive() {
+ return this.selectedOption !== null
+ },
+
+ currentPreset() {
+ return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
+ },
+ },
+
+ watch: {
+ selectedOption() {
+ if (this.selectedOption === null) {
+ this.$emit('update:preset')
+ } else {
+ const preset = this.currentPreset
+ this.$emit('update:preset', preset)
+ }
+ },
+ },
+
+ methods: {
+ t,
+
+ resetFilter() {
+ this.selectedOption = null
+ this.timeRangeEnd = null
+ this.timeRangeStart = null
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list-filter-time {
+ &__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
new file mode 100644
index 00000000000..938be171f6d
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
@@ -0,0 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcButton v-show="isVisible" @click="onClick">
+ {{ t('files', 'Search everywhere') }}
+ </NcButton>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import { getPinia } from '../../store/index.ts'
+import { useSearchStore } from '../../store/search.ts'
+
+const isVisible = ref(false)
+
+defineExpose({
+ hideButton,
+ showButton,
+})
+
+/**
+ * Hide the button - called by the filter class
+ */
+function hideButton() {
+ isVisible.value = false
+}
+
+/**
+ * Show the button - called by the filter class
+ */
+function showButton() {
+ isVisible.value = true
+}
+
+/**
+ * Button click handler to make the filtering a global search.
+ */
+function onClick() {
+ const searchStore = useSearchStore(getPinia())
+ searchStore.scope = 'globally'
+}
+</script>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue
new file mode 100644
index 00000000000..d3ad791513f
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue
@@ -0,0 +1,122 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter class="file-list-filter-type"
+ :is-active="isActive"
+ :filter-name="t('files', 'Type')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiFileOutline" />
+ </template>
+ <NcActionButton v-for="fileType of typePresets"
+ :key="fileType.id"
+ type="checkbox"
+ :model-value="selectedOptions.includes(fileType)"
+ @click="toggleOption(fileType)">
+ <template #icon>
+ <NcIconSvgWrapper :svg="fileType.icon" />
+ </template>
+ {{ fileType.label }}
+ </NcActionButton>
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITypePreset } from '../../filters/TypeFilter.ts'
+
+import { mdiFileOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ name: 'FileListFilterType',
+
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ presets: {
+ type: Array as PropType<ITypePreset[]>,
+ default: () => [],
+ },
+ typePresets: {
+ type: Array as PropType<ITypePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ mdiFileOutline,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ selectedOptions: [] as ITypePreset[],
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.selectedOptions.length > 0
+ },
+ },
+
+ watch: {
+ /** Reset selected options if property is changed */
+ presets() {
+ this.selectedOptions = this.presets ?? []
+ },
+ selectedOptions(newValue, oldValue) {
+ if (this.selectedOptions.length === 0) {
+ if (oldValue.length !== 0) {
+ this.$emit('update:presets')
+ }
+ } else {
+ this.$emit('update:presets', this.selectedOptions)
+ }
+ },
+ },
+
+ mounted() {
+ this.selectedOptions = this.presets ?? []
+ },
+
+ methods: {
+ resetFilter() {
+ this.selectedOptions = []
+ },
+
+ /**
+ * Toggle option from selected option
+ * @param option The option to toggle
+ */
+ toggleOption(option: ITypePreset) {
+ const idx = this.selectedOptions.indexOf(option)
+ if (idx !== -1) {
+ this.selectedOptions.splice(idx, 1)
+ } else {
+ this.selectedOptions.push(option)
+ }
+ },
+ },
+})
+</script>
+
+<style>
+.file-list-filter-type {
+ max-width: 220px;
+}
+</style>
diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue
new file mode 100644
index 00000000000..7f0d71fd85a
--- /dev/null
+++ b/apps/files/src/components/FileListFilters.vue
@@ -0,0 +1,74 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="file-list-filters">
+ <div class="file-list-filters__filter" data-cy-files-filters>
+ <span v-for="filter of visualFilters"
+ :key="filter.id"
+ ref="filterElements" />
+ </div>
+ <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')">
+ <li v-for="(chip, index) of activeChips" :key="index">
+ <NcChip :aria-label-close="t('files', 'Remove filter')"
+ :icon-svg="chip.icon"
+ :text="chip.text"
+ @close="chip.onclick">
+ <template v-if="chip.user" #icon>
+ <NcAvatar disable-menu
+ :show-user-status="false"
+ :size="24"
+ :user="chip.user" />
+ </template>
+ </NcChip>
+ </li>
+ </ul>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { computed, ref, watchEffect } from 'vue'
+import { useFiltersStore } from '../store/filters.ts'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcChip from '@nextcloud/vue/components/NcChip'
+
+const filterStore = useFiltersStore()
+const visualFilters = computed(() => filterStore.filtersWithUI)
+const activeChips = computed(() => filterStore.activeChips)
+
+const filterElements = ref<HTMLElement[]>([])
+watchEffect(() => {
+ filterElements.value
+ .forEach((el, index) => visualFilters.value[index].mount(el))
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filters {
+ display: flex;
+ flex-direction: column;
+ gap: var(--default-grid-baseline);
+ height: 100%;
+ width: 100%;
+
+ &__filter {
+ display: flex;
+ align-items: start;
+ justify-content: start;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+
+ > * {
+ flex: 0 1 fit-content;
+ }
+ }
+
+ &__active {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
new file mode 100644
index 00000000000..31458398028
--- /dev/null
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -0,0 +1,100 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div v-show="enabled" :class="`files-list__header-${header.id}`">
+ <span ref="mount" />
+ </div>
+</template>
+
+<script lang="ts">
+import type { Folder, Header, View } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import PQueue from 'p-queue'
+
+import logger from '../logger.ts'
+
+/**
+ * This component is used to render custom
+ * elements provided by an API. Vue doesn't allow
+ * to directly render an HTMLElement, so we can do
+ * this magic here.
+ */
+export default {
+ name: 'FilesListHeader',
+ props: {
+ header: {
+ type: Object as PropType<Header>,
+ required: true,
+ },
+ currentFolder: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ currentView: {
+ type: Object as PropType<View>,
+ required: true,
+ },
+ },
+ setup() {
+ // Create a queue to ensure that the header is only rendered once at a time
+ const queue = new PQueue({ concurrency: 1 })
+
+ return {
+ queue,
+ }
+ },
+ computed: {
+ enabled() {
+ return this.header.enabled?.(this.currentFolder, this.currentView) ?? true
+ },
+ },
+ watch: {
+ enabled(enabled) {
+ if (!enabled) {
+ return
+ }
+ // If the header is enabled, we need to render it
+ logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header })
+ this.queueUpdate(this.currentFolder, this.currentView)
+ },
+ currentFolder(folder: Folder) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queueUpdate(folder, this.currentView)
+ },
+ currentView(view: View) {
+ this.queueUpdate(this.currentFolder, view)
+ },
+ },
+
+ mounted() {
+ logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header })
+ const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView)
+ this.queue.add(initialRender).then(() => {
+ logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header })
+ }).catch((error) => {
+ logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
+ destroyed() {
+ logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header })
+ },
+
+ methods: {
+ queueUpdate(currentFolder: Folder, currentView: View) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queue.add(() => this.header.updated(currentFolder, currentView))
+ .then(() => {
+ logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header })
+ })
+ .catch((error) => {
+ logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
+ },
+}
+</script>
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
new file mode 100644
index 00000000000..9e8cdc159ee
--- /dev/null
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -0,0 +1,164 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <tr>
+ <th class="files-list__row-checkbox">
+ <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table -->
+ <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
+ </th>
+
+ <!-- Link to file -->
+ <td class="files-list__row-name">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Summary -->
+ <span>{{ summary }}</span>
+ </td>
+
+ <!-- Actions -->
+ <td class="files-list__row-actions" />
+
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime" />
+
+ <!-- Size -->
+ <td v-if="isSizeAvailable"
+ class="files-list__column files-list__row-size">
+ <span>{{ totalSize }}</span>
+ </td>
+
+ <!-- Mtime -->
+ <td v-if="isMtimeAvailable"
+ class="files-list__column files-list__row-mtime" />
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)">
+ <span>{{ column.summary?.(nodes, currentView) }}</span>
+ </th>
+ </tr>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { View, formatFileSize } from '@nextcloud/files'
+import { translate } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+
+export default defineComponent({
+ name: 'FilesListTableFooter',
+
+ props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ summary: {
+ type: String,
+ default: '',
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const pathsStore = usePathsStore()
+ const filesStore = useFilesStore()
+ const { directory } = useRouteParameters()
+ return {
+ filesStore,
+ pathsStore,
+ directory,
+ }
+ },
+
+ computed: {
+ currentFolder() {
+ if (!this.currentView?.id) {
+ return
+ }
+
+ if (this.directory === '/') {
+ return this.filesStore.getRoot(this.currentView.id)
+ }
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)!
+ return this.filesStore.getNode(fileId)
+ },
+
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ totalSize() {
+ // If we have the size already, let's use it
+ if (this.currentFolder?.size) {
+ return formatFileSize(this.currentFolder.size, true)
+ }
+
+ // Otherwise let's compute it
+ return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true)
+ },
+ },
+
+ methods: {
+ classForColumn(column) {
+ return {
+ 'files-list__row-column-custom': true,
+ [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+// Scoped row
+tr {
+ margin-bottom: var(--body-container-margin);
+ border-top: 1px solid var(--color-border);
+ // Prevent hover effect on the whole row
+ background-color: transparent !important;
+ border-bottom: none !important;
+
+ td {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
new file mode 100644
index 00000000000..23e631199eb
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -0,0 +1,237 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <tr class="files-list__row-head">
+ <th class="files-list__column files-list__row-checkbox"
+ @keyup.esc.exact="resetSelection">
+ <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" />
+ </th>
+
+ <!-- Columns display -->
+
+ <!-- Link to file -->
+ <th class="files-list__column files-list__row-name files-list__column--sortable"
+ :aria-sort="ariaSortForMode('basename')">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Name -->
+ <FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" />
+ </th>
+
+ <!-- Actions -->
+ <th class="files-list__row-actions" />
+
+ <!-- Mime -->
+ <th v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime"
+ :class="{ 'files-list__column--sortable': isMimeAvailable }"
+ :aria-sort="ariaSortForMode('mime')">
+ <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" />
+ </th>
+
+ <!-- Size -->
+ <th v-if="isSizeAvailable"
+ class="files-list__column files-list__row-size"
+ :class="{ 'files-list__column--sortable': isSizeAvailable }"
+ :aria-sort="ariaSortForMode('size')">
+ <FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" />
+ </th>
+
+ <!-- Mtime -->
+ <th v-if="isMtimeAvailable"
+ class="files-list__column files-list__row-mtime"
+ :class="{ 'files-list__column--sortable': isMtimeAvailable }"
+ :aria-sort="ariaSortForMode('mtime')">
+ <FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" />
+ </th>
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)"
+ :aria-sort="ariaSortForMode(column.id)">
+ <FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
+ <span v-else>
+ {{ column.title }}
+ </span>
+ </th>
+ </tr>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../types.ts'
+
+import { translate as t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+import { useFilesStore } from '../store/files.ts'
+import { useNavigation } from '../composables/useNavigation'
+import { useSelectionStore } from '../store/selection.ts'
+import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
+import filesSortingMixin from '../mixins/filesSorting.ts'
+import logger from '../logger.ts'
+
+export default defineComponent({
+ name: 'FilesListTableHeader',
+
+ components: {
+ FilesListTableHeaderButton,
+ NcCheckboxRadioSwitch,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ const { currentView } = useNavigation()
+
+ return {
+ filesStore,
+ selectionStore,
+
+ currentView,
+ }
+ },
+
+ computed: {
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ dir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+ },
+
+ selectAllBind() {
+ const label = t('files', 'Toggle selection for all files and folders')
+ return {
+ 'aria-label': label,
+ checked: this.isAllSelected,
+ indeterminate: this.isSomeSelected,
+ title: label,
+ }
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isAllSelected() {
+ return this.selectedNodes.length === this.nodes.length
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isSomeSelected() {
+ return !this.isAllSelected && !this.isNoneSelected
+ },
+ },
+
+ created() {
+ // ctrl+a selects all
+ useHotKey('a', this.onToggleAll, {
+ ctrl: true,
+ stop: true,
+ prevent: true,
+ })
+
+ // Escape key cancels selection
+ useHotKey('Escape', this.resetSelection, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ methods: {
+ ariaSortForMode(mode: string): 'ascending'|'descending'|null {
+ if (this.sortingMode === mode) {
+ return this.isAscSorting ? 'ascending' : 'descending'
+ }
+ return null
+ },
+
+ classForColumn(column) {
+ return {
+ 'files-list__column': true,
+ 'files-list__column--sortable': !!column.sort,
+ 'files-list__row-column-custom': true,
+ [`files-list__row-${this.currentView?.id}-${column.id}`]: true,
+ }
+ },
+
+ onToggleAll(selected = true) {
+ if (selected) {
+ const selection = this.nodes.map(node => node.source).filter(Boolean) as FileSource[]
+ logger.debug('Added all nodes to selection', { selection })
+ this.selectionStore.setLastIndex(null)
+ this.selectionStore.set(selection)
+ } else {
+ logger.debug('Cleared selection')
+ this.selectionStore.reset()
+ }
+ },
+
+ resetSelection() {
+ if (this.isNoneSelected) {
+ return
+ }
+ this.selectionStore.reset()
+ },
+
+ t,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__column {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+
+ &--sortable {
+ cursor: pointer;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
new file mode 100644
index 00000000000..6a808355c58
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -0,0 +1,337 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions>
+ <NcActions ref="actionsMenu"
+ container="#app-content-vue"
+ :boundaries-element="boundariesElement"
+ :disabled="!!loading || areSomeNodesLoading"
+ :force-name="true"
+ :inline="enabledInlineActions.length"
+ :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
+ :open.sync="openedMenu"
+ @close="openedSubmenu = null">
+ <!-- Default actions list-->
+ <NcActionButton v-for="action in enabledMenuActions"
+ :key="action.id"
+ :ref="`action-batch-${action.id}`"
+ :class="{
+ [`files-list__row-actions-batch-${action.id}`]: true,
+ [`files-list__row-actions-batch--menu`]: isValidMenu(action)
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-selection-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </template>
+ {{ action.displayName(nodes, currentView) }}
+ </NcActionButton>
+
+ <!-- Submenu actions list-->
+ <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
+ <!-- Back to top-level button -->
+ <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
+ <template #icon>
+ <ArrowLeftIcon />
+ </template>
+ {{ t('files', 'Back') }}
+ </NcActionButton>
+ <NcActionSeparator />
+
+ <!-- Submenu actions -->
+ <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
+ :key="action.id"
+ :class="`files-list__row-actions-batch-${action.id}`"
+ class="files-list__row-actions-batch--submenu"
+ close-after-click
+ :data-cy-files-list-selection-action="action.id"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </template>
+ {{ action.displayName(nodes, currentView) }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+ </div>
+</template>
+
+<script lang="ts">
+import type { FileAction, Node, View } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../types'
+
+import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import actionsMixins from '../mixins/actionsMixin.ts'
+import logger from '../logger.ts'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default defineComponent({
+ name: 'FilesListTableHeaderActions',
+
+ components: {
+ ArrowLeftIcon,
+ NcActions,
+ NcActionButton,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ },
+
+ mixins: [actionsMixins],
+
+ props: {
+ currentView: {
+ type: Object as PropType<View>,
+ required: true,
+ },
+ selectedNodes: {
+ type: Array as PropType<FileSource[]>,
+ default: () => ([]),
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ const fileListWidth = useFileListWidth()
+ const { directory } = useRouteParameters()
+
+ const boundariesElement = document.getElementById('app-content-vue')
+
+ return {
+ directory,
+ fileListWidth,
+
+ actionsMenuStore,
+ filesStore,
+ selectionStore,
+
+ boundariesElement,
+ }
+ },
+
+ data() {
+ return {
+ loading: null,
+ }
+ },
+
+ computed: {
+ enabledFileActions(): FileAction[] {
+ return actions
+ // We don't handle renderInline actions in this component
+ .filter(action => !action.renderInline)
+ // We don't handle actions that are not visible
+ .filter(action => action.default !== DefaultType.HIDDEN)
+ .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ /**
+ * Return the list of enabled actions that are
+ * allowed to be rendered inlined.
+ * This means that they are not within a menu, nor
+ * being the parent of submenu actions.
+ */
+ enabledInlineActions(): FileAction[] {
+ return this.enabledFileActions
+ // Remove all actions that are not top-level actions
+ .filter(action => action.parent === undefined)
+ // Remove all actions that are not batch actions
+ .filter(action => action.execBatch !== undefined)
+ // Remove all top-menu entries
+ .filter(action => !this.isValidMenu(action))
+ // Return a maximum actions to fit the screen
+ .slice(0, this.inlineActions)
+ },
+
+ /**
+ * Return the rest of enabled actions that are not
+ * rendered inlined.
+ */
+ enabledMenuActions(): FileAction[] {
+ // If we're in a submenu, only render the inline
+ // actions before the filtered submenu
+ if (this.openedSubmenu) {
+ return this.enabledInlineActions
+ }
+
+ // We filter duplicates to prevent inline actions to be shown twice
+ const actions = this.enabledFileActions.filter((value, index, self) => {
+ return index === self.findIndex(action => action.id === value.id)
+ })
+
+ // Generate list of all top-level actions ids
+ const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[]
+
+ const menuActions = actions
+ .filter(action => {
+ // If the action is not a batch action, we need
+ // to make sure it's a top-level parent entry
+ // and that we have some children actions bound to it
+ if (!action.execBatch) {
+ return childrenActionsIds.includes(action.id)
+ }
+
+ // Rendering second-level actions is done in the template
+ // when openedSubmenu is set.
+ if (action.parent) {
+ return false
+ }
+
+ return true
+ })
+ .filter(action => !this.enabledInlineActions.includes(action))
+
+ // Make sure we render the inline actions first
+ // and then the rest of the actions.
+ // We do NOT want nested actions to be rendered inlined
+ return [...this.enabledInlineActions, ...menuActions]
+ },
+
+ nodes() {
+ return this.selectedNodes
+ .map(source => this.getNode(source))
+ .filter(Boolean) as Node[]
+ },
+
+ areSomeNodesLoading() {
+ return this.nodes.some(node => node.status === NodeStatus.LOADING)
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === 'global'
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? 'global' : null
+ },
+ },
+
+ inlineActions() {
+ if (this.fileListWidth < 512) {
+ return 0
+ }
+ if (this.fileListWidth < 768) {
+ return 1
+ }
+ if (this.fileListWidth < 1024) {
+ return 2
+ }
+ return 3
+ },
+ },
+
+ methods: {
+ /**
+ * Get a cached note from the store
+ *
+ * @param source The source of the node to get
+ */
+ getNode(source: string): Node|undefined {
+ return this.filesStore.getNode(source)
+ },
+
+ async onActionClick(action) {
+ // If the action is a submenu, we open it
+ if (this.enabledSubmenuActions[action.id]) {
+ this.openedSubmenu = action
+ return
+ }
+
+ let displayName = action.id
+ try {
+ displayName = action.displayName(this.nodes, this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+
+ const selectionSources = this.selectedNodes
+ try {
+ // Set loading markers
+ this.loading = action.id
+ this.nodes.forEach(node => {
+ this.$set(node, 'status', NodeStatus.LOADING)
+ })
+
+ // Dispatch action execution
+ const results = await action.execBatch(this.nodes, this.currentView, this.directory)
+
+ // Check if all actions returned null
+ if (!results.some(result => result !== null)) {
+ // If the actions returned null, we stay silent
+ this.selectionStore.reset()
+ return
+ }
+
+ // Handle potential failures
+ if (results.some(result => result === false)) {
+ // Remove the failed ids from the selection
+ const failedSources = selectionSources
+ .filter((source, index) => results[index] === false)
+ this.selectionStore.set(failedSources)
+
+ if (results.some(result => result === null)) {
+ // If some actions returned null, we assume that the dev
+ // is handling the error messages and we stay silent
+ return
+ }
+
+ showError(this.t('files', '{displayName}: failed on some elements', { displayName }))
+ return
+ }
+
+ // Show success message and clear selection
+ showSuccess(this.t('files', '{displayName}: done', { displayName }))
+ this.selectionStore.reset()
+ } catch (e) {
+ logger.error('Error while executing action', { action, e })
+ showError(this.t('files', '{displayName}: failed', { displayName }))
+ } finally {
+ // Remove loading markers
+ this.loading = null
+ this.nodes.forEach(node => {
+ this.$set(node, 'status', undefined)
+ })
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__row-actions-batch {
+ flex: 1 1 100% !important;
+ max-width: 100%;
+}
+</style>
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
new file mode 100644
index 00000000000..d2e14a5495f
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -0,0 +1,91 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcButton :class="['files-list__column-sort-button', {
+ 'files-list__column-sort-button--active': sortingMode === mode,
+ 'files-list__column-sort-button--size': sortingMode === 'size',
+ }]"
+ :alignment="mode === 'size' ? 'end' : 'start-reverse'"
+ type="tertiary"
+ :title="name"
+ @click="toggleSortBy(mode)">
+ <template #icon>
+ <MenuUp v-if="sortingMode !== mode || isAscSorting" class="files-list__column-sort-button-icon" />
+ <MenuDown v-else class="files-list__column-sort-button-icon" />
+ </template>
+ <span class="files-list__column-sort-button-text">{{ name }}</span>
+ </NcButton>
+</template>
+
+<script lang="ts">
+import { translate } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import MenuDown from 'vue-material-design-icons/MenuDown.vue'
+import MenuUp from 'vue-material-design-icons/MenuUp.vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+
+import filesSortingMixin from '../mixins/filesSorting.ts'
+
+export default defineComponent({
+ name: 'FilesListTableHeaderButton',
+
+ components: {
+ MenuDown,
+ MenuUp,
+ NcButton,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ mode: {
+ type: String,
+ required: true,
+ },
+ },
+
+ methods: {
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__column-sort-button {
+ // Compensate for cells margin
+ margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1);
+ min-width: calc(100% - 3 * var(--cell-margin))!important;
+
+ &-text {
+ color: var(--color-text-maxcontrast);
+ font-weight: normal;
+ }
+
+ &-icon {
+ color: var(--color-text-maxcontrast);
+ opacity: 0;
+ transition: opacity var(--animation-quick);
+ inset-inline-start: -10px;
+ }
+
+ &--size &-icon {
+ inset-inline-start: 10px;
+ }
+
+ &--active &-icon,
+ &:hover &-icon,
+ &:focus &-icon,
+ &:active &-icon {
+ opacity: 1;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
new file mode 100644
index 00000000000..47b8ef19b19
--- /dev/null
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -0,0 +1,1035 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <VirtualList ref="table"
+ :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
+ :data-key="'source'"
+ :data-sources="nodes"
+ :grid-mode="userConfig.grid_view"
+ :extra-props="{
+ isMimeAvailable,
+ isMtimeAvailable,
+ isSizeAvailable,
+ nodes,
+ }"
+ :scroll-to-index="scrollToIndex"
+ :caption="caption">
+ <template #filters>
+ <FileListFilters />
+ </template>
+
+ <template v-if="!isNoneSelected" #header-overlay>
+ <span class="files-list__selected">
+ {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
+ </span>
+ <FilesListTableHeaderActions :current-view="currentView"
+ :selected-nodes="selectedNodes" />
+ </template>
+
+ <template #before>
+ <!-- Headers -->
+ <FilesListHeader v-for="header in headers"
+ :key="header.id"
+ :current-folder="currentFolder"
+ :current-view="currentView"
+ :header="header" />
+ </template>
+
+ <!-- Thead-->
+ <template #header>
+ <!-- Table header and sort buttons -->
+ <FilesListTableHeader ref="thead"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes" />
+ </template>
+
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
+ <!-- Tfoot-->
+ <template #footer>
+ <FilesListTableFooter :current-view="currentView"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes"
+ :summary="summary" />
+ </template>
+ </VirtualList>
+</template>
+
+<script lang="ts">
+import type { UserConfig } from '../types'
+import type { Node as NcNode } from '@nextcloud/files'
+import type { ComponentPublicInstance, PropType } from 'vue'
+
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { n, t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { useActiveStore } from '../store/active.ts'
+import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useSelectionStore } from '../store/selection.js'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import logger from '../logger.ts'
+
+import FileEntry from './FileEntry.vue'
+import FileEntryGrid from './FileEntryGrid.vue'
+import FileListFilters from './FileListFilters.vue'
+import FilesListHeader from './FilesListHeader.vue'
+import FilesListTableFooter from './FilesListTableFooter.vue'
+import FilesListTableHeader from './FilesListTableHeader.vue'
+import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
+import VirtualList from './VirtualList.vue'
+
+export default defineComponent({
+ name: 'FilesListVirtual',
+
+ components: {
+ FileListFilters,
+ FilesListHeader,
+ FilesListTableFooter,
+ FilesListTableHeader,
+ VirtualList,
+ FilesListTableHeaderActions,
+ },
+
+ props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ currentFolder: {
+ type: Folder,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<NcNode[]>,
+ required: true,
+ },
+ summary: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const activeStore = useActiveStore()
+ const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+
+ const fileListWidth = useFileListWidth()
+ const { fileId, openDetails, openFile } = useRouteParameters()
+
+ return {
+ fileId,
+ fileListWidth,
+ headers: useFileListHeaders(),
+ openDetails,
+ openFile,
+
+ activeStore,
+ selectionStore,
+ userConfigStore,
+
+ n,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ FileEntry,
+ FileEntryGrid,
+ scrollToIndex: 0,
+ }
+ },
+
+ computed: {
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
+ isMimeAvailable() {
+ if (!this.userConfig.show_mime_column) {
+ return false
+ }
+ // Hide mime column on narrow screens
+ if (this.fileListWidth < 1024) {
+ return false
+ }
+ return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream')
+ },
+ isMtimeAvailable() {
+ // Hide mtime column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.mtime !== undefined)
+ },
+ isSizeAvailable() {
+ // Hide size column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.size !== undefined)
+ },
+
+ cantUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
+ },
+
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ caption() {
+ const defaultCaption = t('files', 'List of files and folders.')
+ const viewCaption = this.currentView.caption || defaultCaption
+ const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null
+ const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null
+ const sortableCaption = t('files', 'Column headers with buttons are sortable.')
+ const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.')
+ return [
+ viewCaption,
+ cantUploadCaption,
+ quotaExceededCaption,
+ sortableCaption,
+ virtualListNote,
+ ].filter(Boolean).join('\n')
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
+ },
+
+ watch: {
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
+ },
+ fileId() {
+ this.handleOpenQueries()
+ },
+ openFile() {
+ this.handleOpenQueries()
+ },
+ openDetails() {
+ this.handleOpenQueries()
+ },
+ },
+
+ created() {
+ useHotKey('Escape', this.unselectFile, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ mounted() {
+ // 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)
+ },
+
+ beforeDestroy() {
+ 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) {
+ return
+ }
+
+ logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
+ nodes: this.nodes,
+ fileId: this.fileId,
+ openFile: this.openFile,
+ openDetails: this.openDetails,
+ })
+
+ if (this.openFile) {
+ this.handleOpenFile(this.fileId)
+ }
+
+ if (this.openDetails) {
+ this.openSidebarForFile(this.fileId)
+ }
+
+ if (this.fileId) {
+ this.scrollToFile(this.fileId, false)
+ }
+ },
+
+ openSidebarForFile(fileId) {
+ // 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?.([node], this.currentView)) {
+ logger.debug('Opening sidebar on file ' + node.path, { node })
+ sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ return
+ }
+ logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
+ },
+
+ scrollToFile(fileId: number|null, warn = true) {
+ if (fileId) {
+ // Do not uselessly scroll to the top of the list.
+ if (fileId === this.currentFolder.fileid) {
+ return
+ }
+
+ const index = this.nodes.findIndex(node => node.fileid === fileId)
+ if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
+ showError(t('files', 'File not found'))
+ }
+
+ this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
+ }
+ },
+
+ /**
+ * Unselect the current file and clear open parameters from the URL
+ */
+ unselectFile() {
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = undefined
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
+ query,
+ true,
+ )
+ },
+
+ // 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)
+ * @param fileId File to open
+ */
+ async handleOpenFile(fileId: number) {
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node === undefined) {
+ return
+ }
+
+ if (node.type === FileType.File) {
+ const defaultAction = getFileActions()
+ // Get only default actions (visible and hidden)
+ .filter((action) => !!action?.default)
+ // Find actions that are either always enabled or enabled for the current node
+ .filter((action) => !action.enabled || action.enabled([node], this.currentView))
+ .filter((action) => action.id !== 'download')
+ // Sort enabled default actions by order
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ // Get the first one
+ .at(0)
+
+ // Some file types do not have a default action (e.g. they can only be downloaded)
+ // So if there is an enabled default action, so execute it
+ if (defaultAction) {
+ logger.debug('Opening file ' + node.path, { node })
+ return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
+ }
+ }
+ // The file is either a folder or has no default action other than downloading
+ // in this case we need to open the details instead and remove the route from the history
+ logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ { ...this.$route.query, openfile: undefined, opendetails: '' },
+ true, // silent update of the URL
+ )
+ },
+
+ onDragOver(event: DragEvent) {
+ // Detect if we're only dragging existing files or not
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ // Only handle uploading of existing Nextcloud files
+ // See DragAndDropNotice for handling of foreign files
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el
+ const tableTop = tableElement.getBoundingClientRect().top
+ const tableBottom = tableTop + tableElement.getBoundingClientRect().height
+
+ // If reaching top, scroll up. Using 100 because of the floating header
+ if (event.clientY < tableTop + 100) {
+ tableElement.scrollTop = tableElement.scrollTop - 25
+ return
+ }
+
+ // If reaching bottom, scroll down
+ if (event.clientY > tableBottom - 50) {
+ tableElement.scrollTop = tableElement.scrollTop + 25
+ }
+ },
+
+ onKeyDown(event: KeyboardEvent) {
+ // Up and down arrow keys
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
+ const columnCount = this.$refs.table?.columnCount ?? 1
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+
+ // if grid mode, left and right arrow keys
+ if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+ },
+
+ setActiveNode(node: NcNode & { fileid: number }) {
+ logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
+ this.scrollToFile(node.fileid)
+
+ // Remove openfile and opendetails from the URL
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = node
+
+ // Silent update of the URL
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(node.fileid) },
+ query,
+ true,
+ )
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list {
+ --row-height: 44px;
+ --cell-margin: 14px;
+
+ --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
+ --checkbox-size: 24px;
+ --clickable-area: var(--default-clickable-area);
+ --icon-preview-size: 24px;
+
+ --fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ height: 100%;
+ will-change: scroll-position;
+
+ &:has(.file-list-filters__active) {
+ --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
+ }
+
+ & :deep() {
+ // Table head, body and footer
+ tbody {
+ will-change: padding;
+ contain: layout paint style;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ // Necessary for virtual scrolling absolute
+ position: relative;
+
+ /* Hover effect on tbody lines only */
+ tr {
+ contain: strict;
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-dark);
+ }
+ }
+ }
+
+ // Before table and thead
+ .files-list__before {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .files-list__selected {
+ padding-inline-end: 12px;
+ white-space: nowrap;
+ }
+
+ .files-list__table {
+ display: block;
+
+ &.files-list__table--with-thead-overlay {
+ // Hide the table header below the overlay
+ margin-block-start: calc(-1 * var(--row-height));
+ }
+
+ // Visually hide the table when there are no files
+ &--hidden {
+ visibility: hidden;
+ z-index: -1;
+ opacity: 0;
+ }
+ }
+
+ .files-list__filters {
+ // Pinned on top when scrolling above table header
+ position: sticky;
+ top: 0;
+ // ensure there is a background to hide the file list on scroll
+ background-color: var(--color-main-background);
+ z-index: 10;
+ // fixed the size
+ padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
+ height: var(--fixed-block-start-position);
+ width: 100%;
+ }
+
+ .files-list__thead-overlay {
+ // Pinned on top when scrolling
+ position: sticky;
+ top: var(--fixed-block-start-position);
+ // Save space for a row checkbox
+ margin-inline-start: var(--row-height);
+ // More than .files-list__thead
+ z-index: 20;
+
+ display: flex;
+ align-items: center;
+
+ // Reuse row styles
+ background-color: var(--color-main-background);
+ border-block-end: 1px solid var(--color-border);
+ height: var(--row-height);
+ flex: 0 0 var(--row-height);
+ }
+
+ .files-list__thead,
+ .files-list__tfoot {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ background-color: var(--color-main-background);
+ }
+
+ // Table header
+ .files-list__thead {
+ // Pinned on top when scrolling
+ position: sticky;
+ z-index: 10;
+ top: var(--fixed-block-start-position);
+ }
+
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ tr {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-block-end: 1px solid var(--color-border);
+ box-sizing: border-box;
+ user-select: none;
+ height: var(--row-height);
+ }
+
+ td, th {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ justify-content: start;
+ width: var(--row-height);
+ height: var(--row-height);
+ margin: 0;
+ padding: 0;
+ color: var(--color-text-maxcontrast);
+ border: none;
+
+ // Columns should try to add any text
+ // node wrapped in a span. That should help
+ // with the ellipsis on overflow.
+ span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .files-list__row--failed {
+ position: absolute;
+ display: block;
+ top: 0;
+ inset-inline: 0;
+ bottom: 0;
+ opacity: .1;
+ z-index: -1;
+ background: var(--color-error);
+ }
+
+ .files-list__row-checkbox {
+ justify-content: center;
+
+ .checkbox-radio-switch {
+ display: flex;
+ justify-content: center;
+
+ --icon-size: var(--checkbox-size);
+
+ label.checkbox-radio-switch__label {
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ margin: 0;
+ padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
+ }
+
+ .checkbox-radio-switch__icon {
+ margin: 0 !important;
+ }
+ }
+ }
+
+ .files-list__row {
+ &:hover, &:focus, &:active, &--active, &--dragover {
+ // WCAG AA compliant
+ background-color: var(--color-background-hover);
+ // text-maxcontrast have been designed to pass WCAG AA over
+ // a white background, we need to adjust then.
+ --color-text-maxcontrast: var(--color-main-text);
+ > * {
+ --color-border: var(--color-border-dark);
+ }
+
+ // Hover state of the row should also change the favorite markers background
+ .favorite-marker-icon svg path {
+ stroke: var(--color-background-hover);
+ }
+ }
+
+ &--dragover * {
+ // Prevent dropping on row children
+ pointer-events: none;
+ }
+ }
+
+ // Entry preview or mime icon
+ .files-list__row-icon {
+ position: relative;
+ display: flex;
+ overflow: visible;
+ align-items: center;
+ // No shrinking or growing allowed
+ flex: 0 0 var(--icon-preview-size);
+ justify-content: center;
+ width: var(--icon-preview-size);
+ height: 100%;
+ // Show same padding as the checkbox right padding for visual balance
+ margin-inline-end: var(--checkbox-padding);
+ color: var(--color-primary-element);
+
+ // Icon is also clickable
+ * {
+ cursor: pointer;
+ }
+
+ & > span {
+ justify-content: flex-start;
+
+ &:not(.files-list__row-icon-favorite) svg {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ // Slightly increase the size of the folder icon
+ &.folder-icon,
+ &.folder-open-icon {
+ margin: -3px;
+ svg {
+ width: calc(var(--icon-preview-size) + 6px);
+ height: calc(var(--icon-preview-size) + 6px);
+ }
+ }
+ }
+
+ &-preview-container {
+ position: relative; // Needed for the blurshash to be positioned correctly
+ overflow: hidden;
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ border-radius: var(--border-radius);
+ }
+
+ &-blurhash {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ }
+
+ &-preview {
+ // Center and contain the preview
+ object-fit: contain;
+ object-position: center;
+
+ height: 100%;
+ width: 100%;
+
+ /* Preview not loaded animation effect */
+ &:not(.files-list__row-icon-preview--loaded) {
+ background: var(--color-loading-dark);
+ // animation: preview-gradient-fade 1.2s ease-in-out infinite;
+ }
+ }
+
+ &-favorite {
+ position: absolute;
+ top: 0px;
+ inset-inline-end: -10px;
+ }
+
+ // File and folder overlay
+ &-overlay {
+ position: absolute;
+ max-height: calc(var(--icon-preview-size) * 0.6);
+ max-width: calc(var(--icon-preview-size) * 0.6);
+ color: var(--color-primary-element-text);
+ // better alignment with the folder icon
+ margin-block-start: 2px;
+
+ // Improve icon contrast with a background for files
+ &--file {
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: 100%;
+ }
+ }
+ }
+
+ // Entry link
+ .files-list__row-name {
+ // Prevent link from overflowing
+ overflow: hidden;
+ // Take as much space as possible
+ flex: 1 1 auto;
+
+ button.files-list__row-name-link {
+ display: flex;
+ align-items: center;
+ text-align: start;
+ // Fill cell height and width
+ width: 100%;
+ height: 100%;
+ // Necessary for flex grow to work
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+
+ // Already added to the inner text, see rule below
+ &:focus-visible {
+ outline: none !important;
+ }
+
+ // Keyboard indicator a11y
+ &:focus .files-list__row-name-text {
+ outline: var(--border-width-input-focused) solid var(--color-main-text) !important;
+ border-radius: var(--border-radius-element);
+ }
+ &:focus:not(:focus-visible) .files-list__row-name-text {
+ outline: none !important;
+ }
+ }
+
+ .files-list__row-name-text {
+ color: var(--color-main-text);
+ // Make some space for the outline
+ padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
+ padding-inline-start: -10px;
+ // Align two name and ext
+ display: inline-flex;
+ }
+
+ .files-list__row-name-ext {
+ color: var(--color-text-maxcontrast);
+ // always show the extension
+ overflow: visible;
+ }
+ }
+
+ // Rename form
+ .files-list__row-rename {
+ width: 100%;
+ max-width: 600px;
+ input {
+ width: 100%;
+ // Align with text, 0 - padding - border
+ margin-inline-start: -8px;
+ padding: 2px 6px;
+ border-width: 2px;
+
+ &:invalid {
+ // Show red border on invalid input
+ border-color: var(--color-error);
+ color: red;
+ }
+ }
+ }
+
+ .files-list__row-actions {
+ // take as much space as necessary
+ width: auto;
+
+ // Add margin to all cells after the actions
+ & ~ td,
+ & ~ th {
+ margin: 0 var(--cell-margin);
+ }
+
+ button {
+ .button-vue__text {
+ // Remove bold from default button styling
+ font-weight: normal;
+ }
+ }
+ }
+
+ .files-list__row-action--inline {
+ margin-inline-end: 7px;
+ }
+
+ .files-list__row-mime,
+ .files-list__row-mtime,
+ .files-list__row-size {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .files-list__row-size {
+ width: calc(var(--row-height) * 2);
+ // Right align content/text
+ justify-content: flex-end;
+ }
+
+ .files-list__row-mtime {
+ width: calc(var(--row-height) * 2.5);
+ }
+
+ .files-list__row-mime {
+ width: calc(var(--row-height) * 3.5);
+ }
+
+ .files-list__row-column-custom {
+ width: calc(var(--row-height) * 2.5);
+ }
+ }
+}
+
+@media screen and (max-width: 512px) {
+ .files-list :deep(.files-list__filters) {
+ // Reduce padding on mobile
+ padding-inline: var(--default-grid-baseline, 4px);
+ }
+}
+
+</style>
+
+<style lang="scss">
+// Grid mode
+.files-list--grid tbody.files-list__tbody {
+ --item-padding: 16px;
+ --icon-preview-size: 166px;
+ --name-height: var(--default-clickable-area);
+ --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline));
+ --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2);
+ --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2);
+ --checkbox-padding: 0px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, var(--row-width));
+
+ align-content: center;
+ align-items: center;
+ justify-content: space-around;
+ justify-items: center;
+
+ tr {
+ display: flex;
+ flex-direction: column;
+ width: var(--row-width);
+ height: var(--row-height);
+ border: none;
+ border-radius: var(--border-radius-large);
+ padding: var(--item-padding);
+ }
+
+ // Checkbox in the top left
+ .files-list__row-checkbox {
+ position: absolute;
+ z-index: 9;
+ top: calc(var(--item-padding) / 2);
+ inset-inline-start: calc(var(--item-padding) / 2);
+ overflow: hidden;
+ --checkbox-container-size: 44px;
+ width: var(--checkbox-container-size);
+ height: var(--checkbox-container-size);
+
+ // Add a background to the checkbox so we do not see the image through it.
+ .checkbox-radio-switch__content::after {
+ content: '';
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ inset-inline-start: 50%;
+ margin-inline-start: -8px;
+ z-index: -1;
+ background: var(--color-main-background);
+ }
+ }
+
+ // Star icon in the top right
+ .files-list__row-icon-favorite {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name {
+ display: flex;
+ flex-direction: column;
+ width: var(--icon-preview-size);
+ height: calc(var(--icon-preview-size) + var(--name-height));
+ // Ensure that the name outline is visible.
+ overflow: visible;
+
+ span.files-list__row-icon {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ .files-list__row-name-text {
+ margin: 0;
+ // Ensure that the outline is not too close to the text.
+ margin-inline-start: -4px;
+ padding: 0px 4px;
+ }
+ }
+
+ .files-list__row-mtime {
+ width: var(--icon-preview-size);
+ height: var(--mtime-height);
+ font-size: var(--font-size-small);
+ }
+
+ .files-list__row-actions {
+ position: absolute;
+ inset-inline-end: calc(var(--clickable-area) / 4);
+ inset-block-end: calc(var(--mtime-height) / 2);
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+}
+
+@media screen and (max-width: 768px) {
+ // there is no mtime
+ .files-list--grid tbody.files-list__tbody {
+ --mtime-height: 0px;
+
+ // so we move the action to the name
+ .files-list__row-actions {
+ inset-block-end: var(--item-padding);
+ }
+
+ // and we need to keep space on the name for the actions
+ .files-list__row-name-text {
+ padding-inline-end: var(--clickable-area) !important;
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
new file mode 100644
index 00000000000..c29bc00c67f
--- /dev/null
+++ b/apps/files/src/components/FilesNavigationItem.vue
@@ -0,0 +1,182 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <Fragment>
+ <NcAppNavigationItem v-for="view in currentViews"
+ :key="view.id"
+ class="files-navigation__item"
+ allow-collapse
+ :loading="view.loading"
+ :data-cy-files-navigation-item="view.id"
+ :exact="useExactRouteMatching(view)"
+ :icon="view.iconClass"
+ :name="view.name"
+ :open="isExpanded(view)"
+ :pinned="view.sticky"
+ :to="generateToNavigation(view)"
+ :style="style"
+ @update:open="(open) => onOpen(open, view)">
+ <template v-if="view.icon" #icon>
+ <NcIconSvgWrapper :svg="view.icon" />
+ </template>
+
+ <!-- Hack to force the collapse icon to be displayed -->
+ <li v-if="view.loadChildViews && !view.loaded" style="display: none" />
+
+ <!-- Recursively nest child views -->
+ <FilesNavigationItem v-if="hasChildViews(view)"
+ :parent="view"
+ :level="level + 1"
+ :views="filterView(views, parent.id)" />
+ </NcAppNavigationItem>
+ </Fragment>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { View } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { Fragment } from 'vue-frag'
+
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import { useNavigation } from '../composables/useNavigation.js'
+import { useViewConfigStore } from '../store/viewConfig.js'
+
+const maxLevel = 7 // Limit nesting to not exceed max call stack size
+
+export default defineComponent({
+ name: 'FilesNavigationItem',
+
+ components: {
+ Fragment,
+ NcAppNavigationItem,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ parent: {
+ type: Object as PropType<View>,
+ default: () => ({}),
+ },
+ level: {
+ type: Number,
+ default: 0,
+ },
+ views: {
+ type: Object as PropType<Record<string, View[]>>,
+ default: () => ({}),
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+ const viewConfigStore = useViewConfigStore()
+ return {
+ currentView,
+ viewConfigStore,
+ }
+ },
+
+ computed: {
+ currentViews(): View[] {
+ if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
+ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
+ .filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
+ }
+ return this.filterVisible(this.views[this.parent.id] ?? [])
+ },
+
+ style() {
+ if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level
+ return null
+ }
+ return {
+ 'padding-left': '16px',
+ }
+ },
+ },
+
+ methods: {
+ filterVisible(views: View[]) {
+ return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true)
+ },
+
+ hasChildViews(view: View): boolean {
+ if (this.level >= maxLevel) {
+ return false
+ }
+ return this.filterVisible(this.views[view.id] ?? []).length > 0
+ },
+
+ /**
+ * Only use exact route matching on routes with child views
+ * Because if a view does not have children (like the files view) then multiple routes might be matched for it
+ * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
+ * @param view The view to check
+ */
+ useExactRouteMatching(view: View): boolean {
+ return this.hasChildViews(view)
+ },
+
+ /**
+ * Generate the route to a view
+ * @param view View to generate "to" navigation for
+ */
+ generateToNavigation(view: View) {
+ if (view.params) {
+ const { dir } = view.params
+ return { name: 'filelist', params: { ...view.params }, query: { dir } }
+ }
+ return { name: 'filelist', params: { view: view.id } }
+ },
+
+ /**
+ * Check if a view is expanded by user config
+ * or fallback to the default value.
+ * @param view View to check if expanded
+ */
+ isExpanded(view: View): boolean {
+ return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
+ ? this.viewConfigStore.getConfig(view.id).expanded === true
+ : view.expanded === true
+ },
+
+ /**
+ * Expand/collapse a a view with children and permanently
+ * save this setting in the server.
+ * @param open True if open
+ * @param view View
+ */
+ async onOpen(open: boolean, view: View) {
+ // Invert state
+ const isExpanded = this.isExpanded(view)
+ // Update the view expanded state, might not be necessary
+ view.expanded = !isExpanded
+ this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
+ if (open && view.loadChildViews) {
+ await view.loadChildViews(view)
+ }
+ },
+
+ /**
+ * Return the view map with the specified view id removed
+ *
+ * @param viewMap Map of views
+ * @param id View id
+ */
+ filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
+ return Object.fromEntries(
+ Object.entries(viewMap)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ .filter(([viewId, _views]) => viewId !== id),
+ )
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue
new file mode 100644
index 00000000000..0890dffcb39
--- /dev/null
+++ b/apps/files/src/components/FilesNavigationSearch.vue
@@ -0,0 +1,86 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import { mdiMagnify, mdiSearchWeb } from '@mdi/js'
+import { t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useSearchStore } from '../store/search.ts'
+import { VIEW_ID } from '../views/search.ts'
+
+const { currentView } = useNavigation(true)
+const searchStore = useSearchStore()
+
+/**
+ * When the route is changed from search view to something different
+ * we need to clear the search box.
+ */
+onBeforeNavigation((to, from, next) => {
+ if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) {
+ // we are leaving the search view so unset the query
+ searchStore.query = ''
+ searchStore.scope = 'filter'
+ } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) {
+ // fix the query if the user refreshed the view
+ if (searchStore.query && !to.query.query) {
+ // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3)
+ return next({
+ ...to,
+ query: {
+ ...to.query,
+ query: searchStore.query,
+ },
+ })
+ }
+ }
+ next()
+})
+
+/**
+ * Are we currently on the search view.
+ * Needed to disable the action menu (we cannot change the search mode there)
+ */
+const isSearchView = computed(() => currentView.value.id === VIEW_ID)
+
+/**
+ * Different searchbox label depending if filtering or searching
+ */
+const searchLabel = computed(() => {
+ if (searchStore.scope === 'globally') {
+ return t('files', 'Search everywhere …')
+ }
+ return t('files', 'Search here …')
+})
+</script>
+
+<template>
+ <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel">
+ <template #actions>
+ <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView">
+ <template #icon>
+ <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" />
+ </template>
+ <NcActionButton close-after-click @click="searchStore.scope = 'filter'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ {{ t('files', 'Search here') }}
+ </NcActionButton>
+ <NcActionButton close-after-click @click="searchStore.scope = 'globally'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiSearchWeb" />
+ </template>
+ {{ t('files', 'Search everywhere') }}
+ </NcActionButton>
+ </NcActions>
+ </template>
+ </NcAppNavigationSearch>
+</template>
diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue
index 4a50ed558f0..b5a792d9029 100644
--- a/apps/files/src/components/LegacyView.vue
+++ b/apps/files/src/components/LegacyView.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div />
@@ -50,10 +33,8 @@ export default {
},
methods: {
setFileInfo(fileInfo) {
- this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
+ this.component.setFileInfo(fileInfo)
},
},
}
</script>
-<style>
-</style>
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index bfcbaea3776..46c8e5c9af4 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -1,6 +1,10 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
<NcAppNavigationItem v-if="storageStats"
- :aria-label="t('files', 'Storage informations')"
+ :aria-description="t('files', 'Storage information')"
:class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}"
:loading="loadingStorageStats"
:name="storageStatsTitle"
@@ -13,25 +17,27 @@
<!-- Progress bar -->
<NcProgressBar v-if="storageStats.quota >= 0"
slot="extra"
+ :aria-label="t('files', 'Storage quota')"
:error="storageStats.relative > 80"
:value="Math.min(storageStats.relative, 100)" />
</NcAppNavigationItem>
</template>
<script>
+import { debounce, throttle } from 'throttle-debounce'
import { formatFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
-import { debounce, throttle } from 'throttle-debounce'
+import { subscribe } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import ChartPie from 'vue-material-design-icons/ChartPie.vue'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
-import logger from '../logger.js'
-import { subscribe } from '@nextcloud/event-bus'
+import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
+
+import logger from '../logger.ts'
export default {
name: 'NavigationQuota',
@@ -51,8 +57,8 @@ export default {
computed: {
storageStatsTitle() {
- const usedQuotaByte = formatFileSize(this.storageStats?.used)
- const quotaByte = formatFileSize(this.storageStats?.quota)
+ const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false)
+ const quotaByte = formatFileSize(this.storageStats?.total, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
@@ -80,15 +86,26 @@ export default {
*/
setInterval(this.throttleUpdateStorageStats, 60 * 1000)
- subscribe('files:file:created', this.throttleUpdateStorageStats)
- subscribe('files:file:deleted', this.throttleUpdateStorageStats)
- subscribe('files:file:moved', this.throttleUpdateStorageStats)
- subscribe('files:file:updated', this.throttleUpdateStorageStats)
+ subscribe('files:node:created', this.throttleUpdateStorageStats)
+ subscribe('files:node:deleted', this.throttleUpdateStorageStats)
+ subscribe('files:node:moved', this.throttleUpdateStorageStats)
+ subscribe('files:node:updated', this.throttleUpdateStorageStats)
+ },
- subscribe('files:folder:created', this.throttleUpdateStorageStats)
- subscribe('files:folder:deleted', this.throttleUpdateStorageStats)
- subscribe('files:folder:moved', this.throttleUpdateStorageStats)
- subscribe('files:folder:updated', this.throttleUpdateStorageStats)
+ mounted() {
+ // If the user has a quota set, warn if the available account storage is <=0
+ //
+ // NOTE: This doesn't catch situations where actual *server*
+ // disk (non-quota) space is low, but those should probably
+ // be handled differently anyway since a regular user can't
+ // can't do much about them (If we did want to indicate server disk
+ // space matters to users, we'd probably want to use a warning
+ // specific to that situation anyhow. So this covers warning covers
+ // our primary day-to-day concern (individual account quota usage).
+ //
+ if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) {
+ this.showStorageFullWarning()
+ }
},
methods: {
@@ -105,7 +122,7 @@ export default {
* Update the storage stats
* Throttled at max 1 refresh per minute
*
- * @param {Event} [event = null] if user interaction
+ * @param {Event} [event] if user interaction
*/
async updateStorageStats(event = null) {
if (this.loadingStorageStats) {
@@ -118,6 +135,13 @@ export default {
if (!response?.data?.data) {
throw new Error('Invalid storage stats')
}
+
+ // Warn the user if the available account storage changed from > 0 to 0
+ // (unless only because quota was intentionally set to 0 by admin in the interim)
+ if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) {
+ this.showStorageFullWarning()
+ }
+
this.storageStats = response.data.data
} catch (error) {
logger.error('Could not refresh storage stats', { error })
@@ -130,6 +154,10 @@ export default {
}
},
+ showStorageFullWarning() {
+ showError(this.t('files', 'Your storage is full, files can not be updated or synced anymore!'))
+ },
+
t: translate,
},
}
@@ -139,15 +167,18 @@ export default {
// User storage stats display
.app-navigation-entry__settings-quota {
// Align title with progress and icon
- &--not-unlimited::v-deep .app-navigation-entry__title {
- margin-top: -4px;
+ --app-navigation-quota-margin: calc((var(--default-clickable-area) - 24px) / 2); // 20px icon size and 4px progress bar
+
+ &--not-unlimited :deep(.app-navigation-entry__name) {
+ line-height: 1;
+ margin-top: var(--app-navigation-quota-margin);
}
progress {
position: absolute;
- bottom: 10px;
- margin-left: 44px;
- width: calc(100% - 44px - 22px);
+ bottom: var(--app-navigation-quota-margin);
+ margin-inline-start: var(--default-clickable-area);
+ width: calc(100% - (1.5 * var(--default-clickable-area)));
}
}
</style>
diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
new file mode 100644
index 00000000000..ca10935940d
--- /dev/null
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -0,0 +1,168 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog data-cy-files-new-node-dialog
+ :name="name"
+ :open="open"
+ close-on-click-outside
+ out-transition
+ @update:open="emit('close', null)">
+ <template #actions>
+ <NcButton data-cy-files-new-node-dialog-submit
+ type="primary"
+ :disabled="validity !== ''"
+ @click="submit">
+ {{ t('files', 'Create') }}
+ </NcButton>
+ </template>
+ <form ref="formElement"
+ class="new-node-dialog__form"
+ @submit.prevent="emit('close', localDefaultName)">
+ <NcTextField ref="nameInput"
+ data-cy-files-new-node-dialog-input
+ :error="validity !== ''"
+ :helper-text="validity"
+ :label="label"
+ :value.sync="localDefaultName" />
+
+ <!-- Hidden file warning -->
+ <NcNoteCard v-if="isHiddenFileName"
+ type="warning"
+ :text="t('files', 'Files starting with a dot are hidden by default')" />
+ </form>
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import type { ComponentPublicInstance, PropType } from 'vue'
+import { getUniqueName } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { extname } from 'path'
+import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
+import { getFilenameValidity } from '../utils/filenameValidity.ts'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+const props = defineProps({
+ /**
+ * The name to be used by default
+ */
+ defaultName: {
+ type: String,
+ default: t('files', 'New folder'),
+ },
+ /**
+ * Other files that are in the current directory
+ */
+ otherNames: {
+ type: Array as PropType<string[]>,
+ default: () => [],
+ },
+ /**
+ * Open state of the dialog
+ */
+ open: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * Dialog name
+ */
+ name: {
+ type: String,
+ default: t('files', 'Create new folder'),
+ },
+ /**
+ * Input label
+ */
+ label: {
+ type: String,
+ default: t('files', 'Folder name'),
+ },
+})
+
+const emit = defineEmits<{
+ (event: 'close', name: string | null): void
+}>()
+
+const localDefaultName = ref<string>(props.defaultName)
+const nameInput = ref<ComponentPublicInstance>()
+const formElement = ref<HTMLFormElement>()
+const validity = ref('')
+
+const isHiddenFileName = computed(() => {
+ // Check if the name starts with a dot, which indicates a hidden file
+ return localDefaultName.value.trim().startsWith('.')
+})
+
+/**
+ * Focus the filename input field
+ */
+function focusInput() {
+ nextTick(() => {
+ // get the input element
+ const input = nameInput.value?.$el.querySelector('input')
+ if (!props.open || !input) {
+ return
+ }
+
+ // length of the basename
+ const length = localDefaultName.value.length - extname(localDefaultName.value).length
+ // focus the input
+ input.focus()
+ // and set the selection to the basename (name without extension)
+ input.setSelectionRange(0, length)
+ })
+}
+
+/**
+ * Trigger submit on the form
+ */
+function submit() {
+ formElement.value?.requestSubmit()
+}
+
+// Reset local name on props change
+watch(() => [props.defaultName, props.otherNames], () => {
+ localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim()
+})
+
+// Validate the local name
+watchEffect(() => {
+ if (props.otherNames.includes(localDefaultName.value.trim())) {
+ validity.value = t('files', 'This name is already in use.')
+ } else {
+ validity.value = getFilenameValidity(localDefaultName.value.trim())
+ }
+ const input = nameInput.value?.$el.querySelector('input')
+ if (input) {
+ input.setCustomValidity(validity.value)
+ input.reportValidity()
+ }
+})
+
+// Ensure the input is focussed even if the dialog is already mounted but not open
+watch(() => props.open, () => {
+ nextTick(() => {
+ focusInput()
+ })
+})
+
+onMounted(() => {
+ // on mounted lets use the unique name
+ localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim()
+ nextTick(() => focusInput())
+})
+</script>
+
+<style scoped>
+.new-node-dialog__form {
+ /* Ensure the dialog does not jump when there is a validity error */
+ min-height: calc(2 * var(--default-clickable-area));
+}
+</style>
diff --git a/apps/files/src/components/PersonalSettings.vue b/apps/files/src/components/PersonalSettings.vue
index 1431ae4053a..b076b0c1e3d 100644
--- a/apps/files/src/components/PersonalSettings.vue
+++ b/apps/files/src/components/PersonalSettings.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div id="files-personal-settings" class="section">
@@ -27,7 +11,7 @@
</template>
<script>
-import TransferOwnershipDialogue from './TransferOwnershipDialogue'
+import TransferOwnershipDialogue from './TransferOwnershipDialogue.vue'
export default {
name: 'PersonalSettings',
diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue
index c55a2841517..7a9ffb137a2 100644
--- a/apps/files/src/components/Setting.vue
+++ b/apps/files/src/components/Setting.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev>
- -
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div />
diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue
index ac3cfba7d02..d86e5da9d20 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -1,25 +1,7 @@
-
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcAppSidebarTab :id="id"
ref="tab"
@@ -39,8 +21,8 @@
</template>
<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
@@ -66,7 +48,7 @@ export default {
},
icon: {
type: String,
- required: false,
+ default: '',
},
/**
diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue
new file mode 100644
index 00000000000..3f1db8dfd58
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller.vue
@@ -0,0 +1,122 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcModal label-id="template-field-modal__label">
+ <div class="template-field-modal__content">
+ <form>
+ <h3 id="template-field-modal__label">
+ {{ t('files', 'Fill template fields') }}
+ </h3>
+
+ <div v-for="field in fields" :key="field.index">
+ <component :is="getFieldComponent(field.type)"
+ v-if="fieldHasLabel(field)"
+ :field="field"
+ @input="trackInput" />
+ </div>
+ </form>
+ </div>
+
+ <div class="template-field-modal__buttons">
+ <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" />
+ <NcButton aria-label="Submit button"
+ type="primary"
+ @click="submit">
+ {{ t('files', 'Submit') }}
+ </NcButton>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue'
+import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue'
+
+export default defineComponent({
+ name: 'TemplateFiller',
+
+ components: {
+ NcModal,
+ NcButton,
+ NcLoadingIcon,
+ TemplateRichTextField,
+ TemplateCheckboxField,
+ },
+
+ props: {
+ fields: {
+ type: Array,
+ default: () => [],
+ },
+ onSubmit: {
+ type: Function,
+ default: async () => {},
+ },
+ },
+
+ data() {
+ return {
+ localFields: {},
+ loading: false,
+ }
+ },
+
+ methods: {
+ t,
+ trackInput({ index, property, value }) {
+ if (!this.localFields[index]) {
+ this.localFields[index] = {}
+ }
+
+ this.localFields[index][property] = value
+ },
+ getFieldComponent(fieldType) {
+ const fieldComponentType = fieldType.split('-')
+ .map((str) => {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ })
+ .join('')
+
+ return `Template${fieldComponentType}Field`
+ },
+ fieldHasLabel(field) {
+ return field.name || field.alias
+ },
+ async submit() {
+ this.loading = true
+
+ await this.onSubmit(this.localFields)
+
+ this.$emit('close')
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+$modal-margin: calc(var(--default-grid-baseline) * 4);
+
+.template-field-modal__content {
+ padding: $modal-margin;
+
+ h3 {
+ text-align: center;
+ }
+}
+
+.template-field-modal__buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--default-grid-baseline);
+ margin: $modal-margin;
+ margin-top: 0;
+}
+</style>
diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
new file mode 100644
index 00000000000..18536171bd2
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
@@ -0,0 +1,68 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="template-field__checkbox">
+ <NcCheckboxRadioSwitch :id="fieldId"
+ :checked.sync="value"
+ type="switch"
+ @update:checked="input">
+ {{ fieldLabel }}
+ </NcCheckboxRadioSwitch>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+export default defineComponent({
+ name: 'TemplateCheckboxField',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ },
+
+ props: {
+ field: {
+ type: Object,
+ default: () => {},
+ },
+ },
+
+ data() {
+ return {
+ value: this.field.checked ?? false,
+ }
+ },
+
+ computed: {
+ fieldLabel() {
+ const label = this.field.name || this.field.alias
+
+ return label.charAt(0).toUpperCase() + label.slice(1)
+ },
+ fieldId() {
+ return 'checkbox-field' + this.field.index
+ },
+ },
+
+ methods: {
+ input() {
+ this.$emit('input', {
+ index: this.field.index,
+ property: 'checked',
+ value: this.value,
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.template-field__checkbox {
+ margin: 20px 0;
+}
+</style>
diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
new file mode 100644
index 00000000000..f49819f7e7c
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
@@ -0,0 +1,77 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="template-field__text">
+ <label :for="fieldId">
+ {{ fieldLabel }}
+ </label>
+
+ <NcTextField :id="fieldId"
+ type="text"
+ :value.sync="value"
+ :label="fieldLabel"
+ :label-outside="true"
+ :placeholder="field.content"
+ @input="input" />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+export default defineComponent({
+ name: 'TemplateRichTextField',
+
+ components: {
+ NcTextField,
+ },
+
+ props: {
+ field: {
+ type: Object,
+ default: () => {},
+ },
+ },
+
+ data() {
+ return {
+ value: '',
+ }
+ },
+
+ computed: {
+ fieldLabel() {
+ const label = this.field.name || this.field.alias
+
+ return (label.charAt(0).toUpperCase() + label.slice(1))
+ },
+ fieldId() {
+ return 'text-field' + this.field.index
+ },
+ },
+
+ methods: {
+ input() {
+ this.$emit('input', {
+ index: this.field.index,
+ property: 'content',
+ value: this.value,
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.template-field__text {
+ margin: 20px 0;
+
+ label {
+ font-weight: bold;
+ }
+}
+</style>
diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue
index ad152af9ea3..7927948d3af 100644
--- a/apps/files/src/components/TemplatePreview.vue
+++ b/apps/files/src/components/TemplatePreview.vue
@@ -1,35 +1,19 @@
<!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<li class="template-picker__item">
<input :id="id"
+ ref="input"
:checked="checked"
type="radio"
class="radio"
name="template-picker"
@change="onCheck">
- <label :for="id" class="template-picker__label">
+ <label :for="id" class="template-picker__label" @click="onClick">
<div class="template-picker__preview"
:class="failedPreview ? 'template-picker__preview--failed' : ''">
<img class="template-picker__image"
@@ -47,9 +31,9 @@
</template>
<script>
+import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
-import { encodeFilePath } from '../utils/fileUtils'
-import { getToken, isPublic } from '../utils/davUtils'
+import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public'
// preview width generation
const previewWidth = 256
@@ -123,8 +107,8 @@ export default {
return this.previewUrl
}
// TODO: find a nicer standard way of doing this?
- if (isPublic()) {
- return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`)
+ if (isPublicShare()) {
+ return generateUrl(`/apps/files_sharing/publicpreview/${getSharingToken()}?fileId=${this.fileid}&file=${encodePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`)
}
return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`)
},
@@ -141,6 +125,14 @@ export default {
onFailure() {
this.failedPreview = true
},
+ focus() {
+ this.$refs.input?.focus()
+ },
+ onClick() {
+ if (this.checked) {
+ this.$emit('confirm-click', this.fileid)
+ }
+ },
},
}
</script>
@@ -182,7 +174,7 @@ export default {
border-radius: var(--border-radius-large);
input:checked + label > & {
- border-color: var(--color-primary);
+ border-color: var(--color-primary-element);
}
&--failed {
@@ -209,12 +201,9 @@ export default {
}
&__title {
- overflow: hidden;
// also count preview border
- max-width: calc(var(--width) + 2*2px);
+ max-width: calc(var(--width) + 2 * 2px);
padding: var(--margin);
- white-space: nowrap;
- text-overflow: ellipsis;
}
}
diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue
index 67840b18829..3d668da8144 100644
--- a/apps/files/src/components/TransferOwnershipDialogue.vue
+++ b/apps/files/src/components/TransferOwnershipDialogue.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div>
@@ -25,41 +9,33 @@
<form @submit.prevent="submit">
<p class="transfer-select-row">
<span>{{ readableDirectory }}</span>
- <NcButton v-if="directory === undefined" @click.prevent="start">
+ <NcButton v-if="directory === undefined"
+ class="transfer-select-row__choose_button"
+ @click.prevent="start">
{{ t('files', 'Choose file or folder to transfer') }}
</NcButton>
<NcButton v-else @click.prevent="start">
{{ t('files', 'Change') }}
</NcButton>
- <span class="error">{{ directoryPickerError }}</span>
</p>
- <p class="new-owner-row">
+ <p class="new-owner">
<label for="targetUser">
<span>{{ t('files', 'New owner') }}</span>
</label>
- <NcMultiselect id="targetUser"
- v-model="selectedUser"
+ <NcSelect v-model="selectedUser"
+ input-id="targetUser"
:options="formatedUserSuggestions"
:multiple="false"
- :searchable="true"
- :placeholder="t('files', 'Search users')"
- :preselect-first="true"
- :preserve-search="true"
:loading="loadingUsers"
- track-by="user"
- label="displayName"
- :internal-search="false"
- :clear-on-select="false"
:user-select="true"
- class="middle-align"
- @search-change="findUserDebounced" />
+ @search="findUserDebounced" />
</p>
<p>
- <input type="submit"
- class="primary"
- :value="submitButtonText"
+ <NcButton native-type="submit"
+ type="primary"
:disabled="!canSubmit">
- <span class="error">{{ submitError }}</span>
+ {{ submitButtonText }}
+ </NcButton>
</p>
</form>
</div>
@@ -69,16 +45,15 @@
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import { generateOcsUrl } from '@nextcloud/router'
-import { getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs'
-import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect'
+import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import Vue from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton'
+import NcButton from '@nextcloud/vue/components/NcButton'
-import logger from '../logger'
+import logger from '../logger.ts'
const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer'))
.setMultiSelect(false)
- .setModal(true)
.setType(1)
.allowDirectories()
.build()
@@ -86,7 +61,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans
export default {
name: 'TransferOwnershipDialogue',
components: {
- NcMultiselect,
+ NcSelect,
NcButton,
},
data() {
@@ -113,6 +88,7 @@ export default {
user: user.uid,
displayName: user.displayName,
icon: 'icon-user',
+ subname: user.shareWithDisplayNameUnique,
}
})
},
@@ -152,6 +128,7 @@ export default {
logger.error(`Selecting object for transfer aborted: ${error.message || 'Unknown error'}`, { error })
this.directoryPickerError = error.message || t('files', 'Unknown error')
+ showError(this.directoryPickerError)
})
},
async findUser(query) {
@@ -178,6 +155,7 @@ export default {
Vue.set(this.userSuggestions, user.value.shareWith, {
uid: user.value.shareWith,
displayName: user.label,
+ shareWithDisplayNameUnique: user.shareWithDisplayNameUnique,
})
})
} catch (error) {
@@ -217,6 +195,7 @@ export default {
} else {
this.submitError = error.message || t('files', 'Unknown error')
}
+ showError(this.submitError)
})
},
},
@@ -224,33 +203,34 @@ export default {
</script>
<style scoped lang="scss">
-.middle-align {
- vertical-align: middle;
-}
p {
margin-top: 12px;
margin-bottom: 12px;
}
-.new-owner-row {
+
+.new-owner {
display: flex;
+ flex-direction: column;
+ max-width: 400px;
label {
display: flex;
align-items: center;
+ margin-bottom: calc(var(--default-grid-baseline) * 2);
span {
- margin-right: 8px;
+ margin-inline-end: 8px;
}
}
-
- .multiselect {
- flex-grow: 1;
- max-width: 280px;
- }
}
+
.transfer-select-row {
span {
- margin-right: 8px;
+ margin-inline-end: 8px;
+ }
+
+ &__choose_button {
+ width: min(100%, 400px) !important;
}
}
</style>
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
new file mode 100644
index 00000000000..4746fedf863
--- /dev/null
+++ b/apps/files/src/components/VirtualList.vue
@@ -0,0 +1,424 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <div class="files-list"
+ :class="{ 'files-list--grid': gridMode }"
+ data-cy-files-list
+ @scroll.passive="onScroll">
+ <!-- Header -->
+ <div ref="before" class="files-list__before">
+ <slot name="before" />
+ </div>
+
+ <div ref="filters" class="files-list__filters">
+ <slot name="filters" />
+ </div>
+
+ <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay">
+ <slot name="header-overlay" />
+ </div>
+
+ <div v-if="dataSources.length === 0"
+ class="files-list__empty">
+ <slot name="empty" />
+ </div>
+
+ <table :aria-hidden="dataSources.length === 0"
+ :inert="dataSources.length === 0"
+ class="files-list__table"
+ :class="{
+ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'],
+ 'files-list__table--hidden': dataSources.length === 0,
+ }">
+ <!-- Accessibility table caption for screen readers -->
+ <caption v-if="caption" class="hidden-visually">
+ {{ caption }}
+ </caption>
+
+ <!-- Header -->
+ <thead ref="thead" class="files-list__thead" data-cy-files-list-thead>
+ <slot name="header" />
+ </thead>
+
+ <!-- Body -->
+ <tbody :style="tbodyStyle"
+ class="files-list__tbody"
+ data-cy-files-list-tbody>
+ <component :is="dataComponent"
+ v-for="({key, item}, i) in renderedItems"
+ :key="key"
+ :source="item"
+ :index="i"
+ v-bind="extraProps" />
+ </tbody>
+
+ <!-- Footer -->
+ <tfoot ref="footer"
+ class="files-list__tfoot"
+ data-cy-files-list-tfoot>
+ <slot name="footer" />
+ </tfoot>
+ </table>
+ </div>
+</template>
+
+<script lang="ts">
+import type { File, Folder, Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import debounce from 'debounce'
+
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import logger from '../logger.ts'
+
+interface RecycledPoolItem {
+ key: string,
+ item: Node,
+}
+
+type DataSource = File | Folder
+type DataSourceKey = keyof DataSource
+
+export default defineComponent({
+ name: 'VirtualList',
+
+ props: {
+ dataComponent: {
+ type: [Object, Function],
+ required: true,
+ },
+ dataKey: {
+ type: String as PropType<DataSourceKey>,
+ required: true,
+ },
+ dataSources: {
+ type: Array as PropType<DataSource[]>,
+ required: true,
+ },
+ extraProps: {
+ type: Object as PropType<Record<string, unknown>>,
+ default: () => ({}),
+ },
+ scrollToIndex: {
+ type: Number,
+ default: 0,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ /**
+ * Visually hidden caption for the table accessibility
+ */
+ caption: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ const fileListWidth = useFileListWidth()
+
+ return {
+ fileListWidth,
+ }
+ },
+
+ data() {
+ return {
+ index: this.scrollToIndex,
+ beforeHeight: 0,
+ footerHeight: 0,
+ headerHeight: 0,
+ tableHeight: 0,
+ resizeObserver: null as ResizeObserver | null,
+ }
+ },
+
+ computed: {
+ // Wait for measurements to be done before rendering
+ isReady() {
+ return this.tableHeight > 0
+ },
+
+ // Items to render before and after the visible area
+ bufferItems() {
+ if (this.gridMode) {
+ // 1 row before and after in grid mode
+ return this.columnCount
+ }
+ // 3 rows before and after
+ return 3
+ },
+
+ itemHeight() {
+ // Align with css in FilesListVirtual
+ // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
+ },
+
+ // Grid mode only
+ itemWidth() {
+ // 166px + 16px x 2 (padding left and right)
+ return 166 + 16 + 16
+ },
+
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
+ },
+
+ /**
+ * Number of rows that will be rendered.
+ * This includes only visible + buffer rows.
+ */
+ rowCount(): number {
+ return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
+ },
+
+ /**
+ * Number of columns.
+ * 1 for list view otherwise depending on the file list width.
+ */
+ columnCount(): number {
+ if (!this.gridMode) {
+ return 1
+ }
+ return Math.floor(this.fileListWidth / this.itemWidth)
+ },
+
+ /**
+ * Index of the first item to be rendered
+ * The index can be any file, not just the first one
+ * But the start index is the first item to be rendered,
+ * which needs to align with the column count
+ */
+ startIndex() {
+ const firstColumnIndex = this.index - (this.index % this.columnCount)
+ return Math.max(0, firstColumnIndex - this.bufferItems)
+ },
+
+ /**
+ * Number of items to be rendered at the same time
+ * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount`
+ */
+ shownItems() {
+ // If in grid mode, we need to multiply the number of rows by the number of columns
+ if (this.gridMode) {
+ return this.rowCount * this.columnCount
+ }
+
+ return this.rowCount
+ },
+
+ renderedItems(): RecycledPoolItem[] {
+ if (!this.isReady) {
+ return []
+ }
+
+ const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[]
+
+ const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey]))
+ const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string)
+ const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key]))
+
+ return items.map(item => {
+ const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey])
+ // If defined, let's keep the key
+ if (index !== -1) {
+ return {
+ key: Object.keys(this.$_recycledPool)[index],
+ item,
+ }
+ }
+
+ // Get and consume reusable key or generate a new one
+ const key = unusedKeys.pop() || Math.random().toString(36).substr(2)
+ this.$_recycledPool[key] = item[this.dataKey]
+ return { key, item }
+ })
+ },
+
+ /**
+ * The total number of rows that are available
+ */
+ totalRowCount() {
+ return Math.ceil(this.dataSources.length / this.columnCount)
+ },
+
+ tbodyStyle() {
+ // The number of (virtual) rows above the currently rendered ones.
+ // start index is aligned so this should always be an integer
+ const rowsAbove = Math.round(this.startIndex / this.columnCount)
+ // The number of (virtual) rows below the currently rendered ones.
+ const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount)
+
+ return {
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
+ minHeight: `${this.totalRowCount * this.itemHeight}px`,
+ }
+ },
+ },
+ watch: {
+ scrollToIndex(index) {
+ this.scrollTo(index)
+ },
+
+ totalRowCount() {
+ if (this.scrollToIndex) {
+ this.scrollTo(this.scrollToIndex)
+ }
+ },
+
+ columnCount(columnCount, oldColumnCount) {
+ if (oldColumnCount === 0) {
+ // We're initializing, the scroll position is handled on mounted
+ logger.debug('VirtualList: columnCount is 0, skipping scroll')
+ return
+ }
+ // If the column count changes in grid view,
+ // update the scroll position again
+ this.scrollTo(this.index)
+ },
+ },
+
+ mounted() {
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
+
+ this.resizeObserver = new ResizeObserver(debounce(() => {
+ this.updateHeightVariables()
+ logger.debug('VirtualList: resizeObserver updated')
+ this.onScroll()
+ }, 100))
+ this.resizeObserver.observe(this.$el)
+ this.resizeObserver.observe(this.$refs.before as HTMLElement)
+ this.resizeObserver.observe(this.$refs.filters as HTMLElement)
+ this.resizeObserver.observe(this.$refs.footer as HTMLElement)
+
+ this.$nextTick(() => {
+ // Make sure height values are initialized
+ this.updateHeightVariables()
+ // If we need to scroll to an index we do so in the next tick.
+ // This is needed to apply updates from the initialization of the height variables
+ // which will update the tbody styles until next tick.
+ if (this.scrollToIndex) {
+ this.scrollTo(this.scrollToIndex)
+ }
+ })
+ },
+
+ beforeDestroy() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect()
+ }
+ },
+
+ methods: {
+ scrollTo(index: number) {
+ if (!this.$el || this.index === index) {
+ return
+ }
+
+ // Check if the content is smaller (not equal! keep the footer in mind) than the viewport
+ // meaning there is no scrollbar
+ if (this.totalRowCount < this.visibleRows) {
+ logger.debug('VirtualList: Skip scrolling, nothing to scroll', {
+ index,
+ totalRows: this.totalRowCount,
+ visibleRows: this.visibleRows,
+ })
+ return
+ }
+
+ // We can not scroll further as the last page of rows
+ // For the grid view we also need to account for all columns in that row (columnCount - 1)
+ const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1)
+ // The scroll position
+ let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex))
+
+ // First we need to update the internal index for rendering.
+ // This will cause the <tbody> element to be resized allowing us to set the correct scroll position.
+ this.index = index
+
+ // If this is not the first row we can add a half row from above.
+ // This is to help users understand the table is scrolled and not items did not just disappear.
+ // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area)
+ if (index >= this.columnCount && index <= clampedIndex) {
+ scrollTop -= (this.itemHeight / 2)
+ // As we render one half row more we also need to adjust the internal index
+ this.index = index - this.columnCount
+ } else if (index > clampedIndex) {
+ // If we are on the last page we cannot scroll any further
+ // but we can at least scroll the footer into view
+ if (index <= (clampedIndex + this.columnCount)) {
+ // We only show have of the footer for the first of the last page
+ // To still show the previous row partly. Same reasoning as above:
+ // help the user understand that the table is scrolled not "magically trimmed"
+ scrollTop += this.footerHeight / 2
+ } else {
+ // We reached the very end of the files list and we are focussing not the first visible row
+ // so all we now can do is scroll to the end (footer)
+ scrollTop += this.footerHeight
+ }
+ }
+
+ // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position
+ this.$nextTick(() => {
+ this.$el.scrollTop = scrollTop
+ logger.debug(`VirtualList: scrolling to index ${index}`, {
+ clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight,
+ })
+ })
+ },
+
+ onScroll() {
+ this._onScrollHandle ??= requestAnimationFrame(() => {
+ this._onScrollHandle = null
+
+ const index = this.scrollPosToIndex(this.$el.scrollTop)
+ if (index === this.index) {
+ return
+ }
+
+ // Max 0 to prevent negative index
+ this.index = Math.max(0, Math.floor(index))
+ this.$emit('scroll')
+ })
+ },
+
+ // Convert scroll position to index
+ // It should be the opposite of `indexToScrollPos`
+ scrollPosToIndex(scrollPos: number): number {
+ const topScroll = scrollPos - this.beforeHeight
+ // Max 0 to prevent negative index
+ return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount
+ },
+
+ // Convert index to scroll position
+ // It should be the opposite of `scrollPosToIndex`
+ indexToScrollPos(index: number): number {
+ return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight
+ },
+
+ /**
+ * Update the height variables.
+ * To be called by resize observer and `onMount`
+ */
+ updateHeightVariables(): void {
+ this.tableHeight = this.$el?.clientHeight ?? 0
+ this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0
+ this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0
+
+ // Get the header height which consists of table header and filters
+ const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0
+ const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0
+ this.headerHeight = theadHeight + filterHeight
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/composables/useBeforeNavigation.ts b/apps/files/src/composables/useBeforeNavigation.ts
new file mode 100644
index 00000000000..38b72e40fb3
--- /dev/null
+++ b/apps/files/src/composables/useBeforeNavigation.ts
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { NavigationGuard } from 'vue-router'
+
+import { onUnmounted } from 'vue'
+import { useRouter } from 'vue-router/composables'
+
+/**
+ * Helper until we use Vue-Router v4 (Vue3).
+ *
+ * @param fn - The navigation guard
+ */
+export function onBeforeNavigation(fn: NavigationGuard) {
+ const router = useRouter()
+ const remove = router.beforeResolve(fn)
+ onUnmounted(remove)
+}
diff --git a/apps/files/src/composables/useFileListHeaders.spec.ts b/apps/files/src/composables/useFileListHeaders.spec.ts
new file mode 100644
index 00000000000..c407156412b
--- /dev/null
+++ b/apps/files/src/composables/useFileListHeaders.spec.ts
@@ -0,0 +1,41 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { Header } from '@nextcloud/files'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useFileListHeaders } from './useFileListHeaders.ts'
+
+const getFileListHeaders = vi.hoisted(() => vi.fn())
+
+vi.mock('@nextcloud/files', async (originalModule) => {
+ return {
+ ...(await originalModule()),
+ getFileListHeaders,
+ }
+})
+
+describe('useFileListHeaders', () => {
+ beforeEach(() => vi.resetAllMocks())
+
+ it('gets the headers', () => {
+ const header = new Header({ id: '1', order: 5, render: vi.fn(), updated: vi.fn() })
+ getFileListHeaders.mockImplementationOnce(() => [header])
+
+ const headers = useFileListHeaders()
+ expect(headers.value).toEqual([header])
+ expect(getFileListHeaders).toHaveBeenCalledOnce()
+ })
+
+ it('headers are sorted', () => {
+ const header = new Header({ id: '1', order: 10, render: vi.fn(), updated: vi.fn() })
+ const header2 = new Header({ id: '2', order: 5, render: vi.fn(), updated: vi.fn() })
+ getFileListHeaders.mockImplementationOnce(() => [header, header2])
+
+ const headers = useFileListHeaders()
+ // lower order first
+ expect(headers.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
+ expect(getFileListHeaders).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/files/src/composables/useFileListHeaders.ts b/apps/files/src/composables/useFileListHeaders.ts
new file mode 100644
index 00000000000..b57bcbb1432
--- /dev/null
+++ b/apps/files/src/composables/useFileListHeaders.ts
@@ -0,0 +1,19 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Header } from '@nextcloud/files'
+import type { ComputedRef } from 'vue'
+
+import { getFileListHeaders } from '@nextcloud/files'
+import { computed, ref } from 'vue'
+
+/**
+ * Get the registered and sorted file list headers.
+ */
+export function useFileListHeaders(): ComputedRef<Header[]> {
+ const headers = ref(getFileListHeaders())
+ const sorted = computed(() => [...headers.value].sort((a, b) => a.order - b.order) as Header[])
+
+ return sorted
+}
diff --git a/apps/files/src/composables/useFileListWidth.cy.ts b/apps/files/src/composables/useFileListWidth.cy.ts
new file mode 100644
index 00000000000..b0d42c4a2d6
--- /dev/null
+++ b/apps/files/src/composables/useFileListWidth.cy.ts
@@ -0,0 +1,56 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { defineComponent } from 'vue'
+import { useFileListWidth } from './useFileListWidth.ts'
+
+const ComponentMock = defineComponent({
+ template: '<div id="test-component" style="width: 100%;background: white;">{{ fileListWidth }}</div>',
+ setup() {
+ return {
+ fileListWidth: useFileListWidth(),
+ }
+ },
+})
+const FileListMock = defineComponent({
+ template: '<main id="app-content-vue" style="width: 100%;"><component-mock /></main>',
+ components: {
+ ComponentMock,
+ },
+})
+
+describe('composable: fileListWidth', () => {
+
+ it('Has initial value', () => {
+ cy.viewport(600, 400)
+
+ cy.mount(FileListMock, {})
+ cy.get('#app-content-vue')
+ .should('be.visible')
+ .and('contain.text', '600')
+ })
+
+ it('Is reactive to size change', () => {
+ cy.viewport(600, 400)
+ cy.mount(FileListMock)
+ cy.get('#app-content-vue').should('contain.text', '600')
+
+ cy.viewport(800, 400)
+ cy.screenshot()
+ cy.get('#app-content-vue').should('contain.text', '800')
+ })
+
+ it('Is reactive to style changes', () => {
+ cy.viewport(600, 400)
+ cy.mount(FileListMock)
+ cy.get('#app-content-vue')
+ .should('be.visible')
+ .and('contain.text', '600')
+ .invoke('attr', 'style', 'width: 100px')
+
+ cy.get('#app-content-vue')
+ .should('contain.text', '100')
+ })
+})
diff --git a/apps/files/src/composables/useFileListWidth.ts b/apps/files/src/composables/useFileListWidth.ts
new file mode 100644
index 00000000000..621ef204836
--- /dev/null
+++ b/apps/files/src/composables/useFileListWidth.ts
@@ -0,0 +1,50 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Ref } from 'vue'
+import { onMounted, readonly, ref } from 'vue'
+
+/** The element we observe */
+let element: HTMLElement | undefined
+
+/** The current width of the element */
+const width = ref(0)
+
+const observer = new ResizeObserver((elements) => {
+ if (elements[0].contentBoxSize) {
+ // use the newer `contentBoxSize` property if available
+ width.value = elements[0].contentBoxSize[0].inlineSize
+ } else {
+ // fall back to `contentRect`
+ width.value = elements[0].contentRect.width
+ }
+})
+
+/**
+ * Update the observed element if needed and reconfigure the observer
+ */
+function updateObserver() {
+ const el = document.querySelector<HTMLElement>('#app-content-vue') ?? document.body
+ if (el !== element) {
+ // if already observing: stop observing the old element
+ if (element) {
+ observer.unobserve(element)
+ }
+ // observe the new element if needed
+ observer.observe(el)
+ element = el
+ }
+}
+
+/**
+ * Get the reactive width of the file list
+ */
+export function useFileListWidth(): Readonly<Ref<number>> {
+ // Update the observer when the component is mounted (e.g. because this is the files app)
+ onMounted(updateObserver)
+ // Update the observer also in setup context, so we already have an initial value
+ updateObserver()
+
+ return readonly(width)
+}
diff --git a/apps/files/src/composables/useHotKeys.spec.ts b/apps/files/src/composables/useHotKeys.spec.ts
new file mode 100644
index 00000000000..9c001e8b5ff
--- /dev/null
+++ b/apps/files/src/composables/useHotKeys.spec.ts
@@ -0,0 +1,213 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Location } from 'vue-router'
+
+import { File, Folder, Permission, View } from '@nextcloud/files'
+import { enableAutoDestroy, mount } from '@vue/test-utils'
+import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest'
+import { defineComponent, nextTick } from 'vue'
+import axios from '@nextcloud/axios'
+
+import { getPinia } from '../store/index.ts'
+import { useActiveStore } from '../store/active.ts'
+import { useFilesStore } from '../store/files'
+
+import { action as deleteAction } from '../actions/deleteAction.ts'
+import { action as favoriteAction } from '../actions/favoriteAction.ts'
+import { action as renameAction } from '../actions/renameAction.ts'
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { useHotKeys } from './useHotKeys.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
+
+// this is the mocked current route
+const route = vi.hoisted(() => ({
+ name: 'test',
+ params: {
+ fileId: 123,
+ },
+ query: {
+ openFile: 'false',
+ dir: '/parent/dir',
+ },
+}))
+
+// mocked router
+const router = vi.hoisted(() => ({
+ push: vi.fn<(route: Location) => void>(),
+}))
+
+vi.mock('../actions/sidebarAction.ts', { spy: true })
+vi.mock('../actions/deleteAction.ts', { spy: true })
+vi.mock('../actions/favoriteAction.ts', { spy: true })
+vi.mock('../actions/renameAction.ts', { spy: true })
+
+vi.mock('vue-router/composables', () => ({
+ useRoute: vi.fn(() => route),
+ useRouter: vi.fn(() => router),
+}))
+
+let file: File
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const TestComponent = defineComponent({
+ name: 'test',
+ setup() {
+ useHotKeys()
+ },
+ template: '<div />',
+})
+
+describe('HotKeysService testing', () => {
+ const activeStore = useActiveStore(getPinia())
+
+ let initialState: HTMLInputElement
+
+ enableAutoDestroy(afterEach)
+
+ afterEach(() => {
+ document.body.removeChild(initialState)
+ })
+
+ beforeEach(() => {
+ // Make sure the router is reset before each test
+ router.push.mockClear()
+
+ // Make sure the file is reset before each test
+ file = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
+ const files = useFilesStore(getPinia())
+ files.setRoot({ service: 'files', root })
+
+ // Setting the view first as it reset the active node
+ activeStore.activeView = view
+ activeStore.activeNode = file
+
+ window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } }
+ initialState = document.createElement('input')
+ initialState.setAttribute('type', 'hidden')
+ initialState.setAttribute('id', 'initial-state-files_trashbin-config')
+ initialState.setAttribute('value', btoa(JSON.stringify({
+ allow_delete: true,
+ })))
+ document.body.appendChild(initialState)
+
+ mount(TestComponent)
+ })
+
+ it('Pressing d should open the sidebar once', () => {
+ dispatchEvent({ key: 'd', code: 'KeyD' })
+
+ // Modifier keys should not trigger the action
+ dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', altKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true })
+
+ expect(sidebarAction.enabled).toHaveReturnedWith(true)
+ expect(sidebarAction.exec).toHaveBeenCalledOnce()
+ })
+
+ it('Pressing F2 should rename the file', () => {
+ dispatchEvent({ key: 'F2', code: 'F2' })
+
+ // Modifier keys should not trigger the action
+ dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', altKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', metaKey: true })
+
+ expect(renameAction.enabled).toHaveReturnedWith(true)
+ expect(renameAction.exec).toHaveBeenCalledOnce()
+ })
+
+ it('Pressing s should toggle favorite', () => {
+ vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
+ dispatchEvent({ key: 's', code: 'KeyS' })
+
+ // Modifier keys should not trigger the action
+ dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', altKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', metaKey: true })
+
+ expect(favoriteAction.enabled).toHaveReturnedWith(true)
+ expect(favoriteAction.exec).toHaveBeenCalledOnce()
+ })
+
+ it('Pressing Delete should delete the file', async () => {
+ // @ts-expect-error unit testing
+ vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
+
+ dispatchEvent({ key: 'Delete', code: 'Delete' })
+
+ // Modifier keys should not trigger the action
+ dispatchEvent({ key: 'Delete', code: 'Delete', ctrlKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', altKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', shiftKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', metaKey: true })
+
+ expect(deleteAction.enabled).toHaveReturnedWith(true)
+ expect(deleteAction.exec).toHaveBeenCalledOnce()
+ })
+
+ it('Pressing alt+up should go to parent directory', () => {
+ expect(router.push).toHaveBeenCalledTimes(0)
+ dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true })
+
+ expect(router.push).toHaveBeenCalledOnce()
+ expect(router.push.mock.calls[0][0].query?.dir).toBe('/parent')
+ })
+
+ it('Pressing v should toggle grid view', async () => {
+ vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
+
+ const userConfigStore = useUserConfigStore(getPinia())
+ userConfigStore.userConfig.grid_view = false
+ expect(userConfigStore.userConfig.grid_view).toBe(false)
+
+ dispatchEvent({ key: 'v', code: 'KeyV' })
+ expect(userConfigStore.userConfig.grid_view).toBe(true)
+ })
+
+ it.each([
+ ['ctrlKey'],
+ ['altKey'],
+ // those meta keys are still triggering...
+ // ['shiftKey'],
+ // ['metaKey']
+ ])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => {
+ vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
+
+ const userConfigStore = useUserConfigStore(getPinia())
+ userConfigStore.userConfig.grid_view = false
+ expect(userConfigStore.userConfig.grid_view).toBe(false)
+
+ dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true }))
+
+ await nextTick()
+
+ expect(userConfigStore.userConfig.grid_view).toBe(false)
+ })
+})
+
+/**
+ * Helper to dispatch the correct event.
+ *
+ * @param init - KeyboardEvent options
+ */
+function dispatchEvent(init: KeyboardEventInit) {
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true }))
+}
diff --git a/apps/files/src/composables/useHotKeys.ts b/apps/files/src/composables/useHotKeys.ts
new file mode 100644
index 00000000000..ff56627b2f9
--- /dev/null
+++ b/apps/files/src/composables/useHotKeys.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { dirname } from 'path'
+import { useRoute, useRouter } from 'vue-router/composables'
+
+import { action as deleteAction } from '../actions/deleteAction.ts'
+import { action as favoriteAction } from '../actions/favoriteAction.ts'
+import { action as renameAction } from '../actions/renameAction.ts'
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import { useRouteParameters } from './useRouteParameters.ts'
+import { executeAction } from '../utils/actionUtils.ts'
+import logger from '../logger.ts'
+
+/**
+ * This register the hotkeys for the Files app.
+ * As much as possible, we try to have all the hotkeys in one place.
+ * Please make sure to add tests for the hotkeys after adding a new one.
+ */
+export function useHotKeys(): void {
+ const userConfigStore = useUserConfigStore()
+ const { directory } = useRouteParameters()
+ const router = useRouter()
+ const route = useRoute()
+
+ // d opens the sidebar
+ useHotKey('d', () => executeAction(sidebarAction), {
+ stop: true,
+ prevent: true,
+ })
+
+ // F2 renames the file
+ useHotKey('F2', () => executeAction(renameAction), {
+ stop: true,
+ prevent: true,
+ })
+
+ // s toggle favorite
+ useHotKey('s', () => executeAction(favoriteAction), {
+ stop: true,
+ prevent: true,
+ })
+
+ // Delete deletes the file
+ useHotKey('Delete', () => executeAction(deleteAction), {
+ stop: true,
+ prevent: true,
+ })
+
+ // alt+up go to parent directory
+ useHotKey('ArrowUp', goToParentDir, {
+ stop: true,
+ prevent: true,
+ alt: true,
+ })
+
+ // v toggle grid view
+ useHotKey('v', toggleGridView, {
+ stop: true,
+ prevent: true,
+ })
+
+ logger.debug('Hotkeys registered')
+
+ /**
+ * Use the router to go to the parent directory
+ */
+ function goToParentDir() {
+ const dir = dirname(directory.value)
+
+ logger.debug('Navigating to parent directory', { dir })
+ router.push({ params: { ...route.params }, query: { ...route.query, dir } })
+ }
+
+ /**
+ * Toggle the grid view
+ */
+ function toggleGridView() {
+ const value = userConfigStore.userConfig.grid_view
+ logger.debug('Toggling grid view', { old: value, new: !value })
+ userConfigStore.update('grid_view', !value)
+ }
+}
diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts
new file mode 100644
index 00000000000..b9eb671a181
--- /dev/null
+++ b/apps/files/src/composables/useNavigation.spec.ts
@@ -0,0 +1,106 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Navigation, View } from '@nextcloud/files'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+
+import { useNavigation } from './useNavigation'
+import * as nextcloudFiles from '@nextcloud/files'
+
+// Just a wrapper so we can test the composable
+const TestComponent = defineComponent({
+ template: '<div></div>',
+ setup() {
+ const { currentView, views } = useNavigation()
+ return {
+ currentView,
+ views,
+ }
+ },
+})
+
+describe('Composables: useNavigation', () => {
+ const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
+ let navigation: Navigation
+
+ describe('currentView', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line import/namespace
+ navigation = new nextcloudFiles.Navigation()
+ spy.mockImplementation(() => navigation)
+ })
+
+ it('should return null without active navigation', () => {
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null)
+ })
+
+ it('should return already active navigation', async () => {
+ // eslint-disable-next-line import/namespace
+ const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ navigation.register(view)
+ navigation.setActive(view)
+ // Now the navigation is already set it should take the active navigation
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view)
+ })
+
+ it('should be reactive on updating active navigation', async () => {
+ // eslint-disable-next-line import/namespace
+ const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ navigation.register(view)
+ const wrapper = mount(TestComponent)
+
+ // no active navigation
+ expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null)
+
+ navigation.setActive(view)
+ // Now the navigation is set it should take the active navigation
+ expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view)
+ })
+ })
+
+ describe('views', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line import/namespace
+ navigation = new nextcloudFiles.Navigation()
+ spy.mockImplementation(() => navigation)
+ })
+
+ it('should return empty array without registered views', () => {
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([])
+ })
+
+ it('should return already registered views', () => {
+ // eslint-disable-next-line import/namespace
+ const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ // register before mount
+ navigation.register(view)
+ // now mount and check that the view is listed
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view])
+ })
+
+ it('should be reactive on registering new views', () => {
+ // eslint-disable-next-line import/namespace
+ const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ // eslint-disable-next-line import/namespace
+ const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 })
+
+ // register before mount
+ navigation.register(view)
+ // now mount and check that the view is listed
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view])
+
+ // now register view 2 and check it is reactivly added
+ navigation.register(view2)
+ expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view, view2])
+ })
+ })
+})
diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts
new file mode 100644
index 00000000000..2a6f22a1232
--- /dev/null
+++ b/apps/files/src/composables/useNavigation.ts
@@ -0,0 +1,53 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { View } from '@nextcloud/files'
+import type { ShallowRef } from 'vue'
+
+import { getNavigation } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
+
+/**
+ * Composable to get the currently active files view from the files navigation
+ * @param _loaded If set enforce a current view is loaded
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function useNavigation<T extends boolean>(_loaded?: T) {
+ type MaybeView = T extends true ? View : (View | null);
+ const navigation = getNavigation()
+ const views: ShallowRef<View[]> = shallowRef(navigation.views)
+ const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView)
+
+ /**
+ * Event listener to update the `currentView`
+ * @param event The update event
+ */
+ function onUpdateActive(event: CustomEvent<View|null>) {
+ currentView.value = event.detail as MaybeView
+ }
+
+ /**
+ * Event listener to update all registered views
+ */
+ function onUpdateViews() {
+ views.value = navigation.views
+ triggerRef(views)
+ }
+
+ onMounted(() => {
+ navigation.addEventListener('update', onUpdateViews)
+ navigation.addEventListener('updateActive', onUpdateActive)
+ subscribe('files:navigation:updated', onUpdateViews)
+ })
+ onUnmounted(() => {
+ navigation.removeEventListener('update', onUpdateViews)
+ navigation.removeEventListener('updateActive', onUpdateActive)
+ })
+
+ return {
+ currentView,
+ views,
+ }
+}
diff --git a/apps/files/src/composables/useRouteParameters.ts b/apps/files/src/composables/useRouteParameters.ts
new file mode 100644
index 00000000000..dbb8ca7f081
--- /dev/null
+++ b/apps/files/src/composables/useRouteParameters.ts
@@ -0,0 +1,58 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { computed } from 'vue'
+import { useRoute } from 'vue-router/composables'
+
+/**
+ * Get information about the current route
+ */
+export function useRouteParameters() {
+
+ const route = useRoute()
+
+ /**
+ * Get the path of the current active directory
+ */
+ const directory = computed<string>(
+ () => String(route.query.dir || '/')
+ // Remove any trailing slash but leave root slash
+ .replace(/^(.+)\/$/, '$1'),
+ )
+
+ /**
+ * Get the current fileId used on the route
+ */
+ const fileId = computed<number | null>(() => {
+ const fileId = Number.parseInt(route.params.fileid ?? '0') || null
+ return Number.isNaN(fileId) ? null : fileId
+ })
+
+ /**
+ * State of `openFile` route param
+ */
+ const openFile = computed<boolean>(
+ // if `openfile` is set it is considered truthy, but allow to explicitly set it to 'false'
+ () => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'),
+ )
+
+ const openDetails = computed<boolean>(
+ // if `opendetails` is set it is considered truthy, but allow to explicitly set it to 'false'
+ () => 'opendetails' in route.query && (typeof route.query.opendetails !== 'string' || route.query.opendetails.toLocaleLowerCase() !== 'false'),
+ )
+
+ return {
+ /** Path of currently open directory */
+ directory,
+
+ /** Current active fileId */
+ fileId,
+
+ /** Should the active node should be opened (`openFile` route param) */
+ openFile,
+
+ /** Should the details sidebar be shown (`openDetails` route param) */
+ openDetails,
+ }
+}
diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts
new file mode 100644
index 00000000000..ab8dbb63dfc
--- /dev/null
+++ b/apps/files/src/eventbus.d.ts
@@ -0,0 +1,36 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFileListFilter, Node, View } from '@nextcloud/files'
+import type { SearchScope } from './types'
+
+declare module '@nextcloud/event-bus' {
+ export interface NextcloudEvents {
+ // mapping of 'event name' => 'event type'
+ 'files:config:updated': { key: string, value: boolean }
+ 'files:view-config:updated': { key: string, value: string|number|boolean, view: string }
+
+ 'files:favorites:removed': Node
+ 'files:favorites:added': Node
+
+ 'files:filter:added': IFileListFilter
+ 'files:filter:removed': string
+ // the state of some filters has changed
+ 'files:filters:changed': undefined
+
+ 'files:navigation:changed': View
+
+ '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:search:updated': { query: string, scope: SearchScope }
+ }
+}
+
+export {}
diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts
new file mode 100644
index 00000000000..f86269ccd99
--- /dev/null
+++ b/apps/files/src/filters/FilenameFilter.ts
@@ -0,0 +1,75 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { getPinia } from '../store/index.ts'
+import { useSearchStore } from '../store/search.ts'
+
+/**
+ * Register the filename filter
+ */
+export function registerFilenameFilter() {
+ registerFileListFilter(new FilenameFilter())
+}
+
+/**
+ * Simple file list filter controlled by the Navigation search box
+ */
+class FilenameFilter extends FileListFilter {
+
+ private searchQuery = ''
+
+ constructor() {
+ super('files:filename', 5)
+ subscribe('files:search:updated', ({ query, scope }) => {
+ if (scope === 'filter') {
+ this.updateQuery(query)
+ }
+ })
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ const queryParts = this.searchQuery.toLocaleLowerCase().split(' ').filter(Boolean)
+ return nodes.filter((node) => {
+ const displayname = node.displayname.toLocaleLowerCase()
+ return queryParts.every((part) => displayname.includes(part))
+ })
+ }
+
+ public reset(): void {
+ this.updateQuery('')
+ }
+
+ public updateQuery(query: string) {
+ query = (query || '').trim()
+
+ // Only if the query is different we update the filter to prevent re-computing all nodes
+ if (query !== this.searchQuery) {
+ this.searchQuery = query
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (query !== '') {
+ chips.push({
+ text: query,
+ onclick: () => {
+ this.updateQuery('')
+ },
+ })
+ } else {
+ // make sure to also reset the search store when pressing the "X" on the filter chip
+ const store = useSearchStore(getPinia())
+ if (store.scope === 'filter') {
+ store.query = ''
+ }
+ }
+ this.updateChips(chips)
+ }
+ }
+
+}
diff --git a/apps/files/src/filters/HiddenFilesFilter.ts b/apps/files/src/filters/HiddenFilesFilter.ts
new file mode 100644
index 00000000000..e48881d4ab7
--- /dev/null
+++ b/apps/files/src/filters/HiddenFilesFilter.ts
@@ -0,0 +1,42 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { UserConfig } from '../types'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+
+class HiddenFilesFilter extends FileListFilter {
+
+ private showHidden?: boolean
+
+ constructor() {
+ super('files:hidden', 0)
+ this.showHidden = loadState<Partial<UserConfig>>('files', 'config', { show_hidden: false }).show_hidden
+
+ subscribe('files:config:updated', ({ key, value }) => {
+ if (key === 'show_hidden') {
+ this.showHidden = Boolean(value)
+ this.filterUpdated()
+ }
+ })
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (this.showHidden) {
+ return nodes
+ }
+ return nodes.filter((node) => (node.attributes.hidden !== true && !node.basename.startsWith('.')))
+ }
+
+}
+
+/**
+ * Register a file list filter to only show hidden files if enabled by user config
+ */
+export function registerHiddenFilesFilter() {
+ registerFileListFilter(new HiddenFilesFilter())
+}
diff --git a/apps/files/src/filters/ModifiedFilter.ts b/apps/files/src/filters/ModifiedFilter.ts
new file mode 100644
index 00000000000..e7d7c2f26a7
--- /dev/null
+++ b/apps/files/src/filters/ModifiedFilter.ts
@@ -0,0 +1,114 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue'
+
+import calendarSvg from '@mdi/svg/svg/calendar.svg?raw'
+
+export interface ITimePreset {
+ id: string,
+ label: string,
+ filter: (time: number) => boolean
+}
+
+const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
+
+/**
+ * Available presets
+ */
+const timePresets: ITimePreset[] = [
+ {
+ id: 'today',
+ label: t('files', 'Today'),
+ filter: (time: number) => time > startOfToday(),
+ },
+ {
+ id: 'last-7',
+ label: t('files', 'Last 7 days'),
+ filter: (time: number) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)),
+ },
+ {
+ id: 'last-30',
+ label: t('files', 'Last 30 days'),
+ filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
+ },
+ {
+ id: 'this-year',
+ label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }),
+ filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1),
+ },
+ {
+ id: 'last-year',
+ label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }),
+ filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)),
+ },
+] as const
+
+class ModifiedFilter extends FileListFilter {
+
+ private currentInstance?: Vue
+ private currentPreset?: ITimePreset
+
+ constructor() {
+ super('files:modified', 50)
+ }
+
+ public mount(el: HTMLElement) {
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ }
+
+ const View = Vue.extend(FileListFilterModified as never)
+ this.currentInstance = new View({
+ propsData: {
+ timePresets,
+ },
+ el,
+ })
+ .$on('update:preset', this.setPreset.bind(this))
+ .$mount()
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (!this.currentPreset) {
+ return nodes
+ }
+
+ return nodes.filter((node) => node.mtime === undefined || this.currentPreset!.filter(node.mtime.getTime()))
+ }
+
+ public reset(): void {
+ this.setPreset()
+ }
+
+ public setPreset(preset?: ITimePreset) {
+ this.currentPreset = preset
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (preset) {
+ chips.push({
+ icon: calendarSvg,
+ text: preset.label,
+ onclick: () => this.setPreset(),
+ })
+ } else {
+ (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+ }
+ this.updateChips(chips)
+ }
+
+}
+
+/**
+ * Register the file list filter by modification date
+ */
+export function registerModifiedFilter() {
+ registerFileListFilter(new ModifiedFilter())
+}
diff --git a/apps/files/src/filters/SearchFilter.ts b/apps/files/src/filters/SearchFilter.ts
new file mode 100644
index 00000000000..4c7231fd26a
--- /dev/null
+++ b/apps/files/src/filters/SearchFilter.ts
@@ -0,0 +1,49 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ComponentPublicInstance } from 'vue'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import Vue from 'vue'
+import FileListFilterToSearch from '../components/FileListFilter/FileListFilterToSearch.vue'
+
+class SearchFilter extends FileListFilter {
+
+ private currentInstance?: ComponentPublicInstance<typeof FileListFilterToSearch>
+
+ constructor() {
+ super('files:filter-to-search', 999)
+ subscribe('files:search:updated', ({ query, scope }) => {
+ if (query && scope === 'filter') {
+ this.currentInstance?.showButton()
+ } else {
+ this.currentInstance?.hideButton()
+ }
+ })
+ }
+
+ public mount(el: HTMLElement) {
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ }
+
+ const View = Vue.extend(FileListFilterToSearch)
+ this.currentInstance = new View().$mount(el) as unknown as ComponentPublicInstance<typeof FileListFilterToSearch>
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ return nodes
+ }
+
+}
+
+/**
+ * Register a file list filter to only show hidden files if enabled by user config
+ */
+export function registerFilterToSearchToggle() {
+ registerFileListFilter(new SearchFilter())
+}
diff --git a/apps/files/src/filters/TypeFilter.ts b/apps/files/src/filters/TypeFilter.ts
new file mode 100644
index 00000000000..3170e22b260
--- /dev/null
+++ b/apps/files/src/filters/TypeFilter.ts
@@ -0,0 +1,192 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue'
+
+// TODO: Create a modern replacement for OC.MimeType...
+import svgDocument from '@mdi/svg/svg/file-document.svg?raw'
+import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw'
+import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw'
+import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw'
+import svgFolder from '@mdi/svg/svg/folder.svg?raw'
+import svgAudio from '@mdi/svg/svg/music.svg?raw'
+import svgImage from '@mdi/svg/svg/image.svg?raw'
+import svgMovie from '@mdi/svg/svg/movie.svg?raw'
+
+export interface ITypePreset {
+ id: string
+ label: string
+ icon: string
+ mime: string[]
+}
+
+const colorize = (svg: string, color: string) => {
+ return svg.replace('<path ', `<path fill="${color}" `)
+}
+
+/**
+ * Available presets
+ */
+const getTypePresets = async () => [
+ {
+ id: 'document',
+ label: t('files', 'Documents'),
+ icon: colorize(svgDocument, '#49abea'),
+ mime: ['x-office/document'],
+ },
+ {
+ id: 'spreadsheet',
+ label: t('files', 'Spreadsheets'),
+ icon: colorize(svgSpreadsheet, '#9abd4e'),
+ mime: ['x-office/spreadsheet'],
+ },
+ {
+ id: 'presentation',
+ label: t('files', 'Presentations'),
+ icon: colorize(svgPresentation, '#f0965f'),
+ mime: ['x-office/presentation'],
+ },
+ {
+ id: 'pdf',
+ label: t('files', 'PDFs'),
+ icon: colorize(svgPDF, '#dc5047'),
+ mime: ['application/pdf'],
+ },
+ {
+ id: 'folder',
+ label: t('files', 'Folders'),
+ icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')),
+ mime: ['httpd/unix-directory'],
+ },
+ {
+ id: 'audio',
+ label: t('files', 'Audio'),
+ icon: svgAudio,
+ mime: ['audio'],
+ },
+ {
+ id: 'image',
+ // TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general
+ label: t('files', 'Images'),
+ icon: svgImage,
+ mime: ['image'],
+ },
+ {
+ id: 'video',
+ label: t('files', 'Videos'),
+ icon: svgMovie,
+ mime: ['video'],
+ },
+] as ITypePreset[]
+
+class TypeFilter extends FileListFilter {
+
+ private currentInstance?: Vue
+ private currentPresets: ITypePreset[]
+ private allPresets?: ITypePreset[]
+
+ constructor() {
+ super('files:type', 10)
+ this.currentPresets = []
+ }
+
+ public async mount(el: HTMLElement) {
+ // We need to defer this as on init script this is not available:
+ if (this.allPresets === undefined) {
+ this.allPresets = await getTypePresets()
+ }
+
+ // Already mounted
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ delete this.currentInstance
+ }
+
+ const View = Vue.extend(FileListFilterType as never)
+ this.currentInstance = new View({
+ propsData: {
+ presets: this.currentPresets,
+ typePresets: this.allPresets!,
+ },
+ el,
+ })
+ .$on('update:presets', this.setPresets.bind(this))
+ .$mount()
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (!this.currentPresets || this.currentPresets.length === 0) {
+ return nodes
+ }
+
+ const mimeList = this.currentPresets.reduce((previous: string[], current) => [...previous, ...current.mime], [] as string[])
+ return nodes.filter((node) => {
+ if (!node.mime) {
+ return false
+ }
+ const mime = node.mime.toLowerCase()
+
+ if (mimeList.includes(mime)) {
+ return true
+ } else if (mimeList.includes(window.OC.MimeTypeList.aliases[mime])) {
+ return true
+ } else if (mimeList.includes(mime.split('/')[0])) {
+ return true
+ }
+ return false
+ })
+ }
+
+ public reset(): void {
+ this.setPresets()
+ }
+
+ public setPresets(presets?: ITypePreset[]) {
+ this.currentPresets = presets ?? []
+ if (this.currentInstance !== undefined) {
+ // could be called before the instance was created
+ // (meaning the files list is not mounted yet)
+ this.currentInstance.$props.presets = presets
+ }
+
+ this.filterUpdated()
+
+ const chips: IFileListFilterChip[] = []
+ if (presets && presets.length > 0) {
+ for (const preset of presets) {
+ chips.push({
+ icon: preset.icon,
+ text: preset.label,
+ onclick: () => this.removeFilterPreset(preset.id),
+ })
+ }
+ } else {
+ (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+ }
+ this.updateChips(chips)
+ }
+
+ /**
+ * Helper callback that removed a preset from selected.
+ * This is used when clicking on "remove" on a filter-chip.
+ * @param presetId Id of preset to remove
+ */
+ private removeFilterPreset(presetId: string) {
+ const filtered = this.currentPresets.filter(({ id }) => id !== presetId)
+ this.setPresets(filtered)
+ }
+
+}
+
+/**
+ * Register the file list filter by file type
+ */
+export function registerTypeFilter() {
+ registerFileListFilter(new TypeFilter())
+}
diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts
new file mode 100644
index 00000000000..74eca0969b4
--- /dev/null
+++ b/apps/files/src/init.ts
@@ -0,0 +1,83 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { addNewFileMenuEntry, registerDavProperty, registerFileAction } from '@nextcloud/files'
+
+import { action as deleteAction } from './actions/deleteAction'
+import { action as downloadAction } from './actions/downloadAction'
+import { action as editLocallyAction } from './actions/openLocallyAction.ts'
+import { action as favoriteAction } from './actions/favoriteAction'
+import { action as moveOrCopyAction } from './actions/moveOrCopyAction'
+import { action as openFolderAction } from './actions/openFolderAction'
+import { action as openInFilesAction } from './actions/openInFilesAction'
+import { action as renameAction } from './actions/renameAction'
+import { action as sidebarAction } from './actions/sidebarAction'
+import { action as viewInFolderAction } from './actions/viewInFolderAction'
+
+import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts'
+import { registerTypeFilter } from './filters/TypeFilter.ts'
+import { registerModifiedFilter } from './filters/ModifiedFilter.ts'
+
+import { entry as newFolderEntry } from './newMenu/newFolder.ts'
+import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
+import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
+
+import { registerFavoritesView } from './views/favorites.ts'
+import registerRecentView from './views/recent'
+import { registerPersonalFilesView } from './views/personal-files'
+import { registerFilesView } from './views/files'
+import { registerFolderTreeView } from './views/folderTree.ts'
+import { registerSearchView } from './views/search.ts'
+
+import registerPreviewServiceWorker from './services/ServiceWorker.js'
+
+import { initLivePhotos } from './services/LivePhotos'
+import { isPublicShare } from '@nextcloud/sharing/public'
+import { registerConvertActions } from './actions/convertAction.ts'
+import { registerFilenameFilter } from './filters/FilenameFilter.ts'
+import { registerFilterToSearchToggle } from './filters/SearchFilter.ts'
+
+// Register file actions
+registerConvertActions()
+registerFileAction(deleteAction)
+registerFileAction(downloadAction)
+registerFileAction(editLocallyAction)
+registerFileAction(favoriteAction)
+registerFileAction(moveOrCopyAction)
+registerFileAction(openFolderAction)
+registerFileAction(openInFilesAction)
+registerFileAction(renameAction)
+registerFileAction(sidebarAction)
+registerFileAction(viewInFolderAction)
+
+// Register new menu entry
+addNewFileMenuEntry(newFolderEntry)
+addNewFileMenuEntry(newTemplatesFolder)
+registerTemplateEntries()
+
+// Register files views when not on public share
+if (isPublicShare() === false) {
+ registerFavoritesView()
+ registerFilesView()
+ registerPersonalFilesView()
+ registerRecentView()
+ registerSearchView()
+ registerFolderTreeView()
+}
+
+// Register file list filters
+registerHiddenFilesFilter()
+registerTypeFilter()
+registerModifiedFilter()
+registerFilenameFilter()
+registerFilterToSearchToggle()
+
+// Register preview service worker
+registerPreviewServiceWorker()
+
+registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:metadata-blurhash', { nc: 'http://nextcloud.org/ns' })
+
+initLivePhotos()
diff --git a/apps/files/src/legacy/filelistSearch.js b/apps/files/src/legacy/filelistSearch.js
deleted file mode 100644
index 9512f47eccc..00000000000
--- a/apps/files/src/legacy/filelistSearch.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { subscribe } from '@nextcloud/event-bus'
-
-(function() {
-
- const FilesPlugin = {
- attach(fileList) {
- subscribe('nextcloud:unified-search.search', ({ query }) => {
- fileList.setFilter(query)
- })
- subscribe('nextcloud:unified-search.reset', () => {
- this.query = null
- fileList.setFilter('')
- })
-
- },
- }
-
- window.OC.Plugins.register('OCA.Files.FileList', FilesPlugin)
-
-})()
diff --git a/apps/files/src/legacy/navigationMapper.js b/apps/files/src/legacy/navigationMapper.js
deleted file mode 100644
index 764a7cb6cd9..00000000000
--- a/apps/files/src/legacy/navigationMapper.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { loadState } from '@nextcloud/initial-state'
-import logger from '../logger.js'
-
-/**
- * Fetch and register the legacy files views
- */
-export default function() {
- const legacyViews = Object.values(loadState('files', 'navigation', {}))
-
- if (legacyViews.length > 0) {
- logger.debug('Legacy files views detected. Processing...', legacyViews)
- legacyViews.forEach(view => {
- registerLegacyView(view)
- if (view.sublist) {
- view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id }))
- }
- })
- }
-}
-
-const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded, params }) {
- OCP.Files.Navigation.register({
- id,
- name,
- order,
- params,
- parent,
- expanded: expanded === true,
- iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id,
- legacy: true,
- sticky: classes.includes('pinned'),
- })
-}
diff --git a/apps/files/src/logger.js b/apps/files/src/logger.js
deleted file mode 100644
index 39283bd331d..00000000000
--- a/apps/files/src/logger.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-import { getLoggerBuilder } from '@nextcloud/logger'
-
-export default getLoggerBuilder()
- .setApp('files')
- .detectUser()
- .build()
diff --git a/apps/files/src/logger.ts b/apps/files/src/logger.ts
new file mode 100644
index 00000000000..33f87b424e0
--- /dev/null
+++ b/apps/files/src/logger.ts
@@ -0,0 +1,10 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+export default getLoggerBuilder()
+ .setApp('files')
+ .detectUser()
+ .build()
diff --git a/apps/files/src/main-personal-settings.js b/apps/files/src/main-personal-settings.js
index 1d1942e85bb..dce190f0160 100644
--- a/apps/files/src/main-personal-settings.js
+++ b/apps/files/src/main-personal-settings.js
@@ -1,38 +1,17 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
-import PersonalSettings from './components/PersonalSettings'
+import PersonalSettings from './components/PersonalSettings.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
-
-if (!window.TESTING) {
- const View = Vue.extend(PersonalSettings)
- new View().$mount('#files-personal-settings')
-}
+const View = Vue.extend(PersonalSettings)
+const instance = new View()
+instance.$mount('#files-personal-settings')
diff --git a/apps/files/src/main.js b/apps/files/src/main.js
deleted file mode 100644
index 3099a4c619c..00000000000
--- a/apps/files/src/main.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import './templates.js'
-import './legacy/filelistSearch.js'
-import processLegacyFilesViews from './legacy/navigationMapper.js'
-
-import Vue from 'vue'
-import NavigationService from './services/Navigation.ts'
-import NavigationView from './views/Navigation.vue'
-
-import SettingsService from './services/Settings.js'
-import SettingsModel from './models/Setting.js'
-
-import router from './router/router.js'
-
-// Init private and public Files namespace
-window.OCA.Files = window.OCA.Files ?? {}
-window.OCP.Files = window.OCP.Files ?? {}
-
-// Init Navigation Service
-const Navigation = new NavigationService()
-Object.assign(window.OCP.Files, { Navigation })
-
-// Init Files App Settings Service
-const Settings = new SettingsService()
-Object.assign(window.OCA.Files, { Settings })
-Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel })
-
-// Init Navigation View
-const View = Vue.extend(NavigationView)
-const FilesNavigationRoot = new View({
- name: 'FilesNavigationRoot',
- propsData: {
- Navigation,
- },
- router,
-})
-FilesNavigationRoot.$mount('#app-navigation-files')
-
-// Init legacy files views
-processLegacyFilesViews()
diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts
new file mode 100644
index 00000000000..463ecaf6239
--- /dev/null
+++ b/apps/files/src/main.ts
@@ -0,0 +1,51 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Pinia } from 'pinia'
+import { getCSPNonce } from '@nextcloud/auth'
+import { PiniaVuePlugin } from 'pinia'
+import Vue from 'vue'
+
+import { getPinia } from './store/index.ts'
+import FilesApp from './FilesApp.vue'
+import router from './router/router'
+import RouterService from './services/RouterService'
+import SettingsModel from './models/Setting.js'
+import SettingsService from './services/Settings.js'
+
+__webpack_nonce__ = getCSPNonce()
+
+declare global {
+ interface Window {
+ OC: Nextcloud.v29.OC
+ OCP: Nextcloud.v29.OCP
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ OCA: Record<string, any>
+ _nc_files_pinia: Pinia
+ }
+}
+
+// Init private and public Files namespace
+window.OCA.Files = window.OCA.Files ?? {}
+window.OCP.Files = window.OCP.Files ?? {}
+
+// Expose router
+if (!window.OCP.Files.Router) {
+ const Router = new RouterService(router)
+ Object.assign(window.OCP.Files, { Router })
+}
+
+// Init Pinia store
+Vue.use(PiniaVuePlugin)
+
+// Init Files App Settings Service
+const Settings = new SettingsService()
+Object.assign(window.OCA.Files, { Settings })
+Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel })
+
+const FilesAppVue = Vue.extend(FilesApp)
+new FilesAppVue({
+ router: (window.OCP.Files.Router as RouterService)._router,
+ pinia: getPinia(),
+}).$mount('#content')
diff --git a/apps/files/src/mixins/actionsMixin.ts b/apps/files/src/mixins/actionsMixin.ts
new file mode 100644
index 00000000000..f81b0754431
--- /dev/null
+++ b/apps/files/src/mixins/actionsMixin.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { FileAction } from '@nextcloud/files'
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+
+ data() {
+ return {
+ openedSubmenu: null as FileAction|null,
+ }
+ },
+
+ computed: {
+ enabledSubmenuActions(): Record<string, FileAction[]> {
+ return (this.enabledFileActions as FileAction[])
+ .reduce((record, action) => {
+ if (action.parent !== undefined) {
+ if (!record[action.parent]) {
+ record[action.parent] = []
+ }
+
+ record[action.parent].push(action)
+ }
+ return record
+ }, {} as Record<string, FileAction[]>)
+ },
+ },
+
+ methods: {
+ /**
+ * Check if a menu is valid, meaning it is
+ * defined and has at least one action
+ *
+ * @param action The action to check
+ */
+ isValidMenu(action: FileAction): boolean {
+ return this.enabledSubmenuActions[action.id]?.length > 0
+ },
+
+ async onBackToMenuClick(action: FileAction|null) {
+ if (!action) {
+ return
+ }
+
+ this.openedSubmenu = null
+ // Wait for first render
+ await this.$nextTick()
+
+ // Focus the previous menu action button
+ this.$nextTick(() => {
+ // Focus the action button, test both batch and single action references
+ // as this mixin is used in both single and batch actions.
+ const menuAction = this.$refs[`action-batch-${action.id}`]?.[0]
+ || this.$refs[`action-${action.id}`]?.[0]
+ if (menuAction) {
+ menuAction.$el.querySelector('button')?.focus()
+ }
+ })
+ },
+ },
+})
diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts
new file mode 100644
index 00000000000..12515db103f
--- /dev/null
+++ b/apps/files/src/mixins/filesSorting.ts
@@ -0,0 +1,52 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import Vue from 'vue'
+
+import { mapState } from 'pinia'
+import { useViewConfigStore } from '../store/viewConfig'
+import { useNavigation } from '../composables/useNavigation'
+
+export default Vue.extend({
+ setup() {
+ const { currentView } = useNavigation()
+
+ return {
+ currentView,
+ }
+ },
+
+ computed: {
+ ...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']),
+
+ /**
+ * Get the sorting mode for the current view
+ */
+ sortingMode(): string {
+ return this.getConfig(this.currentView.id)?.sorting_mode as string
+ || this.currentView?.defaultSortKey
+ || 'basename'
+ },
+
+ /**
+ * Get the sorting direction for the current view
+ */
+ isAscSorting(): boolean {
+ const sortingDirection = this.getConfig(this.currentView.id)?.sorting_direction
+ return sortingDirection !== 'desc'
+ },
+ },
+
+ methods: {
+ toggleSortBy(key: string) {
+ // If we're already sorting by this key, flip the direction
+ if (this.sortingMode === key) {
+ this.toggleSortingDirection(this.currentView.id)
+ return
+ }
+ // else sort ASC by this new key
+ this.setSortingBy(key, this.currentView.id)
+ },
+ },
+})
diff --git a/apps/files/src/models/Setting.js b/apps/files/src/models/Setting.js
index db276da85af..1db1d818e69 100644
--- a/apps/files/src/models/Setting.js
+++ b/apps/files/src/models/Setting.js
@@ -1,24 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Gary Kim <gary@garykim.dev>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default class Setting {
@@ -27,6 +9,7 @@ export default class Setting {
_el
_name
_open
+ _order
/**
* Create a new files app setting
@@ -37,12 +20,14 @@ export default class Setting {
* @param {Function} component.el function that returns an unmounted dom element to be added
* @param {Function} [component.open] callback for when setting is added
* @param {Function} [component.close] callback for when setting is closed
+ * @param {number} [component.order] the order of this setting, lower numbers are shown first
*/
- constructor(name, { el, open, close }) {
+ constructor(name, { el, open, close, order }) {
this._name = name
this._el = el
this._open = open
this._close = close
+ this._order = order || 0
if (typeof this._open !== 'function') {
this._open = () => {}
@@ -51,6 +36,18 @@ export default class Setting {
if (typeof this._close !== 'function') {
this._close = () => {}
}
+
+ if (typeof this._el !== 'function') {
+ throw new Error('Setting must have an `el` function that returns a DOM element')
+ }
+
+ if (typeof this._name !== 'string') {
+ throw new Error('Setting must have a `name` string')
+ }
+
+ if (typeof this._order !== 'number') {
+ throw new Error('Setting must have an `order` number')
+ }
}
get name() {
@@ -69,4 +66,8 @@ export default class Setting {
return this._close
}
+ get order() {
+ return this._order
+ }
+
}
diff --git a/apps/files/src/models/Tab.js b/apps/files/src/models/Tab.js
index 63d1ad97ff6..b67d51f277f 100644
--- a/apps/files/src/models/Tab.js
+++ b/apps/files/src/models/Tab.js
@@ -1,25 +1,8 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { sanitizeSVG } from '@skjnldsv/sanitize-svg'
+import DOMPurify from 'dompurify'
export default class Tab {
@@ -28,6 +11,7 @@ export default class Tab {
_icon
_iconSvgSanitized
_mount
+ _setIsActive
_update
_destroy
_enabled
@@ -42,12 +26,13 @@ export default class Tab {
* @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, update, destroy, enabled, scrollBottomReached } = {}) {
+ constructor({ id, name, icon, iconSvg, mount, setIsActive, update, destroy, enabled, scrollBottomReached } = {}) {
if (enabled === undefined) {
enabled = () => true
}
@@ -68,6 +53,9 @@ export default class Tab {
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')
}
@@ -85,16 +73,14 @@ export default class Tab {
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') {
- sanitizeSVG(iconSvg)
- .then(sanitizedSvg => {
- this._iconSvgSanitized = sanitizedSvg
- })
+ this._iconSvgSanitized = DOMPurify.sanitize(iconSvg)
}
}
@@ -119,6 +105,10 @@ export default class Tab {
return this._mount
}
+ get setIsActive() {
+ return this._setIsActive || (() => undefined)
+ }
+
get update() {
return this._update
}
diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts
new file mode 100644
index 00000000000..f0f854d2801
--- /dev/null
+++ b/apps/files/src/newMenu/newFolder.ts
@@ -0,0 +1,91 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Entry, Node } from '@nextcloud/files'
+
+import { basename } from 'path'
+import { emit } from '@nextcloud/event-bus'
+import { getCurrentUser } from '@nextcloud/auth'
+import { Permission, Folder } from '@nextcloud/files'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+
+import FolderPlusSvg from '@mdi/svg/svg/folder-plus-outline.svg?raw'
+
+import { newNodeName } from '../utils/newNodeDialog'
+import logger from '../logger'
+
+type createFolderResponse = {
+ fileid: number
+ source: string
+}
+
+const createNewFolder = async (root: Folder, name: string): Promise<createFolderResponse> => {
+ const source = root.source + '/' + name
+ const encodedSource = root.encodedSource + '/' + encodeURIComponent(name)
+
+ const response = await axios({
+ method: 'MKCOL',
+ url: encodedSource,
+ headers: {
+ Overwrite: 'F',
+ },
+ })
+ return {
+ fileid: parseInt(response.headers['oc-fileid']),
+ source,
+ }
+}
+
+export const entry = {
+ id: 'newFolder',
+ displayName: t('files', 'New folder'),
+ enabled: (context: Folder) => Boolean(context.permissions & Permission.CREATE) && Boolean(context.permissions & Permission.READ),
+
+ // Make the svg icon color match the primary element color
+ iconSvgInline: FolderPlusSvg.replace(/viewBox/gi, 'style="color: var(--color-primary-element)" viewBox'),
+ order: 0,
+
+ async handler(context: Folder, content: Node[]) {
+ const name = await newNodeName(t('files', 'New folder'), content)
+ if (name === null) {
+ return
+ }
+ try {
+ const { fileid, source } = await createNewFolder(context, name.trim())
+
+ // Create the folder in the store
+ const folder = new Folder({
+ source,
+ id: fileid,
+ mtime: new Date(),
+ owner: context.owner,
+ permissions: Permission.ALL,
+ root: context?.root || '/files/' + getCurrentUser()?.uid,
+ // Include mount-type from parent folder as this is inherited
+ attributes: {
+ 'mount-type': context.attributes?.['mount-type'],
+ 'owner-id': context.attributes?.['owner-id'],
+ 'owner-display-name': context.attributes?.['owner-display-name'],
+ },
+ })
+
+ // Show success
+ emit('files:node:created', folder)
+ showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
+ logger.debug('Created new folder', { folder, source })
+
+ // Navigate to the new folder
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: String(fileid) },
+ { dir: context.path },
+ )
+ } catch (error) {
+ logger.error('Creating new folder failed', { error })
+ showError('Creating new folder failed')
+ }
+ },
+} as Entry
diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts
new file mode 100644
index 00000000000..356fc5e1611
--- /dev/null
+++ b/apps/files/src/newMenu/newFromTemplate.ts
@@ -0,0 +1,77 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Entry } from '@nextcloud/files'
+import type { ComponentInstance } from 'vue'
+import type { TemplateFile } from '../types.ts'
+
+import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { newNodeName } from '../utils/newNodeDialog'
+import { translate as t } from '@nextcloud/l10n'
+import Vue, { defineAsyncComponent } from 'vue'
+
+// async to reduce bundle size
+const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue'))
+let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null
+
+const getTemplatePicker = async (context: Folder) => {
+ if (TemplatePicker === null) {
+ // Create document root
+ const mountingPoint = document.createElement('div')
+ mountingPoint.id = 'template-picker'
+ document.body.appendChild(mountingPoint)
+
+ // Init vue app
+ TemplatePicker = new Vue({
+ render: (h) => h(
+ TemplatePickerVue,
+ {
+ ref: 'picker',
+ props: {
+ parent: context,
+ },
+ },
+ ),
+ methods: { open(...args) { this.$refs.picker.open(...args) } },
+ el: mountingPoint,
+ })
+ }
+ return TemplatePicker
+}
+
+/**
+ * Register all new-file-menu entries for all template providers
+ */
+export function registerTemplateEntries() {
+ const templates = loadState<TemplateFile[]>('files', 'templates', [])
+
+ // Init template files menu
+ templates.forEach((provider, index) => {
+ addNewFileMenuEntry({
+ id: `template-new-${provider.app}-${index}`,
+ displayName: provider.label,
+ iconClass: provider.iconClass || 'icon-file',
+ iconSvgInline: provider.iconSvgInline,
+ enabled(context: Folder): boolean {
+ return (context.permissions & Permission.CREATE) !== 0
+ },
+ order: 11,
+ async handler(context: Folder, content: Node[]) {
+ const templatePicker = getTemplatePicker(context)
+ const name = await newNodeName(`${provider.label}${provider.extension}`, content, {
+ label: t('files', 'Filename'),
+ name: provider.label,
+ })
+
+ if (name !== null) {
+ // Create the file
+ const picker = await templatePicker
+ picker.open(name.trim(), provider)
+ }
+ },
+ } as Entry)
+ })
+}
diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts
new file mode 100644
index 00000000000..bf6862bda08
--- /dev/null
+++ b/apps/files/src/newMenu/newTemplatesFolder.ts
@@ -0,0 +1,83 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Entry, Folder, Node } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError } from '@nextcloud/dialogs'
+import { Permission, removeNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { join } from 'path'
+import { newNodeName } from '../utils/newNodeDialog'
+
+import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
+import axios from '@nextcloud/axios'
+import logger from '../logger.ts'
+
+const templatesEnabled = loadState<boolean>('files', 'templates_enabled', true)
+let templatesPath = loadState<string|false>('files', 'templates_path', false)
+logger.debug('Templates folder enabled', { templatesEnabled })
+logger.debug('Initial templates folder', { templatesPath })
+
+/**
+ * Init template folder
+ * @param directory Folder where to create the templates folder
+ * @param name Name to use or the templates folder
+ */
+const initTemplatesFolder = async function(directory: Folder, name: string) {
+ const templatePath = join(directory.path, name)
+ try {
+ logger.debug('Initializing the templates directory', { templatePath })
+ const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
+ templatePath,
+ copySystemTemplates: true,
+ })
+
+ // Go to template directory
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: undefined },
+ { dir: templatePath },
+ )
+
+ logger.info('Created new templates folder', {
+ ...data.ocs.data,
+ })
+ templatesPath = data.ocs.data.templates_path as string
+ } catch (error) {
+ logger.error('Unable to initialize the templates directory')
+ showError(t('files', 'Unable to initialize the templates directory'))
+ }
+}
+
+export const entry = {
+ id: 'template-picker',
+ displayName: t('files', 'Create templates folder'),
+ iconSvgInline: PlusSvg,
+ order: 30,
+ enabled(context: Folder): boolean {
+ // Templates disabled or templates folder already initialized
+ if (!templatesEnabled || templatesPath) {
+ return false
+ }
+ // Allow creation on your own folders only
+ if (context.owner !== getCurrentUser()?.uid) {
+ return false
+ }
+ return (context.permissions & Permission.CREATE) !== 0
+ },
+ async handler(context: Folder, content: Node[]) {
+ const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') })
+
+ if (name !== null) {
+ // Create the template folder
+ initTemplatesFolder(context, name)
+
+ // Remove the menu entry
+ removeNewFileMenuEntry('template-picker')
+ }
+ },
+} as Entry
diff --git a/apps/files/src/plugins/search/folderSearch.ts b/apps/files/src/plugins/search/folderSearch.ts
new file mode 100644
index 00000000000..6aabefbfc9d
--- /dev/null
+++ b/apps/files/src/plugins/search/folderSearch.ts
@@ -0,0 +1,61 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+import { emit } from '@nextcloud/event-bus'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { imagePath } from '@nextcloud/router'
+import { translate as t } from '@nextcloud/l10n'
+import logger from '../../logger'
+
+/**
+ * Initialize the unified search plugin.
+ */
+function init() {
+ const OCA = window.OCA
+ if (!OCA.UnifiedSearch) {
+ return
+ }
+
+ logger.info('Initializing unified search plugin: folder search from files app')
+ OCA.UnifiedSearch.registerFilterAction({
+ id: 'in-folder',
+ appId: 'files',
+ searchFrom: 'files',
+ label: t('files', 'In folder'),
+ icon: imagePath('files', 'app.svg'),
+ callback: (showFilePicker: boolean = true) => {
+ if (showFilePicker) {
+ const filepicker = getFilePickerBuilder('Pick plain text files')
+ .addMimeTypeFilter('httpd/unix-directory')
+ .allowDirectories(true)
+ .addButton({
+ label: 'Pick',
+ callback: (nodes: Node[]) => {
+ logger.info('Folder picked', { folder: nodes[0] })
+ const folder = nodes[0]
+ const filterUpdateText = (folder.root === '/files/' + folder.basename)
+ ? t('files', 'Search in all files')
+ : t('files', 'Search in folder: {folder}', { folder: folder.basename })
+ emit('nextcloud:unified-search:add-filter', {
+ id: 'in-folder',
+ appId: 'files',
+ searchFrom: 'files',
+ payload: folder,
+ filterUpdateText,
+ filterParams: { path: folder.path },
+ })
+ },
+ })
+ .build()
+ filepicker.pick()
+ } else {
+ logger.debug('Folder search callback was handled without showing the file picker, it might already be open')
+ }
+ },
+ })
+}
+
+document.addEventListener('DOMContentLoaded', init)
diff --git a/apps/files/src/reference-files.ts b/apps/files/src/reference-files.ts
new file mode 100644
index 00000000000..3d089fe93c4
--- /dev/null
+++ b/apps/files/src/reference-files.ts
@@ -0,0 +1,43 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import { t } from '@nextcloud/l10n'
+
+import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/components/NcRichText'
+
+import FileWidget from './views/ReferenceFileWidget.vue'
+import FileReferencePickerElement from './views/FileReferencePickerElement.vue'
+
+Vue.mixin({
+ methods: {
+ t,
+ },
+})
+
+registerWidget('file', (el, { richObjectType, richObject, accessible, interactive }) => {
+ const Widget = Vue.extend(FileWidget)
+ new Widget({
+ propsData: {
+ richObjectType,
+ richObject,
+ accessible,
+ interactive,
+ },
+ }).$mount(el)
+}, () => {}, { hasInteractiveView: true })
+
+registerCustomPickerElement('files', (el, { providerId, accessible }) => {
+ const Element = Vue.extend(FileReferencePickerElement)
+ const vueElement = new Element({
+ propsData: {
+ providerId,
+ accessible,
+ },
+ }).$mount(el)
+ return new NcCustomPickerRenderResult(vueElement.$el, vueElement)
+}, (el, renderResult) => {
+ renderResult.object.$destroy()
+})
diff --git a/apps/files/src/router/router.js b/apps/files/src/router/router.js
deleted file mode 100644
index cf5e5ec5ea8..00000000000
--- a/apps/files/src/router/router.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-import Vue from 'vue'
-import Router from 'vue-router'
-import { generateUrl } from '@nextcloud/router'
-import { stringify } from 'query-string'
-
-Vue.use(Router)
-
-const router = new Router({
- mode: 'history',
-
- // if index.php is in the url AND we got this far, then it's working:
- // let's keep using index.php in the url
- base: generateUrl('/apps/files', ''),
- linkActiveClass: 'active',
-
- routes: [
- {
- path: '/',
- // Pretending we're using the default view
- alias: '/files',
- },
- {
- path: '/:view/:fileid?',
- name: 'filelist',
- props: true,
- },
- ],
-
- // Custom stringifyQuery to prevent encoding of slashes in the url
- stringifyQuery(query) {
- const result = stringify(query).replace(/%2F/gmi, '/')
- return result ? ('?' + result) : ''
- },
-})
-
-export default router
diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts
new file mode 100644
index 00000000000..fccb4a0a2b2
--- /dev/null
+++ b/apps/files/src/router/router.ts
@@ -0,0 +1,145 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { RawLocation, Route } from 'vue-router'
+
+import { generateUrl } from '@nextcloud/router'
+import { relative } from 'path'
+import queryString from 'query-string'
+import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
+import Vue from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { defaultView } from '../utils/filesViews.ts'
+import logger from '../logger.ts'
+
+Vue.use(Router)
+
+// Prevent router from throwing errors when we're already on the page we're trying to go to
+const originalPush = Router.prototype.push
+Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) {
+ if (args.length > 1) {
+ return originalPush.call(this, ...args)
+ }
+ return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalPush
+
+const originalReplace = Router.prototype.replace
+Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) {
+ if (args.length > 1) {
+ return originalReplace.call(this, ...args)
+ }
+ return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalReplace
+
+/**
+ * Ignore duplicated-navigation error but forward real exceptions
+ * @param error The thrown error
+ */
+function ignoreDuplicateNavigation(error: unknown): void {
+ if (isNavigationFailure(error, NavigationFailureType.duplicated)) {
+ logger.debug('Ignoring duplicated navigation from vue-router', { error })
+ } else {
+ throw error
+ }
+}
+
+const router = new Router({
+ mode: 'history',
+
+ // if index.php is in the url AND we got this far, then it's working:
+ // let's keep using index.php in the url
+ base: generateUrl('/apps/files'),
+ linkActiveClass: 'active',
+
+ routes: [
+ {
+ path: '/',
+ // Pretending we're using the default view
+ redirect: { name: 'filelist', params: { view: defaultView() } },
+ },
+ {
+ path: '/:view/:fileid(\\d+)?',
+ name: 'filelist',
+ props: true,
+ },
+ ],
+
+ // Custom stringifyQuery to prevent encoding of slashes in the url
+ stringifyQuery(query) {
+ const result = queryString.stringify(query).replace(/%2F/gmi, '/')
+ return result ? ('?' + result) : ''
+ },
+})
+
+// Handle aborted navigation (NavigationGuards) gracefully
+router.onError((error) => {
+ if (isNavigationFailure(error, NavigationFailureType.aborted)) {
+ logger.debug('Navigation was aboorted', { error })
+ } else {
+ throw error
+ }
+})
+
+// If navigating back from a folder to a parent folder,
+// we need to keep the current dir fileid so it's highlighted
+// and scrolled into view.
+router.beforeResolve((to, from, next) => {
+ if (to.params?.parentIntercept) {
+ delete to.params.parentIntercept
+ return next()
+ }
+
+ if (to.params.view !== from.params.view) {
+ // skip if different views
+ return next()
+ }
+
+ const fromDir = (from.query?.dir || '/') as string
+ const toDir = (to.query?.dir || '/') as string
+
+ // We are going back to a parent directory
+ if (relative(fromDir, toDir) === '..') {
+ const { getNode } = useFilesStore()
+ const { getPath } = usePathsStore()
+
+ if (!from.params.view) {
+ logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir })
+ return next()
+ }
+
+ // Get the previous parent's file id
+ const fromSource = getPath(from.params.view, fromDir)
+ if (!fromSource) {
+ logger.error('No source found for the parent directory', { fromDir, toDir })
+ return next()
+ }
+
+ const fileId = getNode(fromSource)?.fileid
+ if (!fileId) {
+ logger.error('No fileid found for the parent directory', { fromDir, toDir, fromSource })
+ return next()
+ }
+
+ logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId })
+ return next({
+ name: 'filelist',
+ query: to.query,
+ params: {
+ ...to.params,
+ fileid: String(fileId),
+ // Prevents the beforeEach from being called again
+ parentIntercept: 'true',
+ },
+ // Replace the current history entry
+ replace: true,
+ })
+ }
+
+ // else, we just continue
+ next()
+})
+
+export default router
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
new file mode 100644
index 00000000000..1013baeda6c
--- /dev/null
+++ b/apps/files/src/services/DropService.ts
@@ -0,0 +1,198 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Upload } from '@nextcloud/upload'
+import type { RootDirectory } from './DropServiceUtils'
+
+import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
+import { getUploader, hasConflict } from '@nextcloud/upload'
+import { join } from 'path'
+import { joinPaths } from '@nextcloud/paths'
+import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils'
+import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction'
+import { MoveCopyAction } from '../actions/moveOrCopyActionUtils'
+import logger from '../logger.ts'
+
+/**
+ * This function converts a list of DataTransferItems to a file tree.
+ * It uses the Filesystem API if available, otherwise it falls back to the File API.
+ * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
+ * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems
+ * will be cleared after the first access to the props of one of the entries.
+ *
+ * @param items the list of DataTransferItems
+ */
+export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
+ // Check if the browser supports the Filesystem API
+ // We need to cache the entries to prevent Blink engine bug that clears
+ // the list (`data.items`) after first access props of one of the entries
+ const entries = items
+ .filter((item) => {
+ if (item.kind !== 'file') {
+ logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
+ return false
+ }
+ return true
+ }).map((item) => {
+ // MDN recommends to try both, as it might be renamed in the future
+ return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
+ ?? item?.webkitGetAsEntry?.()
+ ?? item
+ }) as (FileSystemEntry | DataTransferItem)[]
+
+ let warned = false
+ const fileTree = new Directory('root') as RootDirectory
+
+ // Traverse the file tree
+ for (const entry of entries) {
+ // Handle browser issues if Filesystem API is not available. Fallback to File API
+ if (entry instanceof DataTransferItem) {
+ logger.warn('Could not get FilesystemEntry of item, falling back to file')
+
+ const file = entry.getAsFile()
+ if (file === null) {
+ logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
+ showError(t('files', 'One of the dropped files could not be processed'))
+ continue
+ }
+
+ // Warn the user that the browser does not support the Filesystem API
+ // we therefore cannot upload directories recursively.
+ if (file.type === 'httpd/unix-directory' || !file.type) {
+ if (!warned) {
+ logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
+ showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded'))
+ warned = true
+ }
+ continue
+ }
+
+ fileTree.contents.push(file)
+ continue
+ }
+
+ // Use Filesystem API
+ try {
+ fileTree.contents.push(await traverseTree(entry))
+ } catch (error) {
+ // Do not throw, as we want to continue with the other files
+ logger.error('Error while traversing file tree', { error })
+ }
+ }
+
+ return fileTree
+}
+
+export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
+ const uploader = getUploader()
+
+ // Check for conflicts on root elements
+ if (await hasConflict(root.contents, contents)) {
+ root.contents = await resolveConflict(root.contents, destination, contents)
+ }
+
+ if (root.contents.length === 0) {
+ logger.info('No files to upload', { root })
+ showInfo(t('files', 'No files to upload'))
+ return []
+ }
+
+ // Let's process the files
+ logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
+ const queue = [] as Promise<Upload>[]
+
+ const uploadDirectoryContents = async (directory: Directory, path: string) => {
+ for (const file of directory.contents) {
+ // This is the relative path to the resource
+ // from the current uploader destination
+ const relativePath = join(path, file.name)
+
+ // If the file is a directory, we need to create it first
+ // then browse its tree and upload its contents.
+ if (file instanceof Directory) {
+ const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
+ try {
+ console.debug('Processing directory', { relativePath })
+ await createDirectoryIfNotExists(absolutePath)
+ await uploadDirectoryContents(file, relativePath)
+ } catch (error) {
+ showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
+ logger.error('', { error, absolutePath, directory: file })
+ }
+ continue
+ }
+
+ // If we've reached a file, we can upload it
+ logger.debug('Uploading file to ' + join(destination.path, relativePath), { file })
+
+ // Overriding the root to avoid changing the current uploader context
+ queue.push(uploader.upload(relativePath, file, destination.source))
+ }
+ }
+
+ // Pause the uploader to prevent it from starting
+ // while we compute the queue
+ uploader.pause()
+
+ // Upload the files. Using '/' as the starting point
+ // as we already adjusted the uploader destination
+ await uploadDirectoryContents(root, '/')
+ uploader.start()
+
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+
+ // Check for errors
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while uploading files', { errors })
+ showError(t('files', 'Some files could not be uploaded'))
+ return []
+ }
+
+ logger.debug('Files uploaded successfully')
+ showSuccess(t('files', 'Files uploaded successfully'))
+
+ return Promise.all(queue)
+}
+
+export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
+ const queue = [] as Promise<void>[]
+
+ // Check for conflicts on root elements
+ if (await hasConflict(nodes, contents)) {
+ nodes = await resolveConflict(nodes, destination, contents)
+ }
+
+ if (nodes.length === 0) {
+ logger.info('No files to process', { nodes })
+ showInfo(t('files', 'No files to process'))
+ return
+ }
+
+ for (const node of nodes) {
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
+ }
+
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+ nodes.forEach(node => Vue.set(node, 'status', undefined))
+
+ // Check for errors
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while copying or moving files', { errors })
+ showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
+ return
+ }
+
+ logger.debug('Files copy/move successful')
+ showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
+}
diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts
new file mode 100644
index 00000000000..5f4370c7894
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.spec.ts
@@ -0,0 +1,143 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
+import { join } from 'node:path'
+import { Directory, traverseTree } from './DropServiceUtils'
+import { dataTransferToFileTree } from './DropService'
+import logger from '../logger'
+
+const dataTree = {
+ 'file0.txt': ['Hello, world!', 1234567890],
+ dir1: {
+ 'file1.txt': ['Hello, world!', 4567891230],
+ 'file2.txt': ['Hello, world!', 7891234560],
+ },
+ dir2: {
+ 'file3.txt': ['Hello, world!', 1234567890],
+ },
+}
+
+// This is mocking a file tree using the FileSystem API
+const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => {
+ const entries = Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ return new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ } else {
+ return buildFileSystemDirectoryEntry(fullPath, contents)
+ }
+ })
+ return new FileSystemDirectoryEntry(path, entries)
+}
+
+const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => {
+ return Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ }
+
+ const entry = buildFileSystemDirectoryEntry(fullPath, contents)
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ })
+}
+
+describe('Filesystem API traverseTree', () => {
+ it('Should traverse a file tree from root', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('root')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(3)
+ expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should traverse a file tree from a subdirectory', async () => {
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2)
+ const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('dir2')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(1)
+ expect(tree.contents[0].name).toBe('file3.txt')
+ expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should properly compute the last modified', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(rootTree.lastModified).toBe(7891234560)
+
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2)
+ const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+ expect(dir2Tree.lastModified).toBe(1234567890)
+ })
+})
+
+describe('DropService dataTransferToFileTree', () => {
+
+ beforeAll(() => {
+ // @ts-expect-error jsdom doesn't have DataTransferItem
+ delete window.DataTransferItem
+ // DataTransferItem doesn't exists in jsdom, let's mock
+ // a dumb one so we can check the instanceof
+ // @ts-expect-error jsdom doesn't have DataTransferItem
+ window.DataTransferItem = DataTransferItemMock
+ })
+
+ it('Should return a RootDirectory with Filesystem API', async () => {
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+ vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn())
+
+ const dataTransferItems = buildDataTransferItemArray('root', dataTree)
+ const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(3)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[1]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[1] as Directory).contents).toHaveLength(2)
+ expect(fileTree.contents[2]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[2] as Directory).contents).toHaveLength(1)
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).not.toBeCalled()
+ })
+
+ it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+ vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn())
+
+ const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
+
+ const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(1)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[0]).not.toBeInstanceOf(Directory)
+ expect((fileTree.contents[0].name)).toBe('file0.txt')
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded')
+ expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenCalledTimes(4)
+ })
+})
diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts
new file mode 100644
index 00000000000..f10a09cfe27
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.ts
@@ -0,0 +1,178 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { emit } from '@nextcloud/event-bus'
+import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
+import { openConflictPicker } from '@nextcloud/upload'
+import { showError, showInfo } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+import logger from '../logger.ts'
+
+/**
+ * This represents a Directory in the file tree
+ * We extend the File class to better handling uploading
+ * and stay as close as possible as the Filesystem API.
+ * This also allow us to hijack the size or lastModified
+ * properties to compute them dynamically.
+ */
+export class Directory extends File {
+
+ /* eslint-disable no-use-before-define */
+ _contents: (Directory|File)[]
+
+ constructor(name, contents: (Directory|File)[] = []) {
+ super([], name, { type: 'httpd/unix-directory' })
+ this._contents = contents
+ }
+
+ set contents(contents: (Directory|File)[]) {
+ this._contents = contents
+ }
+
+ get contents(): (Directory|File)[] {
+ return this._contents
+ }
+
+ get size() {
+ return this._computeDirectorySize(this)
+ }
+
+ get lastModified() {
+ if (this._contents.length === 0) {
+ return Date.now()
+ }
+ return this._computeDirectoryMtime(this)
+ }
+
+ /**
+ * Get the last modification time of a file tree
+ * This is not perfect, but will get us a pretty good approximation
+ * @param directory the directory to traverse
+ */
+ _computeDirectoryMtime(directory: Directory): number {
+ return directory.contents.reduce((acc, file) => {
+ return file.lastModified > acc
+ // If the file is a directory, the lastModified will
+ // also return the results of its _computeDirectoryMtime method
+ // Fancy recursion, huh?
+ ? file.lastModified
+ : acc
+ }, 0)
+ }
+
+ /**
+ * Get the size of a file tree
+ * @param directory the directory to traverse
+ */
+ _computeDirectorySize(directory: Directory): number {
+ return directory.contents.reduce((acc: number, entry: Directory|File) => {
+ // If the file is a directory, the size will
+ // also return the results of its _computeDirectorySize method
+ // Fancy recursion, huh?
+ return acc + entry.size
+ }, 0)
+ }
+
+}
+
+export type RootDirectory = Directory & {
+ name: 'root'
+}
+
+/**
+ * Traverse a file tree using the Filesystem API
+ * @param entry the entry to traverse
+ */
+export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
+ // Handle file
+ if (entry.isFile) {
+ return new Promise<File>((resolve, reject) => {
+ (entry as FileSystemFileEntry).file(resolve, reject)
+ })
+ }
+
+ // Handle directory
+ logger.debug('Handling recursive file tree', { entry: entry.name })
+ const directory = entry as FileSystemDirectoryEntry
+ const entries = await readDirectory(directory)
+ const contents = (await Promise.all(entries.map(traverseTree))).flat()
+ return new Directory(directory.name, contents)
+}
+
+/**
+ * Read a directory using Filesystem API
+ * @param directory the directory to read
+ */
+const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
+ const dirReader = directory.createReader()
+
+ return new Promise<FileSystemEntry[]>((resolve, reject) => {
+ const entries = [] as FileSystemEntry[]
+ const getEntries = () => {
+ dirReader.readEntries((results) => {
+ if (results.length) {
+ entries.push(...results)
+ getEntries()
+ } else {
+ resolve(entries)
+ }
+ }, (error) => {
+ reject(error)
+ })
+ }
+
+ getEntries()
+ })
+}
+
+export const createDirectoryIfNotExists = async (absolutePath: string) => {
+ const davClient = davGetClient()
+ const dirExists = await davClient.exists(absolutePath)
+ if (!dirExists) {
+ logger.debug('Directory does not exist, creating it', { absolutePath })
+ await davClient.createDirectory(absolutePath, { recursive: true })
+ const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
+ emit('files:node:created', davResultToNode(stat.data))
+ }
+}
+
+export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
+ try {
+ // List all conflicting files
+ const conflicts = files.filter((file: File|Node) => {
+ return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
+ }).filter(Boolean) as (File|Node)[]
+
+ // List of incoming files that are NOT in conflict
+ const uploads = files.filter((file: File|Node) => {
+ return !conflicts.includes(file)
+ })
+
+ // Let the user choose what to do with the conflicting files
+ const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
+
+ logger.debug('Conflict resolution', { uploads, selected, renamed })
+
+ // If the user selected nothing, we cancel the upload
+ if (selected.length === 0 && renamed.length === 0) {
+ // User skipped
+ showInfo(t('files', 'Conflicts resolution skipped'))
+ logger.info('User skipped the conflict resolution')
+ return []
+ }
+
+ // Update the list of files to upload
+ return [...uploads, ...selected, ...renamed] as (typeof files)
+ } catch (error) {
+ console.error(error)
+ // User cancelled
+ showError(t('files', 'Upload cancelled'))
+ logger.error('User cancelled the upload')
+ }
+
+ return []
+}
diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts
new file mode 100644
index 00000000000..e156c92c511
--- /dev/null
+++ b/apps/files/src/services/Favorites.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission, davRemoteURL, davRootPath, getFavoriteNodes } from '@nextcloud/files'
+import { CancelablePromise } from 'cancelable-promise'
+import { getContents as filesContents } from './Files.ts'
+import { client } from './WebdavClient.ts'
+
+export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
+ // We only filter root files for favorites, for subfolders we can simply reuse the files contents
+ if (path !== '/') {
+ return filesContents(path)
+ }
+
+ return new CancelablePromise((resolve, reject, cancel) => {
+ const promise = getFavoriteNodes(client)
+ .catch(reject)
+ .then((contents) => {
+ if (!contents) {
+ reject()
+ return
+ }
+ resolve({
+ contents,
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.READ,
+ }),
+ })
+ })
+ cancel(() => promise.cancel())
+ })
+}
diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js
deleted file mode 100644
index c09af45f495..00000000000
--- a/apps/files/src/services/FileInfo.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import axios from '@nextcloud/axios'
-
-/**
- * @param {any} url -
- */
-export default async function(url) {
- const response = await axios({
- method: 'PROPFIND',
- url,
- data: `<?xml version="1.0"?>
- <d:propfind xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:prop>
- <d:getlastmodified />
- <d:getetag />
- <d:getcontenttype />
- <d:resourcetype />
- <oc:fileid />
- <oc:permissions />
- <oc:size />
- <d:getcontentlength />
- <nc:has-preview />
- <nc:mount-type />
- <nc:is-encrypted />
- <ocs:share-permissions />
- <nc:share-attributes />
- <oc:tags />
- <oc:favorite />
- <oc:comments-unread />
- <oc:owner-id />
- <oc:owner-display-name />
- <oc:share-types />
- </d:prop>
- </d:propfind>`,
- })
-
- // TODO: create new parser or use cdav-lib when available
- const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data)
- // TODO: create new parser or use cdav-lib when available
- const fileInfo = OCA.Files.App.fileList.filesClient._parseFileInfo(file[0])
-
- // TODO remove when no more legacy backbone is used
- fileInfo.get = (key) => fileInfo[key]
- fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory'
-
- return fileInfo
-}
diff --git a/apps/files/src/services/FileInfo.ts b/apps/files/src/services/FileInfo.ts
new file mode 100644
index 00000000000..318236f1677
--- /dev/null
+++ b/apps/files/src/services/FileInfo.ts
@@ -0,0 +1,36 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/* eslint-disable jsdoc/require-jsdoc */
+
+import type { Node } from '@nextcloud/files'
+
+export default function(node: Node) {
+ const fileInfo = new OC.Files.FileInfo({
+ id: node.fileid,
+ path: node.dirname,
+ name: node.basename,
+ mtime: node.mtime?.getTime(),
+ etag: node.attributes.etag,
+ size: node.size,
+ hasPreview: node.attributes.hasPreview,
+ isEncrypted: node.attributes.isEncrypted === 1,
+ isFavourited: node.attributes.favorite === 1,
+ mimetype: node.mime,
+ permissions: node.permissions,
+ mountType: node.attributes['mount-type'],
+ sharePermissions: node.attributes['share-permissions'],
+ shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'),
+ type: node.type === 'file' ? 'file' : 'dir',
+ attributes: node.attributes,
+ })
+
+ // TODO remove when no more legacy backbone is used
+ fileInfo.get = (key) => fileInfo[key]
+ fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory'
+ fileInfo.canEdit = () => Boolean(fileInfo.permissions & OC.PERMISSION_UPDATE)
+
+ return fileInfo
+}
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
new file mode 100644
index 00000000000..080ce91e538
--- /dev/null
+++ b/apps/files/src/services/Files.ts
@@ -0,0 +1,110 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files'
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav'
+import { CancelablePromise } from 'cancelable-promise'
+import { join } from 'path'
+import { client } from './WebdavClient.ts'
+import { searchNodes } from './WebDavSearch.ts'
+import { getPinia } from '../store/index.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSearchStore } from '../store/search.ts'
+import logger from '../logger.ts'
+/**
+ * Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
+ * @param stat The result returned by the webdav library
+ */
+export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
+
+/**
+ * Get contents implementation for the files view.
+ * This also allows to fetch local search results when the user is currently filtering.
+ *
+ * @param path - The path to query
+ */
+export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
+ const controller = new AbortController()
+ const searchStore = useSearchStore(getPinia())
+
+ if (searchStore.query.length >= 3) {
+ return new CancelablePromise((resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ getLocalSearch(path, searchStore.query, controller.signal)
+ .then(resolve)
+ .catch(reject)
+ })
+ } else {
+ return defaultGetContents(path)
+ }
+}
+
+/**
+ * Generic `getContents` implementation for the users files.
+ *
+ * @param path - The path to get the contents
+ */
+export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> {
+ path = join(defaultRootPath, path)
+ const controller = new AbortController()
+ const propfindPayload = getDefaultPropfind()
+
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ onCancel(() => controller.abort())
+
+ try {
+ const contentsResponse = await client.getDirectoryContents(path, {
+ details: true,
+ data: propfindPayload,
+ includeSelf: true,
+ signal: controller.signal,
+ }) as ResponseDataDetailed<FileStat[]>
+
+ const root = contentsResponse.data[0]
+ const contents = contentsResponse.data.slice(1)
+ if (root.filename !== path && `${root.filename}/` !== path) {
+ logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`)
+ throw new Error('Root node does not match requested path')
+ }
+
+ resolve({
+ folder: resultToNode(root) as Folder,
+ contents: contents.map((result) => {
+ try {
+ return resultToNode(result)
+ } catch (error) {
+ logger.error(`Invalid node detected '${result.basename}'`, { error })
+ return null
+ }
+ }).filter(Boolean) as File[],
+ })
+ } catch (error) {
+ reject(error)
+ }
+ })
+}
+
+/**
+ * Get the local search results for the current folder.
+ *
+ * @param path - The path
+ * @param query - The current search query
+ * @param signal - The aboort signal
+ */
+async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> {
+ const filesStore = useFilesStore(getPinia())
+ let folder = filesStore.getDirectoryByPath('files', path)
+ if (!folder) {
+ const rootPath = join(defaultRootPath, path)
+ const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat>
+ folder = resultToNode(stat.data) as Folder
+ }
+ const contents = await searchNodes(query, { dir: path, signal })
+ return {
+ folder,
+ contents,
+ }
+}
diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts
new file mode 100644
index 00000000000..82f0fb392e5
--- /dev/null
+++ b/apps/files/src/services/FolderTree.ts
@@ -0,0 +1,95 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { CancelablePromise } from 'cancelable-promise'
+import { davRemoteURL } from '@nextcloud/files'
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { dirname, encodePath, joinPaths } from '@nextcloud/paths'
+import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+
+import { getContents as getFiles } from './Files.ts'
+
+// eslint-disable-next-line no-use-before-define
+type Tree = TreeNodeData[]
+
+interface TreeNodeData {
+ id: number,
+ basename: string,
+ displayName?: string,
+ children: Tree,
+}
+
+export interface TreeNode {
+ source: string,
+ encodedSource: string,
+ path: string,
+ fileid: number,
+ basename: string,
+ displayName?: string,
+}
+
+export const folderTreeId = 'folders'
+
+export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
+
+const collator = Intl.Collator(
+ [getLanguage(), getCanonicalLocale()],
+ {
+ numeric: true,
+ usage: 'sort',
+ },
+)
+
+const compareNodes = (a: TreeNodeData, b: TreeNodeData) => collator.compare(a.displayName ?? a.basename, b.displayName ?? b.basename)
+
+const getTreeNodes = (tree: Tree, currentPath: string = '/', nodes: TreeNode[] = []): TreeNode[] => {
+ const sortedTree = tree.toSorted(compareNodes)
+ for (const { id, basename, displayName, children } of sortedTree) {
+ const path = joinPaths(currentPath, basename)
+ const source = `${sourceRoot}${path}`
+ const node: TreeNode = {
+ source,
+ encodedSource: encodeSource(source),
+ path,
+ fileid: id,
+ basename,
+ }
+ if (displayName) {
+ node.displayName = displayName
+ }
+ nodes.push(node)
+ if (children.length > 0) {
+ getTreeNodes(children, path, nodes)
+ }
+ }
+ return nodes
+}
+
+export const getFolderTreeNodes = async (path: string = '/', depth: number = 1): Promise<TreeNode[]> => {
+ const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'), {
+ params: new URLSearchParams({ path, depth: String(depth) }),
+ })
+ const nodes = getTreeNodes(tree, path)
+ return nodes
+}
+
+export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path)
+
+export const encodeSource = (source: string): string => {
+ const { origin } = new URL(source)
+ return origin + encodePath(source.slice(origin.length))
+}
+
+export const getSourceParent = (source: string): string => {
+ const parent = dirname(source)
+ if (parent === sourceRoot) {
+ return folderTreeId
+ }
+ return encodeSource(parent)
+}
diff --git a/apps/files/src/services/LivePhotos.ts b/apps/files/src/services/LivePhotos.ts
new file mode 100644
index 00000000000..10be42444e2
--- /dev/null
+++ b/apps/files/src/services/LivePhotos.ts
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { Node, registerDavProperty } from '@nextcloud/files'
+
+/**
+ *
+ */
+export function initLivePhotos(): void {
+ registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' })
+}
+
+/**
+ * @param {Node} node - The node
+ */
+export function isLivePhoto(node: Node): boolean {
+ return node.attributes['metadata-files-live-photo'] !== undefined
+}
diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts
deleted file mode 100644
index e3286c79a88..00000000000
--- a/apps/files/src/services/Navigation.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-import type Node from '@nextcloud/files/dist/files/node'
-import isSvg from 'is-svg'
-
-import logger from '../logger'
-
-export interface Column {
- /** Unique column ID */
- id: string
- /** Translated column title */
- title: string
- /** Property key from Node main or additional attributes.
- Will be used if no custom sort function is provided.
- Sorting will be done by localCompare */
- property: string
- /** Special function used to sort Nodes between them */
- sortFunction?: (nodeA: Node, nodeB: Node) => number;
- /** Custom summary of the column to display at the end of the list.
- Will not be displayed if nothing is provided */
- summary?: (node: Node[]) => string
-}
-
-export interface Navigation {
- /** Unique view ID */
- id: string
- /** Translated view name */
- name: string
- /** Method return the content of the provided path */
- getFiles: (path: string) => Node[]
- /** The view icon as an inline svg */
- icon: string
- /** The view order */
- order: number
- /** This view column(s). Name and actions are
- by default always included */
- columns?: Column[]
- /** The empty view element to render your empty content into */
- emptyView?: (div: HTMLDivElement) => void
- /** The parent unique ID */
- parent?: string
- /** This view is sticky (sent at the bottom) */
- sticky?: boolean
- /** This view has children and is expanded or not */
- expanded?: boolean
-
- /**
- * This view is sticky a legacy view.
- * Here until all the views are migrated to Vue.
- * @deprecated It will be removed in a near future
- */
- legacy?: boolean
- /**
- * An icon class.
- * @deprecated It will be removed in a near future
- */
- iconClass?: string
-}
-
-export default class {
-
- private _views: Navigation[] = []
- private _currentView: Navigation | null = null
-
- constructor() {
- logger.debug('Navigation service initialized')
- }
-
- register(view: Navigation) {
- try {
- isValidNavigation(view)
- isUniqueNavigation(view, this._views)
- } catch (e) {
- if (e instanceof Error) {
- logger.error(e.message, { view })
- }
- throw e
- }
-
- if (view.legacy) {
- logger.warn('Legacy view detected, please migrate to Vue')
- }
-
- if (view.iconClass) {
- view.legacy = true
- }
-
- this._views.push(view)
- }
-
- get views(): Navigation[] {
- return this._views
- }
-
- setActive(view: Navigation | null) {
- this._currentView = view
- }
-
- get active(): Navigation | null {
- return this._currentView
- }
-
-}
-
-/**
- * Make sure the given view is unique
- * and not already registered.
- */
-const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean {
- if (views.find(search => search.id === view.id)) {
- throw new Error(`Navigation id ${view.id} is already registered`)
- }
- return true
-}
-
-/**
- * Typescript cannot validate an interface.
- * Please keep in sync with the Navigation interface requirements.
- */
-const isValidNavigation = function(view: Navigation): boolean {
- if (!view.id || typeof view.id !== 'string') {
- throw new Error('Navigation id is required and must be a string')
- }
-
- if (!view.name || typeof view.name !== 'string') {
- throw new Error('Navigation name is required and must be a string')
- }
-
- /**
- * Legacy handle their content and icon differently
- * TODO: remove when support for legacy views is removed
- */
- if (!view.legacy) {
- if (!view.getFiles || typeof view.getFiles !== 'function') {
- throw new Error('Navigation getFiles is required and must be a function')
- }
-
- if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
- throw new Error('Navigation icon is required and must be a valid svg string')
- }
- }
-
- if (!('order' in view) || typeof view.order !== 'number') {
- throw new Error('Navigation order is required and must be a number')
- }
-
- // Optional properties
- if (view.columns) {
- view.columns.forEach(isValidColumn)
- }
-
- if (view.emptyView && typeof view.emptyView !== 'function') {
- throw new Error('Navigation emptyView must be a function')
- }
-
- if (view.parent && typeof view.parent !== 'string') {
- throw new Error('Navigation parent must be a string')
- }
-
- if ('sticky' in view && typeof view.sticky !== 'boolean') {
- throw new Error('Navigation sticky must be a boolean')
- }
-
- if ('expanded' in view && typeof view.expanded !== 'boolean') {
- throw new Error('Navigation expanded must be a boolean')
- }
-
- return true
-}
-
-/**
- * Typescript cannot validate an interface.
- * Please keep in sync with the Column interface requirements.
- */
-const isValidColumn = function(column: Column): boolean {
- if (!column.id || typeof column.id !== 'string') {
- throw new Error('Column id is required')
- }
-
- if (!column.title || typeof column.title !== 'string') {
- throw new Error('Column title is required')
- }
-
- if (!column.property || typeof column.property !== 'string') {
- throw new Error('Column property is required')
- }
-
- // Optional properties
- if (column.sortFunction && typeof column.sortFunction !== 'function') {
- throw new Error('Column sortFunction must be a function')
- }
-
- if (column.summary && typeof column.summary !== 'function') {
- throw new Error('Column summary must be a function')
- }
-
- return true
-}
diff --git a/apps/files/src/services/PersonalFiles.ts b/apps/files/src/services/PersonalFiles.ts
new file mode 100644
index 00000000000..6d86bd3bae2
--- /dev/null
+++ b/apps/files/src/services/PersonalFiles.ts
@@ -0,0 +1,39 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, ContentsWithRoot } from '@nextcloud/files'
+import type { CancelablePromise } from 'cancelable-promise'
+import { getCurrentUser } from '@nextcloud/auth'
+
+import { getContents as getFiles } from './Files'
+
+const currentUserId = getCurrentUser()?.uid
+
+/**
+ * Filters each file/folder on its shared status
+ *
+ * A personal file is considered a file that has all of the following properties:
+ * 1. the current user owns
+ * 2. the file is not shared with anyone
+ * 3. the file is not a group folder
+ * @todo Move to `@nextcloud/files`
+ * @param node The node to check
+ */
+export const isPersonalFile = function(node: Node): boolean {
+ // the type of mounts that determine whether the file is shared
+ const sharedMountTypes = ['group', 'shared']
+ const mountType = node.attributes['mount-type']
+
+ return currentUserId === node.owner && !sharedMountTypes.includes(mountType)
+}
+
+export const getContents = (path: string = '/'): CancelablePromise<ContentsWithRoot> => {
+ // get all the files from the current path as a cancellable promise
+ // then filter the files that the user does not own, or has shared / is a group folder
+ return getFiles(path)
+ .then((content) => {
+ content.contents = content.contents.filter(isPersonalFile)
+ return content
+ })
+}
diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts
new file mode 100644
index 00000000000..6dbb67f30b6
--- /dev/null
+++ b/apps/files/src/services/PreviewService.ts
@@ -0,0 +1,21 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// The preview service worker cache name (see webpack config)
+const SWCacheName = 'previews'
+
+/**
+ * Check if the preview is already cached by the service worker
+ * @param previewUrl URL to check
+ */
+export async function isCachedPreview(previewUrl: string): Promise<boolean> {
+ if (!window?.caches?.open) {
+ return false
+ }
+
+ const cache = await window.caches.open(SWCacheName)
+ const response = await cache.match(previewUrl)
+ return response !== undefined
+}
diff --git a/apps/files/src/services/Recent.ts b/apps/files/src/services/Recent.ts
new file mode 100644
index 00000000000..d0ca285b05c
--- /dev/null
+++ b/apps/files/src/services/Recent.ts
@@ -0,0 +1,74 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ContentsWithRoot, Node } from '@nextcloud/files'
+import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL, davResultToNode } from '@nextcloud/files'
+import { CancelablePromise } from 'cancelable-promise'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import { getPinia } from '../store/index.ts'
+import { client } from './WebdavClient.ts'
+import { getBaseUrl } from '@nextcloud/router'
+
+const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 14))
+
+/**
+ * Helper to map a WebDAV result to a Nextcloud node
+ * The search endpoint already includes the dav remote URL so we must not include it in the source
+ *
+ * @param stat the WebDAV result
+ */
+const resultToNode = (stat: FileStat) => davResultToNode(stat, davRootPath, getBaseUrl())
+
+/**
+ * Get recently changed nodes
+ *
+ * This takes the users preference about hidden files into account.
+ * If hidden files are not shown, then also recently changed files *in* hidden directories are filtered.
+ *
+ * @param path Path to search for recent changes
+ */
+export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
+ const store = useUserConfigStore(getPinia())
+
+ /**
+ * Filter function that returns only the visible nodes - or hidden if explicitly configured
+ * @param node The node to check
+ */
+ const filterHidden = (node: Node) =>
+ path !== '/' // We need to hide files from hidden directories in the root if not configured to show
+ || store.userConfig.show_hidden // If configured to show hidden files we can early return
+ || !node.dirname.split('/').some((dir) => dir.startsWith('.')) // otherwise only include the file if non of the parent directories is hidden
+
+ const controller = new AbortController()
+ const handler = async () => {
+ const contentsResponse = await client.search('/', {
+ signal: controller.signal,
+ details: true,
+ data: davGetRecentSearch(lastTwoWeeksTimestamp),
+ }) as ResponseDataDetailed<SearchResult>
+
+ const contents = contentsResponse.data.results
+ .map(resultToNode)
+ .filter(filterHidden)
+
+ return {
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.READ,
+ }),
+ contents,
+ }
+ }
+
+ return new CancelablePromise(async (resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ resolve(handler())
+ })
+}
diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts
new file mode 100644
index 00000000000..4e2999b1d29
--- /dev/null
+++ b/apps/files/src/services/RouterService.ts
@@ -0,0 +1,75 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Route, Location } from 'vue-router'
+import type VueRouter from 'vue-router'
+
+export default class RouterService {
+
+ // typescript compiles this to `#router` to make it private even in JS,
+ // but in TS it needs to be called without the visibility specifier
+ private router: VueRouter
+
+ constructor(router: VueRouter) {
+ this.router = router
+ }
+
+ get name(): string | null | undefined {
+ return this.router.currentRoute.name
+ }
+
+ get query(): Record<string, string | (string | null)[] | null | undefined> {
+ return this.router.currentRoute.query || {}
+ }
+
+ get params(): Record<string, string> {
+ return this.router.currentRoute.params || {}
+ }
+
+ /**
+ * This is a protected getter only for internal use
+ * @private
+ */
+ get _router() {
+ return this.router
+ }
+
+ /**
+ * Trigger a route change on the files app
+ *
+ * @param path the url path, eg: '/trashbin?dir=/Deleted'
+ * @param replace replace the current history
+ * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location
+ */
+ goTo(path: string, replace = false): Promise<Route> {
+ return this.router.push({
+ path,
+ replace,
+ })
+ }
+
+ /**
+ * Trigger a route change on the files App
+ *
+ * @param name the route name
+ * @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>,
+ query?: Record<string, string | (string | null)[] | null | undefined>,
+ replace?: boolean,
+ ): Promise<Route> {
+ return this.router.push({
+ name,
+ query,
+ params,
+ replace,
+ } as Location)
+ }
+
+}
diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts
new file mode 100644
index 00000000000..c2840521a15
--- /dev/null
+++ b/apps/files/src/services/Search.spec.ts
@@ -0,0 +1,61 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getContents } from './Search.ts'
+import { Folder, Permission } from '@nextcloud/files'
+
+const searchNodes = vi.hoisted(() => vi.fn())
+vi.mock('./WebDavSearch.ts', () => ({ searchNodes }))
+vi.mock('@nextcloud/auth')
+
+describe('Search service', () => {
+ const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' })
+
+ beforeAll(() => {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router ??= { params: {}, query: {} }
+ vi.spyOn(window.OCP.Files.Router, 'params', 'get').mockReturnValue({ view: 'files' })
+ })
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setActivePinia(createPinia())
+ })
+
+ it('rejects on error', async () => {
+ searchNodes.mockImplementationOnce(() => { throw new Error('expected error') })
+ expect(getContents).rejects.toThrow('expected error')
+ })
+
+ it('returns the search results and a fake root', async () => {
+ searchNodes.mockImplementationOnce(() => [fakeFolder])
+ const { contents, folder } = await getContents()
+
+ expect(searchNodes).toHaveBeenCalledOnce()
+ expect(contents).toHaveLength(1)
+ expect(contents).toEqual([fakeFolder])
+ // read only root
+ expect(folder.permissions).toBe(Permission.READ)
+ })
+
+ it('can be cancelled', async () => {
+ const { promise, resolve } = Promise.withResolvers<Event>()
+ searchNodes.mockImplementationOnce(async (_, { signal }: { signal: AbortSignal}) => {
+ signal.addEventListener('abort', resolve)
+ await promise
+ return []
+ })
+
+ const content = getContents()
+ content.cancel()
+
+ // its cancelled thus the promise returns the event
+ const event = await promise
+ expect(event.type).toBe('abort')
+ })
+})
diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts
new file mode 100644
index 00000000000..f1d7c30a94e
--- /dev/null
+++ b/apps/files/src/services/Search.ts
@@ -0,0 +1,43 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission } from '@nextcloud/files'
+import { defaultRemoteURL } from '@nextcloud/files/dav'
+import { CancelablePromise } from 'cancelable-promise'
+import { searchNodes } from './WebDavSearch.ts'
+import logger from '../logger.ts'
+import { useSearchStore } from '../store/search.ts'
+import { getPinia } from '../store/index.ts'
+
+/**
+ * Get the contents for a search view
+ */
+export function getContents(): CancelablePromise<ContentsWithRoot> {
+ const controller = new AbortController()
+
+ const searchStore = useSearchStore(getPinia())
+
+ return new CancelablePromise<ContentsWithRoot>(async (resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ try {
+ const contents = await searchNodes(searchStore.query, { signal: controller.signal })
+ resolve({
+ contents,
+ folder: new Folder({
+ id: 0,
+ source: `${defaultRemoteURL}#search`,
+ owner: getCurrentUser()!.uid,
+ permissions: Permission.READ,
+ }),
+ })
+ } catch (error) {
+ logger.error('Failed to fetch search results', { error })
+ reject(error)
+ }
+ })
+}
diff --git a/apps/files/src/services/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js
new file mode 100644
index 00000000000..cc13db44009
--- /dev/null
+++ b/apps/files/src/services/ServiceWorker.js
@@ -0,0 +1,31 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.ts'
+
+export default () => {
+ if ('serviceWorker' in navigator) {
+ // Use the window load event to keep the page load performant
+ window.addEventListener('load', async () => {
+ try {
+ const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true })
+ let scope = getRootUrl()
+ // If the instance is not in a subfolder an empty string will be returned.
+ // The service worker registration will use the current path if it receives an empty string,
+ // which will result in a service worker registration for every single path the user visits.
+ if (scope === '') {
+ scope = '/'
+ }
+
+ const registration = await navigator.serviceWorker.register(url, { scope })
+ logger.debug('SW registered: ', { registration })
+ } catch (error) {
+ logger.error('SW registration failed: ', { error })
+ }
+ })
+ } else {
+ logger.debug('Service Worker is not enabled on this browser.')
+ }
+}
diff --git a/apps/files/src/services/Settings.js b/apps/files/src/services/Settings.js
index 83c2c850580..7f04aa82fda 100644
--- a/apps/files/src/services/Settings.js
+++ b/apps/files/src/services/Settings.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- *
- * @author Gary Kim <gary@garykim.dev>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default class Settings {
diff --git a/apps/files/src/services/Sidebar.js b/apps/files/src/services/Sidebar.js
index e87ee71a4b1..0f5c275e532 100644
--- a/apps/files/src/services/Sidebar.js
+++ b/apps/files/src/services/Sidebar.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default class Sidebar {
diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js
index c242f9ae82d..d7f25846ceb 100644
--- a/apps/files/src/services/Templates.js
+++ b/apps/files/src/services/Templates.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateOcsUrl } from '@nextcloud/router'
@@ -28,18 +11,25 @@ export const getTemplates = async function() {
return response.data.ocs.data
}
+export const getTemplateFields = async function(fileId) {
+ const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`))
+ return response.data.ocs.data
+}
+
/**
* Create a new file from a specified template
*
* @param {string} filePath The new file destination path
* @param {string} templatePath The template source path
* @param {string} templateType The template type e.g 'user'
+ * @param {object} templateFields The template fields to fill in (if any)
*/
-export const createFromTemplate = async function(filePath, templatePath, templateType) {
+export const createFromTemplate = async function(filePath, templatePath, templateType, templateFields) {
const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), {
filePath,
templatePath,
templateType,
+ templateFields,
})
return response.data.ocs.data
}
diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts
new file mode 100644
index 00000000000..feb7f30b357
--- /dev/null
+++ b/apps/files/src/services/WebDavSearch.ts
@@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ResponseDataDetailed, SearchResult } from 'webdav'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { defaultRootPath, getDavNameSpaces, getDavProperties, resultToNode } from '@nextcloud/files/dav'
+import { getBaseUrl } from '@nextcloud/router'
+import { client } from './WebdavClient.ts'
+import logger from '../logger.ts'
+
+export interface SearchNodesOptions {
+ dir?: string,
+ signal?: AbortSignal
+}
+
+/**
+ * Search for nodes matching the given query.
+ *
+ * @param query - Search query
+ * @param options - Options
+ * @param options.dir - The base directory to scope the search to
+ * @param options.signal - Abort signal for the request
+ */
+export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise<INode[]> {
+ const user = getCurrentUser()
+ if (!user) {
+ // the search plugin only works for user roots
+ return []
+ }
+
+ query = query.trim()
+ if (query.length < 3) {
+ // the search plugin only works with queries of at least 3 characters
+ return []
+ }
+
+ if (dir && !dir.startsWith('/')) {
+ dir = `/${dir}`
+ }
+
+ logger.debug('Searching for nodes', { query, dir })
+ const { data } = await client.search('/', {
+ details: true,
+ signal,
+ data: `
+<d:searchrequest ${getDavNameSpaces()}>
+ <d:basicsearch>
+ <d:select>
+ <d:prop>
+ ${getDavProperties()}
+ </d:prop>
+ </d:select>
+ <d:from>
+ <d:scope>
+ <d:href>/files/${user.uid}${dir || ''}</d:href>
+ <d:depth>infinity</d:depth>
+ </d:scope>
+ </d:from>
+ <d:where>
+ <d:like>
+ <d:prop>
+ <d:displayname/>
+ </d:prop>
+ <d:literal>%${query.replace('%', '')}%</d:literal>
+ </d:like>
+ </d:where>
+ <d:orderby/>
+ </d:basicsearch>
+</d:searchrequest>`,
+ }) as ResponseDataDetailed<SearchResult>
+
+ // check if the request was aborted
+ if (signal?.aborted) {
+ return []
+ }
+
+ // otherwise return the result mapped to Nextcloud nodes
+ return data.results.map((result) => resultToNode(result, defaultRootPath, getBaseUrl()))
+}
diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts
new file mode 100644
index 00000000000..2b92deba9b4
--- /dev/null
+++ b/apps/files/src/services/WebdavClient.ts
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { Node } from '@nextcloud/files'
+
+import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'
+
+export const client = getClient()
+
+export const fetchNode = async (path: string): Promise<Node> => {
+ const propfindPayload = getDefaultPropfind()
+ const result = await client.stat(`${getRootPath()}${path}`, {
+ details: true,
+ data: propfindPayload,
+ }) as ResponseDataDetailed<FileStat>
+ return resultToNode(result.data)
+}
diff --git a/apps/files/src/sidebar.js b/apps/files/src/sidebar.ts
index 58b798ed0e7..35a379ad649 100644
--- a/apps/files/src/sidebar.js
+++ b/apps/files/src/sidebar.ts
@@ -1,31 +1,14 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import { translate as t } from '@nextcloud/l10n'
import SidebarView from './views/Sidebar.vue'
-import Sidebar from './services/Sidebar'
-import Tab from './models/Tab'
+import Sidebar from './services/Sidebar.js'
+import Tab from './models/Tab.js'
Vue.prototype.t = t
@@ -36,12 +19,12 @@ if (!window.OCA.Files) {
Object.assign(window.OCA.Files, { Sidebar: new Sidebar() })
Object.assign(window.OCA.Files.Sidebar, { Tab })
-console.debug('OCA.Files.Sidebar initialized')
-
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
@@ -50,15 +33,22 @@ window.addEventListener('DOMContentLoaded', function() {
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',
- })
- AppSidebar.$mount('#app-sidebar')
+ 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
})
diff --git a/apps/files/src/store/actionsmenu.ts b/apps/files/src/store/actionsmenu.ts
new file mode 100644
index 00000000000..dc5ce8cb8b3
--- /dev/null
+++ b/apps/files/src/store/actionsmenu.ts
@@ -0,0 +1,12 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { defineStore } from 'pinia'
+import type { ActionsMenuStore } from '../types'
+
+export const useActionsMenuStore = defineStore('actionsmenu', {
+ state: () => ({
+ opened: null,
+ } as ActionsMenuStore),
+})
diff --git a/apps/files/src/store/active.ts b/apps/files/src/store/active.ts
new file mode 100644
index 00000000000..1303a157b08
--- /dev/null
+++ b/apps/files/src/store/active.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { FileAction, View, Node, Folder } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import logger from '../logger.ts'
+
+export const useActiveStore = defineStore('active', () => {
+ /**
+ * The currently active action
+ */
+ const activeAction = ref<FileAction>()
+
+ /**
+ * The currently active folder
+ */
+ const activeFolder = ref<Folder>()
+
+ /**
+ * The current active node within the folder
+ */
+ const activeNode = ref<Node>()
+
+ /**
+ * The current active view
+ */
+ const activeView = ref<View>()
+
+ initialize()
+
+ /**
+ * Unset the active node if deleted
+ *
+ * @param node - The node thats deleted
+ * @private
+ */
+ function onDeletedNode(node: Node) {
+ if (activeNode.value && activeNode.value.source === node.source) {
+ activeNode.value = undefined
+ }
+ }
+
+ /**
+ * Callback to update the current active view
+ *
+ * @param view - The new active view
+ * @private
+ */
+ function onChangedView(view: View|null = null) {
+ logger.debug('Setting active view', { view })
+ activeView.value = view ?? undefined
+ activeNode.value = undefined
+ }
+
+ /**
+ * Initalize the store - connect all event listeners.
+ * @private
+ */
+ function initialize() {
+ const navigation = getNavigation()
+
+ // Make sure we only register the listeners once
+ subscribe('files:node:deleted', onDeletedNode)
+
+ onChangedView(navigation.active)
+
+ // Or you can react to changes of the current active view
+ navigation.addEventListener('updateActive', (event) => {
+ onChangedView(event.detail)
+ })
+ }
+
+ return {
+ activeAction,
+ activeFolder,
+ activeNode,
+ activeView,
+ }
+})
diff --git a/apps/files/src/store/dragging.ts b/apps/files/src/store/dragging.ts
new file mode 100644
index 00000000000..810f662149c
--- /dev/null
+++ b/apps/files/src/store/dragging.ts
@@ -0,0 +1,31 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { DragAndDropStore, FileSource } from '../types'
+
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+
+export const useDragAndDropStore = defineStore('dragging', {
+ state: () => ({
+ dragging: [],
+ } as DragAndDropStore),
+
+ actions: {
+ /**
+ * Set the selection of files being dragged currently
+ * @param selection array of node sources
+ */
+ set(selection = [] as FileSource[]) {
+ Vue.set(this, 'dragging', selection)
+ },
+
+ /**
+ * Reset the selection
+ */
+ reset() {
+ Vue.set(this, 'dragging', [])
+ },
+ },
+})
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
new file mode 100644
index 00000000000..0bcf4ce9350
--- /dev/null
+++ b/apps/files/src/store/files.ts
@@ -0,0 +1,198 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileSource } from '../types'
+import type { Folder, Node } from '@nextcloud/files'
+
+import { defineStore } from 'pinia'
+import { subscribe } from '@nextcloud/event-bus'
+import logger from '../logger'
+import Vue from 'vue'
+
+import { fetchNode } from '../services/WebdavClient.ts'
+import { usePathsStore } from './paths.ts'
+
+export const useFilesStore = function(...args) {
+ const store = defineStore('files', {
+ state: (): FilesState => ({
+ files: {} as FilesStore,
+ roots: {} as RootsStore,
+ }),
+
+ getters: {
+ /**
+ * Get a file or folder by its source
+ * @param state
+ */
+ getNode: (state) => (source: FileSource): Node|undefined => state.files[source],
+
+ /**
+ * Get a list of files or folders by their IDs
+ * Note: does not return undefined values
+ * @param state
+ */
+ getNodes: (state) => (sources: FileSource[]): Node[] => sources
+ .map(source => state.files[source])
+ .filter(Boolean),
+
+ /**
+ * Get files or folders by their file ID
+ * Multiple nodes can have the same file ID but different sources
+ * (e.g. in a shared context)
+ * @param state
+ */
+ getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter(node => node.fileid === fileId),
+
+ /**
+ * Get the root folder of a service
+ * @param state
+ */
+ getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
+ },
+
+ actions: {
+ /**
+ * Get cached directory matching a given path
+ *
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return The folder if found
+ */
+ getDirectoryByPath(service: string, path?: string): Folder | undefined {
+ const pathsStore = usePathsStore()
+ let folder: Folder | undefined
+
+ // Get the containing folder from path store
+ if (!path || path === '/') {
+ folder = this.getRoot(service)
+ } else {
+ const source = pathsStore.getPath(service, path)
+ if (source) {
+ folder = this.getNode(source) as Folder | undefined
+ }
+ }
+
+ return folder
+ },
+
+ /**
+ * Get cached child nodes within a given path
+ *
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return Array of cached nodes within the path
+ */
+ getNodesByPath(service: string, path?: string): Node[] {
+ const folder = this.getDirectoryByPath(service, path)
+
+ // If we found a cache entry and the cache entry was already loaded (has children) then use it
+ return (folder?._children ?? [])
+ .map((source: string) => this.getNode(source))
+ .filter(Boolean)
+ },
+
+ updateNodes(nodes: Node[]) {
+ // Update the store all at once
+ const files = nodes.reduce((acc, node) => {
+ if (!node.fileid) {
+ logger.error('Trying to update/set a node without fileid', { node })
+ return acc
+ }
+
+ acc[node.source] = node
+ return acc
+ }, {} as FilesStore)
+
+ Vue.set(this, 'files', { ...this.files, ...files })
+ },
+
+ deleteNodes(nodes: Node[]) {
+ nodes.forEach(node => {
+ if (node.source) {
+ Vue.delete(this.files, node.source)
+ }
+ })
+ },
+
+ setRoot({ service, root }: RootOptions) {
+ Vue.set(this.roots, service, root)
+ },
+
+ onDeletedNode(node: Node) {
+ this.deleteNodes([node])
+ },
+
+ onCreatedNode(node: Node) {
+ this.updateNodes([node])
+ },
+
+ onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
+ if (!node.fileid) {
+ logger.error('Trying to update/set a node without fileid', { node })
+ return
+ }
+
+ // Update the path of the node
+ Vue.delete(this.files, oldSource)
+ this.updateNodes([node])
+ },
+
+ async onUpdatedNode(node: Node) {
+ if (!node.fileid) {
+ logger.error('Trying to update/set a node without fileid', { node })
+ return
+ }
+
+ // If we have multiple nodes with the same file ID, we need to update all of them
+ const nodes = this.getNodesById(node.fileid)
+ if (nodes.length > 1) {
+ await Promise.all(nodes.map(node => fetchNode(node.path))).then(this.updateNodes)
+ logger.debug(nodes.length + ' nodes updated in store', { fileid: node.fileid })
+ return
+ }
+
+ // If we have only one node with the file ID, we can update it directly
+ if (nodes.length === 1 && node.source === nodes[0].source) {
+ this.updateNodes([node])
+ return
+ }
+
+ // Otherwise, it means we receive an event for a node that is not in the store
+ fetchNode(node.path).then(n => this.updateNodes([n]))
+ },
+
+ // Handlers for legacy sidebar (no real nodes support)
+ onAddFavorite(node: Node) {
+ const ourNode = this.getNode(node.source)
+ if (ourNode) {
+ Vue.set(ourNode.attributes, 'favorite', 1)
+ }
+ },
+
+ onRemoveFavorite(node: Node) {
+ const ourNode = this.getNode(node.source)
+ if (ourNode) {
+ Vue.set(ourNode.attributes, 'favorite', 0)
+ }
+ },
+ },
+ })
+
+ const fileStore = store(...args)
+ // Make sure we only register the listeners once
+ if (!fileStore._initialized) {
+ subscribe('files:node:created', fileStore.onCreatedNode)
+ subscribe('files:node:deleted', fileStore.onDeletedNode)
+ subscribe('files:node:updated', fileStore.onUpdatedNode)
+ subscribe('files:node:moved', fileStore.onMovedNode)
+ // legacy sidebar
+ subscribe('files:favorites:added', fileStore.onAddFavorite)
+ subscribe('files:favorites:removed', fileStore.onRemoveFavorite)
+
+ fileStore._initialized = true
+ }
+
+ return fileStore
+}
diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts
new file mode 100644
index 00000000000..fd16ec5dc84
--- /dev/null
+++ b/apps/files/src/store/filters.ts
@@ -0,0 +1,133 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { getFileListFilters } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import logger from '../logger'
+
+/**
+ * Check if the given value is an instance file list filter with mount function
+ * @param value The filter to check
+ */
+function isFileListFilterWithUi(value: IFileListFilter): value is Required<IFileListFilter> {
+ return 'mount' in value
+}
+
+export const useFiltersStore = defineStore('filters', () => {
+ const chips = ref<Record<string, IFileListFilterChip[]>>({})
+ const filters = ref<IFileListFilter[]>([])
+
+ /**
+ * Currently active filter chips
+ */
+ const activeChips = computed<IFileListFilterChip[]>(
+ () => Object.values(chips.value).flat(),
+ )
+
+ /**
+ * Filters sorted by order
+ */
+ const sortedFilters = computed<IFileListFilter[]>(
+ () => filters.value.sort((a, b) => a.order - b.order),
+ )
+
+ /**
+ * All filters that provide a UI for visual controlling the filter state
+ */
+ const filtersWithUI = computed<Required<IFileListFilter>[]>(
+ () => sortedFilters.value.filter(isFileListFilterWithUi),
+ )
+
+ /**
+ * Register a new filter on the store.
+ * This will subscribe the store to the filters events.
+ *
+ * @param filter The filter to add
+ */
+ function addFilter(filter: IFileListFilter) {
+ filter.addEventListener('update:chips', onFilterUpdateChips)
+ filter.addEventListener('update:filter', onFilterUpdate)
+
+ filters.value.push(filter)
+ logger.debug('New file list filter registered', { id: filter.id })
+ }
+
+ /**
+ * Unregister a filter from the store.
+ * This will remove the filter from the store and unsubscribe the store from the filer events.
+ * @param filterId Id of the filter to remove
+ */
+ function removeFilter(filterId: string) {
+ const index = filters.value.findIndex(({ id }) => id === filterId)
+ if (index > -1) {
+ const [filter] = filters.value.splice(index, 1)
+ filter.removeEventListener('update:chips', onFilterUpdateChips)
+ filter.removeEventListener('update:filter', onFilterUpdate)
+ logger.debug('Files list filter unregistered', { id: filterId })
+ }
+ }
+
+ /**
+ * Event handler for filter update events
+ * @private
+ */
+ function onFilterUpdate() {
+ emit('files:filters:changed')
+ }
+
+ /**
+ * Event handler for filter chips updates
+ * @param event The update event
+ * @private
+ */
+ function onFilterUpdateChips(event: FilterUpdateChipsEvent) {
+ const id = (event.target as IFileListFilter).id
+ chips.value = {
+ ...chips.value,
+ [id]: [...event.detail],
+ }
+
+ logger.debug('File list filter chips updated', { filter: id, chips: event.detail })
+ }
+
+ /**
+ * Event handler that resets all filters if the file list view was changed.
+ * @private
+ */
+ function onViewChanged() {
+ logger.debug('Reset all file list filters - view changed')
+
+ for (const filter of filters.value) {
+ if (filter.reset !== undefined) {
+ filter.reset()
+ }
+ }
+ }
+
+ // Initialize the store
+ subscribe('files:navigation:changed', onViewChanged)
+ subscribe('files:filter:added', addFilter)
+ subscribe('files:filter:removed', removeFilter)
+ for (const filter of getFileListFilters()) {
+ addFilter(filter)
+ }
+
+ return {
+ // state
+ chips,
+ filters,
+ filtersWithUI,
+
+ // getters / computed
+ activeChips,
+ sortedFilters,
+
+ // actions / methods
+ addFilter,
+ removeFilter,
+ }
+})
diff --git a/apps/files/src/store/index.ts b/apps/files/src/store/index.ts
new file mode 100644
index 00000000000..3ba667ffd2f
--- /dev/null
+++ b/apps/files/src/store/index.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createPinia } from 'pinia'
+
+export const getPinia = () => {
+ if (window._nc_files_pinia) {
+ return window._nc_files_pinia
+ }
+
+ window._nc_files_pinia = createPinia()
+ return window._nc_files_pinia
+}
diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts
new file mode 100644
index 00000000000..f2654933895
--- /dev/null
+++ b/apps/files/src/store/keyboard.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+
+/**
+ * Observe various events and save the current
+ * special keys states. Useful for checking the
+ * current status of a key when executing a method.
+ * @param {...any} args
+ */
+export const useKeyboardStore = function(...args) {
+ const store = defineStore('keyboard', {
+ state: () => ({
+ altKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ }),
+
+ actions: {
+ onEvent(event: MouseEvent | KeyboardEvent) {
+ if (!event) {
+ event = window.event as MouseEvent | KeyboardEvent
+ }
+ Vue.set(this, 'altKey', !!event.altKey)
+ Vue.set(this, 'ctrlKey', !!event.ctrlKey)
+ Vue.set(this, 'metaKey', !!event.metaKey)
+ Vue.set(this, 'shiftKey', !!event.shiftKey)
+ },
+ },
+ })
+
+ const keyboardStore = store(...args)
+ // Make sure we only register the listeners once
+ if (!keyboardStore._initialized) {
+ window.addEventListener('keydown', keyboardStore.onEvent)
+ window.addEventListener('keyup', keyboardStore.onEvent)
+ window.addEventListener('mousemove', keyboardStore.onEvent)
+
+ keyboardStore._initialized = true
+ }
+
+ return keyboardStore
+}
diff --git a/apps/files/src/store/paths.spec.ts b/apps/files/src/store/paths.spec.ts
new file mode 100644
index 00000000000..932e8b1a6a1
--- /dev/null
+++ b/apps/files/src/store/paths.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, beforeEach, test, expect } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { usePathsStore } from './paths.ts'
+import { emit } from '@nextcloud/event-bus'
+import { File, Folder } from '@nextcloud/files'
+import { useFilesStore } from './files.ts'
+
+describe('Path store', () => {
+
+ let store: ReturnType<typeof usePathsStore>
+ let files: ReturnType<typeof useFilesStore>
+ let root: Folder & { _children?: string[] }
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+
+ root = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/', id: 1 })
+ files = useFilesStore()
+ files.setRoot({ service: 'files', root })
+
+ store = usePathsStore()
+ })
+
+ test('Folder is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the folder
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+
+ // see that the path is added
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+
+ // see that the node is added
+ expect(root._children).toEqual([node.source])
+ })
+
+ test('File is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+
+ // see that there are still no paths
+ expect(store.paths).toEqual({})
+
+ // see that the node is added
+ expect(root._children).toEqual([node.source])
+ })
+
+ test('Existing file is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node1 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node1)
+
+ // see that there are still no paths
+ expect(store.paths).toEqual({})
+
+ // see that the node is added
+ expect(root._children).toEqual([node1.source])
+
+ // create the same named file again
+ const node2 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node2)
+
+ // see that there are still no paths and the children are not duplicated
+ expect(store.paths).toEqual({})
+ expect(root._children).toEqual([node1.source])
+
+ })
+
+ test('Existing folder is created', () => {
+ // no defined paths
+ expect(store.paths).toEqual({})
+
+ // create the file
+ const node1 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node1)
+
+ // see the path is added
+ expect(store.paths).toEqual({ files: { [node1.path]: node1.source } })
+
+ // see that the node is added
+ expect(root._children).toEqual([node1.source])
+
+ // create the same named file again
+ const node2 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node2)
+
+ // see that there is still only one paths and the children are not duplicated
+ expect(store.paths).toEqual({ files: { [node1.path]: node1.source } })
+ expect(root._children).toEqual([node1.source])
+ })
+
+ test('Folder is deleted', () => {
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+ // see that the path is added and the children are set-up
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+ expect(root._children).toEqual([node.source])
+
+ emit('files:node:deleted', node)
+ // See the path is removed
+ expect(store.paths).toEqual({ files: {} })
+ // See the child is removed
+ expect(root._children).toEqual([])
+ })
+
+ test('File is deleted', () => {
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+ // see that the children are set-up
+ expect(root._children).toEqual([node.source])
+
+ emit('files:node:deleted', node)
+ // See the child is removed
+ expect(root._children).toEqual([])
+ })
+
+ test('Folder is moved', () => {
+ const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 })
+ emit('files:node:created', node)
+ // see that the path is added and the children are set-up
+ expect(store.paths).toEqual({ files: { [node.path]: node.source } })
+ expect(root._children).toEqual([node.source])
+
+ const renamedNode = node.clone()
+ renamedNode.rename('new-folder')
+
+ expect(renamedNode.path).toBe('/new-folder')
+ expect(renamedNode.source).toBe('http://example.com/remote.php/dav/files/test/new-folder')
+
+ emit('files:node:moved', { node: renamedNode, oldSource: node.source })
+ // See the path is updated
+ expect(store.paths).toEqual({ files: { [renamedNode.path]: renamedNode.source } })
+ // See the child is updated
+ expect(root._children).toEqual([renamedNode.source])
+ })
+
+ test('File is moved', () => {
+ const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' })
+ emit('files:node:created', node)
+ // see that the children are set-up
+ expect(root._children).toEqual([node.source])
+ expect(store.paths).toEqual({})
+
+ const renamedNode = node.clone()
+ renamedNode.rename('new-file.txt')
+
+ emit('files:node:moved', { node: renamedNode, oldSource: node.source })
+ // See the child is updated
+ expect(root._children).toEqual([renamedNode.source])
+ expect(store.paths).toEqual({})
+ })
+})
diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts
new file mode 100644
index 00000000000..4a83cb51c83
--- /dev/null
+++ b/apps/files/src/store/paths.ts
@@ -0,0 +1,165 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileSource, PathsStore, PathOptions, ServicesState, Service } from '../types'
+import { defineStore } from 'pinia'
+import { dirname } from '@nextcloud/paths'
+import { File, FileType, Folder, Node, getNavigation } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import Vue from 'vue'
+import logger from '../logger'
+
+import { useFilesStore } from './files'
+
+export const usePathsStore = function(...args) {
+ const files = useFilesStore(...args)
+
+ const store = defineStore('paths', {
+ state: () => ({
+ paths: {} as ServicesState,
+ } as PathsStore),
+
+ getters: {
+ getPath: (state) => {
+ return (service: string, path: string): FileSource|undefined => {
+ if (!state.paths[service]) {
+ return undefined
+ }
+ return state.paths[service][path]
+ }
+ },
+ },
+
+ actions: {
+ addPath(payload: PathOptions) {
+ // If it doesn't exists, init the service state
+ if (!this.paths[payload.service]) {
+ Vue.set(this.paths, payload.service, {})
+ }
+
+ // Now we can set the provided path
+ Vue.set(this.paths[payload.service], payload.path, payload.source)
+ },
+
+ deletePath(service: Service, path: string) {
+ // skip if service does not exist
+ if (!this.paths[service]) {
+ return
+ }
+
+ Vue.delete(this.paths[service], path)
+ },
+
+ onCreatedNode(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+ if (!node.fileid) {
+ logger.error('Node has no fileid', { node })
+ return
+ }
+
+ // Only add path if it's a folder
+ if (node.type === FileType.Folder) {
+ this.addPath({
+ service,
+ path: node.path,
+ source: node.source,
+ })
+ }
+
+ // Update parent folder children if exists
+ // If the folder is the root, get it and update it
+ this.addNodeToParentChildren(node)
+ },
+
+ onDeletedNode(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ if (node.type === FileType.Folder) {
+ // Delete the path
+ this.deletePath(
+ service,
+ node.path,
+ )
+ }
+
+ this.deleteNodeFromParentChildren(node)
+ },
+
+ onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ // Update the path of the node
+ if (node.type === FileType.Folder) {
+ // Delete the old path if it exists
+ const oldPath = Object.entries(this.paths[service]).find(([, source]) => source === oldSource)
+ if (oldPath?.[0]) {
+ this.deletePath(service, oldPath[0])
+ }
+
+ // Add the new path
+ this.addPath({
+ service,
+ path: node.path,
+ source: node.source,
+ })
+ }
+
+ // Dummy simple clone of the renamed node from a previous state
+ const oldNode = new File({ source: oldSource, owner: node.owner, mime: node.mime })
+
+ this.deleteNodeFromParentChildren(oldNode)
+ this.addNodeToParentChildren(node)
+ },
+
+ deleteNodeFromParentChildren(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ // Update children of a root folder
+ const parentSource = dirname(node.source)
+ const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] }
+ if (folder) {
+ // ensure sources are unique
+ const children = new Set(folder._children ?? [])
+ children.delete(node.source)
+ Vue.set(folder, '_children', [...children.values()])
+ logger.debug('Children updated', { parent: folder, node, children: folder._children })
+ return
+ }
+
+ logger.debug('Parent path does not exists, skipping children update', { node })
+ },
+
+ addNodeToParentChildren(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
+
+ // Update children of a root folder
+ const parentSource = dirname(node.source)
+ const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] }
+ if (folder) {
+ // ensure sources are unique
+ const children = new Set(folder._children ?? [])
+ children.add(node.source)
+ Vue.set(folder, '_children', [...children.values()])
+ logger.debug('Children updated', { parent: folder, node, children: folder._children })
+ return
+ }
+
+ logger.debug('Parent path does not exists, skipping children update', { node })
+ },
+
+ },
+ })
+
+ const pathsStore = store(...args)
+ // Make sure we only register the listeners once
+ if (!pathsStore._initialized) {
+ subscribe('files:node:created', pathsStore.onCreatedNode)
+ subscribe('files:node:deleted', pathsStore.onDeletedNode)
+ subscribe('files:node:moved', pathsStore.onMovedNode)
+
+ pathsStore._initialized = true
+ }
+
+ return pathsStore
+}
diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts
new file mode 100644
index 00000000000..fc61be3bd3b
--- /dev/null
+++ b/apps/files/src/store/renaming.ts
@@ -0,0 +1,175 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node } from '@nextcloud/files'
+
+import axios, { isAxiosError } from '@nextcloud/axios'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { FileType, NodeStatus } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
+import { basename, dirname, extname } from 'path'
+import { defineStore } from 'pinia'
+import logger from '../logger'
+import Vue, { defineAsyncComponent, ref } from 'vue'
+import { useUserConfigStore } from './userconfig'
+import { fetchNode } from '../services/WebdavClient'
+
+export const useRenamingStore = defineStore('renaming', () => {
+ /**
+ * The currently renamed node
+ */
+ const renamingNode = ref<Node>()
+ /**
+ * The new name of the currently renamed node
+ */
+ const newNodeName = ref('')
+
+ /**
+ * Internal flag to only allow calling `rename` once.
+ */
+ const isRenaming = ref(false)
+
+ /**
+ * Execute the renaming.
+ * This will rename the node set as `renamingNode` to the configured new name `newName`.
+ *
+ * @return true if success, false if skipped (e.g. new and old name are the same)
+ * @throws Error if renaming fails, details are set in the error message
+ */
+ async function rename(): Promise<boolean> {
+ if (renamingNode.value === undefined) {
+ throw new Error('No node is currently being renamed')
+ }
+
+ // Only rename once so we use this as some kind of mutex
+ if (isRenaming.value) {
+ return false
+ }
+ isRenaming.value = true
+
+ let node = renamingNode.value
+ Vue.set(node, 'status', NodeStatus.LOADING)
+
+ const userConfig = useUserConfigStore()
+
+ let newName = newNodeName.value.trim()
+ const oldName = node.basename
+ const oldExtension = extname(oldName)
+ const newExtension = extname(newName)
+ // Check for extension change for files
+ if (node.type === FileType.File
+ && oldExtension !== newExtension
+ && userConfig.userConfig.show_dialog_file_extension
+ && !(await showFileExtensionDialog(oldExtension, newExtension))
+ ) {
+ // user selected to use the old extension
+ newName = basename(newName, newExtension) + oldExtension
+ }
+
+ const oldEncodedSource = node.encodedSource
+ try {
+ if (oldName === newName) {
+ return false
+ }
+
+ // rename the node
+ node.rename(newName)
+ logger.debug('Moving file to', { destination: node.encodedSource, oldEncodedSource })
+ // create MOVE request
+ await axios({
+ method: 'MOVE',
+ url: oldEncodedSource,
+ headers: {
+ Destination: node.encodedSource,
+ Overwrite: 'F',
+ },
+ })
+
+ // Update mime type if extension changed
+ // as other related informations might have changed
+ // on the backend but it is really hard to know on the front
+ if (oldExtension !== newExtension) {
+ node = await fetchNode(node.path)
+ }
+
+ // Success 🎉
+ emit('files:node:updated', node)
+ emit('files:node:renamed', node)
+ emit('files:node:moved', {
+ node,
+ oldSource: `${dirname(node.source)}/${oldName}`,
+ })
+
+ // Reset the state not changed
+ if (renamingNode.value === node) {
+ $reset()
+ }
+
+ return true
+ } catch (error) {
+ logger.error('Error while renaming file', { error })
+ // Rename back as it failed
+ node.rename(oldName)
+ if (isAxiosError(error)) {
+ // TODO: 409 means current folder does not exist, redirect ?
+ if (error?.response?.status === 404) {
+ throw new Error(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
+ } else if (error?.response?.status === 412) {
+ throw new Error(t(
+ 'files',
+ 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.',
+ {
+ newName,
+ dir: basename(renamingNode.value!.dirname),
+ },
+ ))
+ }
+ }
+ // Unknown error
+ throw new Error(t('files', 'Could not rename "{oldName}"', { oldName }))
+ } finally {
+ Vue.set(node, 'status', undefined)
+ isRenaming.value = false
+ }
+ }
+
+ /**
+ * Reset the store state
+ */
+ function $reset(): void {
+ newNodeName.value = ''
+ renamingNode.value = undefined
+ }
+
+ // Make sure we only register the listeners once
+ subscribe('files:node:rename', (node: Node) => {
+ renamingNode.value = node
+ newNodeName.value = node.basename
+ })
+
+ return {
+ $reset,
+
+ newNodeName,
+ rename,
+ renamingNode,
+ }
+})
+
+/**
+ * Show a dialog asking user for confirmation about changing the file extension.
+ *
+ * @param oldExtension the old file name extension
+ * @param newExtension the new file name extension
+ */
+async function showFileExtensionDialog(oldExtension: string, newExtension: string): Promise<boolean> {
+ const { promise, resolve } = Promise.withResolvers<boolean>()
+ spawnDialog(
+ defineAsyncComponent(() => import('../views/DialogConfirmFileExtension.vue')),
+ { oldExtension, newExtension },
+ (useNewExtension: unknown) => resolve(Boolean(useNewExtension)),
+ )
+ return await promise
+}
diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts
new file mode 100644
index 00000000000..43e01f35b92
--- /dev/null
+++ b/apps/files/src/store/search.ts
@@ -0,0 +1,153 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { View } from '@nextcloud/files'
+import type RouterService from '../services/RouterService.ts'
+import type { SearchScope } from '../types.ts'
+
+import { emit, subscribe } from '@nextcloud/event-bus'
+import debounce from 'debounce'
+import { defineStore } from 'pinia'
+import { ref, watch } from 'vue'
+import { VIEW_ID } from '../views/search.ts'
+import logger from '../logger.ts'
+
+export const useSearchStore = defineStore('search', () => {
+ /**
+ * The current search query
+ */
+ const query = ref('')
+
+ /**
+ * Scope of the search.
+ * Scopes:
+ * - filter: only filter current file list
+ * - globally: search everywhere
+ */
+ const scope = ref<SearchScope>('filter')
+
+ // reset the base if query is cleared
+ watch(scope, updateSearch)
+
+ watch(query, (old, current) => {
+ // skip if only whitespaces changed
+ if (old.trim() === current.trim()) {
+ return
+ }
+
+ updateSearch()
+ })
+
+ // initialize the search store
+ initialize()
+
+ /**
+ * Debounced update of the current route
+ * @private
+ */
+ const updateRouter = debounce((isSearch: boolean) => {
+ const router = window.OCP.Files.Router as RouterService
+ router.goToRoute(
+ undefined,
+ {
+ view: VIEW_ID,
+ },
+ {
+ query: query.value,
+ },
+ isSearch,
+ )
+ })
+
+ /**
+ * Handle updating the filter if needed.
+ * Also update the search view by updating the current route if needed.
+ *
+ * @private
+ */
+ function updateSearch() {
+ // emit the search event to update the filter
+ emit('files:search:updated', { query: query.value, scope: scope.value })
+ const router = window.OCP.Files.Router as RouterService
+
+ // if we are on the search view and the query was unset or scope was set to 'filter' we need to move back to the files view
+ if (router.params.view === VIEW_ID && (query.value === '' || scope.value === 'filter')) {
+ scope.value = 'filter'
+ return router.goToRoute(
+ undefined,
+ {
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ )
+ }
+
+ // for the filter scope we do not need to adjust the current route anymore
+ // also if the query is empty we do not need to do anything
+ if (scope.value === 'filter' || !query.value) {
+ return
+ }
+
+ const isSearch = router.params.view === VIEW_ID
+
+ logger.debug('Update route for updated search query', { query: query.value, isSearch })
+ updateRouter(isSearch)
+ }
+
+ /**
+ * Event handler that resets the store if the file list view was changed.
+ *
+ * @param view - The new view that is active
+ * @private
+ */
+ function onViewChanged(view: View) {
+ if (view.id !== VIEW_ID) {
+ query.value = ''
+ scope.value = 'filter'
+ }
+ }
+
+ /**
+ * Initialize the store from the router if needed
+ */
+ function initialize() {
+ subscribe('files:navigation:changed', onViewChanged)
+
+ const router = window.OCP.Files.Router as RouterService
+ // if we initially load the search view (e.g. hard page refresh)
+ // then we need to initialize the store from the router
+ if (router.params.view === VIEW_ID) {
+ query.value = [router.query.query].flat()[0] ?? ''
+
+ if (query.value) {
+ scope.value = 'globally'
+ logger.debug('Directly navigated to search view', { query: query.value })
+ } else {
+ // we do not have any query so we need to move to the files list
+ logger.info('Directly navigated to search view without any query, redirect to files view.')
+ router.goToRoute(
+ undefined,
+ {
+ ...router.params,
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ true,
+ )
+ }
+ }
+ }
+
+ return {
+ query,
+ scope,
+ }
+})
diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts
new file mode 100644
index 00000000000..fa35d953406
--- /dev/null
+++ b/apps/files/src/store/selection.ts
@@ -0,0 +1,44 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileSource, SelectionStore } from '../types'
+import { defineStore } from 'pinia'
+import Vue from 'vue'
+
+export const useSelectionStore = defineStore('selection', {
+ state: () => ({
+ selected: [],
+ lastSelection: [],
+ lastSelectedIndex: null,
+ } as SelectionStore),
+
+ actions: {
+ /**
+ * Set the selection of fileIds
+ * @param selection
+ */
+ set(selection = [] as FileSource[]) {
+ Vue.set(this, 'selected', [...new Set(selection)])
+ },
+
+ /**
+ * Set the last selected index
+ * @param lastSelectedIndex
+ */
+ setLastIndex(lastSelectedIndex = null as number | null) {
+ // Update the last selection if we provided a new selection starting point
+ Vue.set(this, 'lastSelection', lastSelectedIndex ? this.selected : [])
+ Vue.set(this, 'lastSelectedIndex', lastSelectedIndex)
+ },
+
+ /**
+ * Reset the selection
+ */
+ reset() {
+ Vue.set(this, 'selected', [])
+ Vue.set(this, 'lastSelection', [])
+ Vue.set(this, 'lastSelectedIndex', null)
+ },
+ },
+})
diff --git a/apps/files/src/store/uploader.ts b/apps/files/src/store/uploader.ts
new file mode 100644
index 00000000000..12c0f77cbf2
--- /dev/null
+++ b/apps/files/src/store/uploader.ts
@@ -0,0 +1,24 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Uploader } from '@nextcloud/upload'
+import type { UploaderStore } from '../types'
+
+import { defineStore } from 'pinia'
+import { getUploader } from '@nextcloud/upload'
+
+let uploader: Uploader
+
+export const useUploaderStore = function(...args) {
+ // Only init on runtime
+ uploader = getUploader()
+
+ const store = defineStore('uploader', {
+ state: () => ({
+ queue: uploader.queue,
+ } as UploaderStore),
+ })
+
+ return store(...args)
+}
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
new file mode 100644
index 00000000000..48fe01d5134
--- /dev/null
+++ b/apps/files/src/store/userconfig.ts
@@ -0,0 +1,62 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { UserConfig } from '../types'
+import { getCurrentUser } from '@nextcloud/auth'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { ref, set } from 'vue'
+import axios from '@nextcloud/axios'
+
+const initialUserConfig = loadState<UserConfig>('files', 'config', {
+ crop_image_previews: true,
+ default_view: 'files',
+ grid_view: false,
+ show_files_extensions: true,
+ show_hidden: false,
+ show_mime_column: true,
+ sort_favorites_first: true,
+ sort_folders_first: true,
+
+ show_dialog_deletion: false,
+ show_dialog_file_extension: true,
+})
+
+export const useUserConfigStore = defineStore('userconfig', () => {
+ const userConfig = ref<UserConfig>({ ...initialUserConfig })
+
+ /**
+ * Update the user config local store
+ * @param key The config key
+ * @param value The new value
+ */
+ function onUpdate(key: string, value: boolean): void {
+ set(userConfig.value, key, value)
+ }
+
+ /**
+ * Update the user config local store AND on server side
+ * @param key The config key
+ * @param value The new value
+ */
+ async function update(key: string, value: boolean): Promise<void> {
+ // only update if a user is logged in (not the case for public shares)
+ if (getCurrentUser() !== null) {
+ await axios.put(generateUrl('/apps/files/api/v1/config/{key}', { key }), {
+ value,
+ })
+ }
+ emit('files:config:updated', { key, value })
+ }
+
+ // Register the event listener
+ subscribe('files:config:updated', ({ key, value }) => onUpdate(key, value))
+
+ return {
+ userConfig,
+ update,
+ }
+})
diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts
new file mode 100644
index 00000000000..a902cedb6fa
--- /dev/null
+++ b/apps/files/src/store/viewConfig.ts
@@ -0,0 +1,96 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ViewConfigs, ViewId, ViewConfig } from '../types'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { ref, set } from 'vue'
+import axios from '@nextcloud/axios'
+
+const initialViewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
+
+export const useViewConfigStore = defineStore('viewconfig', () => {
+
+ const viewConfigs = ref({ ...initialViewConfig })
+
+ /**
+ * Get the config for a specific view
+ * @param viewid Id of the view to fet the config for
+ */
+ function getConfig(viewid: ViewId): ViewConfig {
+ return viewConfigs.value[viewid] || {}
+ }
+
+ /**
+ * Update the view config local store
+ * @param viewId The id of the view to update
+ * @param key The config key to update
+ * @param value The new value
+ */
+ function onUpdate(viewId: ViewId, key: string, value: string | number | boolean): void {
+ if (!(viewId in viewConfigs.value)) {
+ set(viewConfigs.value, viewId, {})
+ }
+ set(viewConfigs.value[viewId], key, value)
+ }
+
+ /**
+ * Update the view config local store AND on server side
+ * @param view Id of the view to update
+ * @param key Config key to update
+ * @param value New value
+ */
+ async function update(view: ViewId, key: string, value: string | number | boolean): Promise<void> {
+ if (getCurrentUser() !== null) {
+ await axios.put(generateUrl('/apps/files/api/v1/views'), {
+ value,
+ view,
+ key,
+ })
+ }
+
+ emit('files:view-config:updated', { view, key, value })
+ }
+
+ /**
+ * Set the sorting key AND sort by ASC
+ * The key param must be a valid key of a File object
+ * If not found, will be searched within the File attributes
+ * @param key Key to sort by
+ * @param view View to set the sorting key for
+ */
+ function setSortingBy(key = 'basename', view = 'files'): void {
+ // Save new config
+ update(view, 'sorting_mode', key)
+ update(view, 'sorting_direction', 'asc')
+ }
+
+ /**
+ * Toggle the sorting direction
+ * @param viewId id of the view to set the sorting order for
+ */
+ function toggleSortingDirection(viewId = 'files'): void {
+ const config = viewConfigs.value[viewId] || { sorting_direction: 'asc' }
+ const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc'
+
+ // Save new config
+ update(viewId, 'sorting_direction', newDirection)
+ }
+
+ // Initialize event listener
+ subscribe('files:view-config:updated', ({ view, key, value }) => onUpdate(view, key, value))
+
+ return {
+ viewConfigs,
+
+ getConfig,
+ setSortingBy,
+ toggleSortingDirection,
+ update,
+ }
+})
diff --git a/apps/files/src/templates.js b/apps/files/src/templates.js
deleted file mode 100644
index 7f7ebbf2dcc..00000000000
--- a/apps/files/src/templates.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { loadState } from '@nextcloud/initial-state'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import { generateOcsUrl } from '@nextcloud/router'
-import { getCurrentDirectory } from './utils/davUtils'
-import axios from '@nextcloud/axios'
-import Vue from 'vue'
-
-import TemplatePickerView from './views/TemplatePicker'
-import { showError } from '@nextcloud/dialogs'
-
-// Set up logger
-const logger = getLoggerBuilder()
- .setApp('files')
- .detectUser()
- .build()
-
-// Add translates functions
-Vue.mixin({
- methods: {
- t,
- n,
- },
-})
-
-// Create document root
-const TemplatePickerRoot = document.createElement('div')
-TemplatePickerRoot.id = 'template-picker'
-document.body.appendChild(TemplatePickerRoot)
-
-// Retrieve and init templates
-let templates = loadState('files', 'templates', [])
-let templatesPath = loadState('files', 'templates_path', false)
-logger.debug('Templates providers', templates)
-logger.debug('Templates folder', { templatesPath })
-
-// Init vue app
-const View = Vue.extend(TemplatePickerView)
-const TemplatePicker = new View({
- name: 'TemplatePicker',
- propsData: {
- logger,
- },
-})
-TemplatePicker.$mount('#template-picker')
-
-// Init template engine after load to make sure it's the last injected entry
-window.addEventListener('DOMContentLoaded', function() {
- if (!templatesPath) {
- logger.debug('Templates folder not initialized')
- const initTemplatesPlugin = {
- attach(menu) {
- // register the new menu entry
- menu.addMenuEntry({
- id: 'template-init',
- displayName: t('files', 'Set up templates folder'),
- templateName: t('files', 'Templates'),
- iconClass: 'icon-template-add',
- fileType: 'file',
- actionHandler(name) {
- initTemplatesFolder(name)
- menu.removeMenuEntry('template-init')
- },
- })
- },
- }
- OC.Plugins.register('OCA.Files.NewFileMenu', initTemplatesPlugin)
- }
-})
-
-// Init template files menu
-templates.forEach((provider, index) => {
- const newTemplatePlugin = {
- attach(menu) {
- const fileList = menu.fileList
-
- // only attach to main file list, public view is not supported yet
- if (fileList.id !== 'files' && fileList.id !== 'files.public') {
- return
- }
-
- // register the new menu entry
- menu.addMenuEntry({
- id: `template-new-${provider.app}-${index}`,
- displayName: provider.label,
- templateName: provider.label + provider.extension,
- iconClass: provider.iconClass || 'icon-file',
- fileType: 'file',
- actionHandler(name) {
- TemplatePicker.open(name, provider)
- },
- })
- },
- }
- OC.Plugins.register('OCA.Files.NewFileMenu', newTemplatePlugin)
-})
-
-/**
- * Init the template directory
- *
- * @param {string} name the templates folder name
- */
-const initTemplatesFolder = async function(name) {
- const templatePath = (getCurrentDirectory() + `/${name}`).replace('//', '/')
- try {
- logger.debug('Initializing the templates directory', { templatePath })
- const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
- templatePath,
- copySystemTemplates: true,
- })
-
- // Go to template directory
- OCA.Files.App.currentFileList.changeDirectory(templatePath, true, true)
-
- templates = response.data.ocs.data.templates
- templatesPath = response.data.ocs.data.template_path
- } catch (error) {
- logger.error('Unable to initialize the templates directory')
- showError(t('files', 'Unable to initialize the templates directory'))
- }
-}
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
new file mode 100644
index 00000000000..0096ecc0fdb
--- /dev/null
+++ b/apps/files/src/types.ts
@@ -0,0 +1,148 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileAction, Folder, Node, View } from '@nextcloud/files'
+import type { Upload } from '@nextcloud/upload'
+
+// Global definitions
+export type Service = string
+export type FileSource = string
+export type ViewId = string
+
+// Files store
+export type FilesStore = {
+ [source: FileSource]: Node
+}
+
+export type RootsStore = {
+ [service: Service]: Folder
+}
+
+export type FilesState = {
+ files: FilesStore,
+ roots: RootsStore,
+}
+
+export interface RootOptions {
+ root: Folder
+ service: Service
+}
+
+// Paths store
+export type PathConfig = {
+ [path: string]: FileSource
+}
+
+export type ServicesState = {
+ [service: Service]: PathConfig
+}
+
+export type PathsStore = {
+ paths: ServicesState
+}
+
+export interface PathOptions {
+ service: Service
+ path: string
+ source: FileSource
+}
+
+// User config store
+export interface UserConfig {
+ [key: string]: boolean | string | undefined
+
+ crop_image_previews: boolean
+ default_view: 'files' | 'personal'
+ grid_view: boolean
+ show_files_extensions: boolean
+ show_hidden: boolean
+ show_mime_column: boolean
+ sort_favorites_first: boolean
+ sort_folders_first: boolean
+
+ show_dialog_deletion: boolean
+ show_dialog_file_extension: boolean,
+}
+
+export interface UserConfigStore {
+ userConfig: UserConfig
+}
+
+export interface SelectionStore {
+ selected: FileSource[]
+ lastSelection: FileSource[]
+ lastSelectedIndex: number | null
+}
+
+// Actions menu store
+export type GlobalActions = 'global'
+export interface ActionsMenuStore {
+ opened: GlobalActions|string|null
+}
+
+// View config store
+export interface ViewConfig {
+ [key: string]: string|boolean
+}
+export interface ViewConfigs {
+ [viewId: ViewId]: ViewConfig
+}
+export interface ViewConfigStore {
+ viewConfig: ViewConfigs
+}
+
+// Renaming store
+export interface RenamingStore {
+ renamingNode?: Node
+ newName: string
+}
+
+// Uploader store
+export interface UploaderStore {
+ queue: Upload[]
+}
+
+// Drag and drop store
+export interface DragAndDropStore {
+ dragging: FileSource[]
+}
+
+// Active node store
+export interface ActiveStore {
+ activeAction: FileAction|null
+ activeFolder: Folder|null
+ activeNode: Node|null
+ activeView: View|null
+}
+
+/**
+ * Search scope for the in-files-search
+ */
+export type SearchScope = 'filter'|'globally'
+
+export interface TemplateFile {
+ app: string
+ label: string
+ extension: string
+ iconClass?: string
+ iconSvgInline?: string
+ mimetypes: string[]
+ ratio?: number
+ templates?: Record<string, unknown>[]
+}
+
+export type Capabilities = {
+ files: {
+ bigfilechunking: boolean
+ blacklisted_files: string[]
+ forbidden_filename_basenames: string[]
+ forbidden_filename_characters: string[]
+ forbidden_filename_extensions: string[]
+ forbidden_filenames: string[]
+ undelete: boolean
+ version_deletion: boolean
+ version_labeling: boolean
+ versioning: boolean
+ }
+}
diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts
new file mode 100644
index 00000000000..adacf621b4c
--- /dev/null
+++ b/apps/files/src/utils/actionUtils.ts
@@ -0,0 +1,74 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileAction } from '@nextcloud/files'
+
+import { NodeStatus } from '@nextcloud/files'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import { getPinia } from '../store'
+import { useActiveStore } from '../store/active'
+import logger from '../logger'
+
+/**
+ * Execute an action on the current active node
+ *
+ * @param action The action to execute
+ */
+export const executeAction = async (action: FileAction) => {
+ const activeStore = useActiveStore(getPinia())
+ const currentDir = (window?.OCP?.Files?.Router?.query?.dir || '/') as string
+ const currentNode = activeStore.activeNode
+ const currentView = activeStore.activeView
+
+ if (!currentNode || !currentView) {
+ logger.error('No active node or view', { node: currentNode, view: currentView })
+ return
+ }
+
+ if (currentNode.status === NodeStatus.LOADING) {
+ logger.debug('Node is already loading', { node: currentNode })
+ return
+ }
+
+ if (!action.enabled!([currentNode], currentView)) {
+ logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView })
+ return
+ }
+
+ let displayName = action.id
+ try {
+ displayName = action.displayName([currentNode], currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+
+ try {
+ // Set the loading marker
+ Vue.set(currentNode, 'status', NodeStatus.LOADING)
+ activeStore.activeAction = action
+
+ const success = await action.exec(currentNode, currentView, currentDir)
+
+ // If the action returns null, we stay silent
+ if (success === null || success === undefined) {
+ return
+ }
+
+ if (success) {
+ showSuccess(t('files', '{displayName}: done', { displayName }))
+ return
+ }
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } catch (error) {
+ logger.error('Error while executing action', { action, error })
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } finally {
+ // Reset the loading marker
+ Vue.set(currentNode, 'status', undefined)
+ activeStore.activeAction = undefined
+ }
+}
diff --git a/apps/files/src/utils/davUtils.js b/apps/files/src/utils/davUtils.js
deleted file mode 100644
index 1bd63347518..00000000000
--- a/apps/files/src/utils/davUtils.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import { generateRemoteUrl } from '@nextcloud/router'
-import { getCurrentUser } from '@nextcloud/auth'
-
-export const getRootPath = function() {
- if (getCurrentUser()) {
- return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`)
- } else {
- return generateRemoteUrl('webdav').replace('/remote.php', '/public.php')
- }
-}
-
-export const isPublic = function() {
- return !getCurrentUser()
-}
-
-export const getToken = function() {
- return document.getElementById('sharingToken') && document.getElementById('sharingToken').value
-}
-
-/**
- * Return the current directory, fallback to root
- *
- * @return {string}
- */
-export const getCurrentDirectory = function() {
- const currentDirInfo = OCA?.Files?.App?.currentFileList?.dirInfo
- || { path: '/', name: '' }
-
- // Make sure we don't have double slashes
- return `${currentDirInfo.path}/${currentDirInfo.name}`.replace(/\/\//gi, '/')
-}
diff --git a/apps/files/src/utils/davUtils.ts b/apps/files/src/utils/davUtils.ts
new file mode 100644
index 00000000000..54c1a6ea966
--- /dev/null
+++ b/apps/files/src/utils/davUtils.ts
@@ -0,0 +1,41 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+import type { WebDAVClientError } from 'webdav'
+
+/**
+ * Whether error is a WebDAVClientError
+ * @param error - Any exception
+ * @return {boolean} - Whether error is a WebDAVClientError
+ */
+function isWebDAVClientError(error: unknown): error is WebDAVClientError {
+ return error instanceof Error && 'status' in error && 'response' in error
+}
+
+/**
+ * Get a localized error message from webdav request
+ * @param error - An exception from webdav request
+ * @return {string} Localized error message for end user
+ */
+export function humanizeWebDAVError(error: unknown) {
+ if (error instanceof Error) {
+ if (isWebDAVClientError(error)) {
+ const status = error.status || error.response?.status || 0
+ if ([400, 404, 405].includes(status)) {
+ return t('files', 'Folder not found')
+ } else if (status === 403) {
+ return t('files', 'This operation is forbidden')
+ } else if (status === 500) {
+ return t('files', 'This folder is unavailable, please try again later or contact the administration')
+ } else if (status === 503) {
+ return t('files', 'Storage is temporarily not available')
+ }
+ }
+ return t('files', 'Unexpected error: {error}', { error: error.message })
+ }
+
+ return t('files', 'Unknown error')
+}
diff --git a/apps/files/src/utils/dragUtils.ts b/apps/files/src/utils/dragUtils.ts
new file mode 100644
index 00000000000..0722e313089
--- /dev/null
+++ b/apps/files/src/utils/dragUtils.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node } from '@nextcloud/files'
+import DragAndDropPreview from '../components/DragAndDropPreview.vue'
+import Vue from 'vue'
+
+const Preview = Vue.extend(DragAndDropPreview)
+let preview: Vue
+
+export const getDragAndDropPreview = async (nodes: Node[]): Promise<Element> => {
+ return new Promise((resolve) => {
+ if (!preview) {
+ preview = new Preview().$mount()
+ document.body.appendChild(preview.$el)
+ }
+
+ preview.update(nodes)
+ preview.$on('loaded', () => {
+ resolve(preview.$el)
+ preview.$off('loaded')
+ })
+ })
+}
diff --git a/apps/files/src/utils/fileUtils.js b/apps/files/src/utils/fileUtils.js
deleted file mode 100644
index 5ab88c6eb63..00000000000
--- a/apps/files/src/utils/fileUtils.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-const encodeFilePath = function(path) {
- const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
- let relativePath = ''
- pathSections.forEach((section) => {
- if (section !== '') {
- relativePath += '/' + encodeURIComponent(section)
- }
- })
- return relativePath
-}
-
-/**
- * Extract dir and name from file path
- *
- * @param {string} path the full path
- * @return {string[]} [dirPath, fileName]
- */
-const extractFilePaths = function(path) {
- const pathSections = path.split('/')
- const fileName = pathSections[pathSections.length - 1]
- const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
- return [dirPath, fileName]
-}
-
-export { encodeFilePath, extractFilePaths }
diff --git a/apps/files/src/utils/fileUtils.ts b/apps/files/src/utils/fileUtils.ts
new file mode 100644
index 00000000000..f0b974be21d
--- /dev/null
+++ b/apps/files/src/utils/fileUtils.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { FileType, type Node } from '@nextcloud/files'
+import { n } from '@nextcloud/l10n'
+
+/**
+ * Extract dir and name from file path
+ *
+ * @param path - The full path
+ * @return [dirPath, fileName]
+ */
+export function extractFilePaths(path: string): [string, string] {
+ const pathSections = path.split('/')
+ const fileName = pathSections[pathSections.length - 1]
+ const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
+ return [dirPath, fileName]
+}
+
+/**
+ * Generate a translated summary of an array of nodes
+ *
+ * @param nodes - The nodes to summarize
+ * @param hidden - The number of hidden nodes
+ */
+export function getSummaryFor(nodes: Node[], hidden = 0): string {
+ const fileCount = nodes.filter(node => node.type === FileType.File).length
+ const folderCount = nodes.filter(node => node.type === FileType.Folder).length
+
+ const summary: string[] = []
+ if (fileCount > 0 || folderCount === 0) {
+ const fileSummary = n('files', '%n file', '%n files', fileCount)
+ summary.push(fileSummary)
+ }
+ if (folderCount > 0) {
+ const folderSummary = n('files', '%n folder', '%n folders', folderCount)
+ summary.push(folderSummary)
+ }
+ if (hidden > 0) {
+ // TRANSLATORS: This is the number of hidden files or folders
+ const hiddenSummary = n('files', '%n hidden', '%n hidden', hidden)
+ summary.push(hiddenSummary)
+ }
+
+ return summary.join(' · ')
+}
diff --git a/apps/files/src/utils/filenameValidity.ts b/apps/files/src/utils/filenameValidity.ts
new file mode 100644
index 00000000000..2666d530052
--- /dev/null
+++ b/apps/files/src/utils/filenameValidity.ts
@@ -0,0 +1,41 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+
+/**
+ * Get the validity of a filename (empty if valid).
+ * This can be used for `setCustomValidity` on input elements
+ * @param name The filename
+ * @param escape Escape the matched string in the error (only set when used in HTML)
+ */
+export function getFilenameValidity(name: string, escape = false): string {
+ if (name.trim() === '') {
+ return t('files', 'Filename must not be empty.')
+ }
+
+ try {
+ validateFilename(name)
+ return ''
+ } catch (error) {
+ if (!(error instanceof InvalidFilenameError)) {
+ throw error
+ }
+
+ switch (error.reason) {
+ case InvalidFilenameErrorReason.Character:
+ return t('files', '"{char}" is not allowed inside a filename.', { char: error.segment }, undefined, { escape })
+ case InvalidFilenameErrorReason.ReservedName:
+ return t('files', '"{segment}" is a reserved name and not allowed for filenames.', { segment: error.segment }, undefined, { escape: false })
+ case InvalidFilenameErrorReason.Extension:
+ if (error.segment.match(/\.[a-z]/i)) {
+ return t('files', '"{extension}" is not an allowed filetype.', { extension: error.segment }, undefined, { escape: false })
+ }
+ return t('files', 'Filenames must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false })
+ default:
+ return t('files', 'Invalid filename.')
+ }
+ }
+}
diff --git a/apps/files/src/utils/filesViews.spec.ts b/apps/files/src/utils/filesViews.spec.ts
new file mode 100644
index 00000000000..03b0bb9aeb0
--- /dev/null
+++ b/apps/files/src/utils/filesViews.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, test } from 'vitest'
+import { defaultView, hasPersonalFilesView } from './filesViews.ts'
+
+describe('hasPersonalFilesView', () => {
+ beforeEach(() => removeInitialState())
+
+ test('enabled if user has unlimited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('enabled if user has limited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 1234 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('disabled if user has no quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(hasPersonalFilesView()).toBe(false)
+ })
+})
+
+describe('defaultView', () => {
+ beforeEach(removeInitialState)
+
+ test('Returns files view if set', () => {
+ mockInitialState('files', 'config', { default_view: 'files' })
+ expect(defaultView()).toBe('files')
+ })
+
+ test('Returns personal view if set and enabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(defaultView()).toBe('personal')
+ })
+
+ test('Falls back to files if personal view is disabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(defaultView()).toBe('files')
+ })
+})
+
+/**
+ * Remove the mocked initial state
+ */
+function removeInitialState(): void {
+ document.querySelectorAll('input[type="hidden"]').forEach((el) => {
+ el.remove()
+ })
+ // clear the cache
+ delete globalThis._nc_initial_state
+}
+
+/**
+ * Helper to mock an initial state value
+ * @param app - The app
+ * @param key - The key
+ * @param value - The value
+ */
+function mockInitialState(app: string, key: string, value: unknown): void {
+ const el = document.createElement('input')
+ el.value = btoa(JSON.stringify(value))
+ el.id = `initial-state-${app}-${key}`
+ el.type = 'hidden'
+
+ document.head.appendChild(el)
+}
diff --git a/apps/files/src/utils/filesViews.ts b/apps/files/src/utils/filesViews.ts
new file mode 100644
index 00000000000..9489c35cbde
--- /dev/null
+++ b/apps/files/src/utils/filesViews.ts
@@ -0,0 +1,30 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { UserConfig } from '../types.ts'
+
+import { loadState } from '@nextcloud/initial-state'
+
+/**
+ * Check whether the personal files view can be shown
+ */
+export function hasPersonalFilesView(): boolean {
+ const storageStats = loadState('files', 'storageStats', { quota: -1 })
+ // Don't show this view if the user has no storage quota
+ return storageStats.quota !== 0
+}
+
+/**
+ * Get the default files view
+ */
+export function defaultView() {
+ const { default_view: defaultView } = loadState<Partial<UserConfig>>('files', 'config', { default_view: 'files' })
+
+ // the default view - only use the personal one if it is enabled
+ if (defaultView !== 'personal' || hasPersonalFilesView()) {
+ return defaultView
+ }
+ return 'files'
+}
diff --git a/apps/files/src/utils/hashUtils.ts b/apps/files/src/utils/hashUtils.ts
new file mode 100644
index 00000000000..2e1fadff067
--- /dev/null
+++ b/apps/files/src/utils/hashUtils.ts
@@ -0,0 +1,17 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Simple non-secure hashing function similar to Java's `hashCode`
+ * @param str The string to hash
+ * @return {number} a non secure hash of the string
+ */
+export const hashCode = function(str: string): number {
+ let hash = 0
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
+ }
+ return (hash >>> 0)
+}
diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts
new file mode 100644
index 00000000000..a81fa9f4e17
--- /dev/null
+++ b/apps/files/src/utils/newNodeDialog.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+import { spawnDialog } from '@nextcloud/dialogs'
+import NewNodeDialog from '../components/NewNodeDialog.vue'
+
+interface ILabels {
+ /**
+ * Dialog heading, defaults to "New folder name"
+ */
+ name?: string
+ /**
+ * Label for input box, defaults to "New folder"
+ */
+ label?: string
+}
+
+/**
+ * Ask user for file or folder name
+ * @param defaultName Default name to use
+ * @param folderContent Nodes with in the current folder to check for unique name
+ * @param labels Labels to set on the dialog
+ * @return string if successful otherwise null if aborted
+ */
+export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
+ const contentNames = folderContent.map((node: Node) => node.basename)
+
+ return new Promise<string|null>((resolve) => {
+ spawnDialog(NewNodeDialog, {
+ ...labels,
+ defaultName,
+ otherNames: contentNames,
+ }, (folderName) => {
+ resolve(folderName as string|null)
+ })
+ })
+}
diff --git a/apps/files/src/utils/permissions.ts b/apps/files/src/utils/permissions.ts
new file mode 100644
index 00000000000..9b4c42bf49c
--- /dev/null
+++ b/apps/files/src/utils/permissions.ts
@@ -0,0 +1,37 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node } from '@nextcloud/files'
+import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts'
+
+import { Permission } from '@nextcloud/files'
+
+/**
+ * Check permissions on the node if it can be downloaded
+ * @param node The node to check
+ * @return True if downloadable, false otherwise
+ */
+export function isDownloadable(node: Node): boolean {
+ if ((node.permissions & Permission.READ) === 0) {
+ return false
+ }
+
+ // check hide-download property of shares
+ if (node.attributes['hide-download'] === true
+ || node.attributes['hide-download'] === 'true'
+ ) {
+ return false
+ }
+
+ // If the mount type is a share, ensure it got download permissions.
+ if (node.attributes['share-attributes']) {
+ const shareAttributes = JSON.parse(node.attributes['share-attributes'] || '[]') as Array<ShareAttribute>
+ const downloadAttribute = shareAttributes.find(({ scope, key }: ShareAttribute) => scope === 'permissions' && key === 'download')
+ if (downloadAttribute !== undefined) {
+ return downloadAttribute.value === true
+ }
+ }
+
+ return true
+}
diff --git a/apps/files/src/views/DialogConfirmFileExtension.cy.ts b/apps/files/src/views/DialogConfirmFileExtension.cy.ts
new file mode 100644
index 00000000000..460497dd91f
--- /dev/null
+++ b/apps/files/src/views/DialogConfirmFileExtension.cy.ts
@@ -0,0 +1,161 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { createTestingPinia } from '@pinia/testing'
+import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue'
+import { useUserConfigStore } from '../store/userconfig'
+
+describe('DialogConfirmFileExtension', () => {
+ it('renders with both extensions', () => {
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ oldExtension: '.old',
+ newExtension: '.new',
+ },
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
+ })
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('heading')
+ .should('contain.text', 'Change file extension')
+ cy.get('@dialog')
+ .findByRole('checkbox', { name: /Do not show this dialog again/i })
+ .should('exist')
+ .and('not.be.checked')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Keep .old' })
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Use .new' })
+ .should('be.visible')
+ })
+
+ it('renders without old extension', () => {
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ newExtension: '.new',
+ },
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
+ })
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Keep without extension' })
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Use .new' })
+ .should('be.visible')
+ })
+
+ it('renders without new extension', () => {
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ oldExtension: '.old',
+ },
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
+ })
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Keep .old' })
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Remove extension' })
+ .should('be.visible')
+ })
+
+ it('emits correct value on keep old', () => {
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ oldExtension: '.old',
+ newExtension: '.new',
+ },
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
+ }).as('component')
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Keep .old' })
+ .click()
+ cy.get('@component')
+ .its('wrapper')
+ .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[false]]))
+ })
+
+ it('emits correct value on use new', () => {
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ oldExtension: '.old',
+ newExtension: '.new',
+ },
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
+ }).as('component')
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('button', { name: 'Use .new' })
+ .click()
+ cy.get('@component')
+ .its('wrapper')
+ .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[true]]))
+ })
+
+ it('updates user config when checking the checkbox', () => {
+ const pinia = createTestingPinia({
+ createSpy: cy.spy,
+ })
+
+ cy.mount(DialogConfirmFileExtension, {
+ propsData: {
+ oldExtension: '.old',
+ newExtension: '.new',
+ },
+ global: {
+ plugins: [pinia],
+ },
+ }).as('component')
+
+ cy.findByRole('dialog')
+ .as('dialog')
+ .should('be.visible')
+ cy.get('@dialog')
+ .findByRole('checkbox', { name: /Do not show this dialog again/i })
+ .check({ force: true })
+
+ cy.wrap(useUserConfigStore())
+ .its('update')
+ .should('have.been.calledWith', 'show_dialog_file_extension', false)
+ })
+})
diff --git a/apps/files/src/views/DialogConfirmFileExtension.vue b/apps/files/src/views/DialogConfirmFileExtension.vue
new file mode 100644
index 00000000000..cc1ee363f98
--- /dev/null
+++ b/apps/files/src/views/DialogConfirmFileExtension.vue
@@ -0,0 +1,92 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import type { IDialogButton } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import { computed, ref } from 'vue'
+import { useUserConfigStore } from '../store/userconfig.ts'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import svgIconCancel from '@mdi/svg/svg/cancel.svg?raw'
+import svgIconCheck from '@mdi/svg/svg/check.svg?raw'
+
+const props = defineProps<{
+ oldExtension?: string
+ newExtension?: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'close', v: boolean): void
+}>()
+
+const userConfigStore = useUserConfigStore()
+const dontShowAgain = computed({
+ get: () => !userConfigStore.userConfig.show_dialog_file_extension,
+ set: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value),
+})
+
+const buttons = computed<IDialogButton[]>(() => [
+ {
+ label: props.oldExtension
+ ? t('files', 'Keep {old}', { old: props.oldExtension })
+ : t('files', 'Keep without extension'),
+ icon: svgIconCancel,
+ type: 'secondary',
+ callback: () => closeDialog(false),
+ },
+ {
+ label: props.newExtension
+ ? t('files', 'Use {new}', { new: props.newExtension })
+ : t('files', 'Remove extension'),
+ icon: svgIconCheck,
+ type: 'primary',
+ callback: () => closeDialog(true),
+ },
+])
+
+/** Open state of the dialog */
+const open = ref(true)
+
+/**
+ * Close the dialog and emit the response
+ * @param value User selected response
+ */
+function closeDialog(value: boolean) {
+ emit('close', value)
+ open.value = false
+}
+</script>
+
+<template>
+ <NcDialog :buttons="buttons"
+ :open="open"
+ :can-close="false"
+ :name="t('files', 'Change file extension')"
+ size="small">
+ <p v-if="newExtension && oldExtension">
+ {{ t('files', 'Changing the file extension from "{old}" to "{new}" may render the file unreadable.', { old: oldExtension, new: newExtension }) }}
+ </p>
+ <p v-else-if="oldExtension">
+ {{ t('files', 'Removing the file extension "{old}" may render the file unreadable.', { old: oldExtension }) }}
+ </p>
+ <p v-else-if="newExtension">
+ {{ t('files', 'Adding the file extension "{new}" may render the file unreadable.', { new: newExtension }) }}
+ </p>
+
+ <NcCheckboxRadioSwitch v-model="dontShowAgain"
+ class="dialog-confirm-file-extension__checkbox"
+ type="checkbox">
+ {{ t('files', 'Do not show this dialog again.') }}
+ </NcCheckboxRadioSwitch>
+ </NcDialog>
+</template>
+
+<style scoped>
+.dialog-confirm-file-extension__checkbox {
+ margin-top: 1rem;
+}
+</style>
diff --git a/apps/files/src/views/FileReferencePickerElement.vue b/apps/files/src/views/FileReferencePickerElement.vue
new file mode 100644
index 00000000000..b4d4bc54f14
--- /dev/null
+++ b/apps/files/src/views/FileReferencePickerElement.vue
@@ -0,0 +1,86 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div :id="containerId">
+ <FilePicker v-bind="filepickerOptions" @close="onClose" />
+ </div>
+</template>
+
+<script lang="ts">
+import type { Node as NcNode } from '@nextcloud/files'
+import type { IFilePickerButton } from '@nextcloud/dialogs'
+
+import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+ name: 'FileReferencePickerElement',
+ components: {
+ FilePicker,
+ },
+ props: {
+ providerId: {
+ type: String,
+ required: true,
+ },
+ accessible: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ computed: {
+ containerId() {
+ return `filepicker-${Math.random().toString(36).slice(7)}`
+ },
+ filepickerOptions() {
+ return {
+ allowPickDirectory: true,
+ buttons: this.buttonFactory,
+ container: `#${this.containerId}`,
+ multiselect: false,
+ name: t('files', 'Select file or folder to link to'),
+ }
+ },
+ },
+ methods: {
+ t,
+
+ buttonFactory(selected: NcNode[]): IFilePickerButton[] {
+ const buttons = [] as IFilePickerButton[]
+ if (selected.length === 0) {
+ return []
+ }
+ const node = selected.at(0)
+ if (node.path === '/') {
+ return [] // Do not allow selecting the users root folder
+ }
+ buttons.push({
+ label: t('files', 'Choose {file}', { file: node.displayname }),
+ type: 'primary',
+ callback: this.onClose,
+ })
+ return buttons
+ },
+
+ onClose(nodes?: NcNode[]) {
+ if (nodes === undefined || nodes.length === 0) {
+ this.$emit('cancel')
+ } else {
+ this.onSubmit(nodes[0])
+ }
+ },
+
+ onSubmit(node: NcNode) {
+ const url = new URL(window.location.href)
+ url.pathname = generateUrl('/f/{fileId}', { fileId: node.fileid! })
+ url.search = ''
+ this.$emit('submit', url.href)
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
new file mode 100644
index 00000000000..f9e517e92ee
--- /dev/null
+++ b/apps/files/src/views/FilesList.vue
@@ -0,0 +1,909 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcAppContent :page-heading="pageHeading" data-cy-files-content>
+ <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }">
+ <!-- Current folder breadcrumbs -->
+ <BreadCrumbs :path="directory" @reload="fetchContent">
+ <template #actions>
+ <!-- Sharing button -->
+ <NcButton v-if="canShare && fileListWidth >= 512"
+ :aria-label="shareButtonLabel"
+ :class="{ 'files-list__header-share-button--shared': shareButtonType }"
+ :title="shareButtonLabel"
+ class="files-list__header-share-button"
+ type="tertiary"
+ @click="openSharingSidebar">
+ <template #icon>
+ <LinkIcon v-if="shareButtonType === ShareType.Link" />
+ <AccountPlusIcon v-else :size="20" />
+ </template>
+ </NcButton>
+
+ <!-- Uploader -->
+ <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder"
+ allow-folders
+ class="files-list__header-upload-button"
+ :content="getContent"
+ :destination="currentFolder"
+ :forbidden-characters="forbiddenCharacters"
+ multiple
+ @failed="onUploadFail"
+ @uploaded="onUpload" />
+ </template>
+ </BreadCrumbs>
+
+ <!-- Secondary loading indicator -->
+ <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
+
+ <NcActions class="files-list__header-actions"
+ :inline="1"
+ type="tertiary"
+ force-name>
+ <NcActionButton v-for="action in enabledFileListActions"
+ :key="action.id"
+ :disabled="!!loadingAction"
+ :data-cy-files-list-action="action.id"
+ close-after-click
+ @click="execFileListAction(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loadingAction === action.id" :size="18" />
+ <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView"
+ :svg="action.iconSvgInline(currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </NcActions>
+
+ <NcButton v-if="fileListWidth >= 512 && enableGridView"
+ :aria-label="gridViewButtonLabel"
+ :title="gridViewButtonLabel"
+ class="files-list__header-grid-button"
+ type="tertiary"
+ @click="toggleGridView">
+ <template #icon>
+ <ListViewIcon v-if="userConfig.grid_view" />
+ <ViewGridIcon v-else />
+ </template>
+ </NcButton>
+ </div>
+
+ <!-- Drag and drop notice -->
+ <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" />
+
+ <!--
+ Initial current view loading0. This should never happen,
+ views are supposed to be registered far earlier in the lifecycle.
+ In case the URL is bad or a view is missing, we show a loading icon.
+ -->
+ <NcLoadingIcon v-if="!currentView"
+ class="files-list__loading-icon"
+ :size="38"
+ :name="t('files', 'Loading current folder')" />
+
+ <!-- File list - always mounted -->
+ <FilesListVirtual v-else
+ ref="filesListVirtual"
+ :current-folder="currentFolder"
+ :current-view="currentView"
+ :nodes="dirContentsSorted"
+ :summary="summary">
+ <template #empty>
+ <!-- Initial loading -->
+ <NcLoadingIcon v-if="loading && !isRefreshing"
+ class="files-list__loading-icon"
+ :size="38"
+ :name="t('files', 'Loading current folder')" />
+
+ <!-- Empty due to error -->
+ <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error>
+ <template #action>
+ <NcButton type="secondary" @click="fetchContent">
+ <template #icon>
+ <IconReload :size="20" />
+ </template>
+ {{ t('files', 'Retry') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <IconAlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+
+ <!-- Custom empty view -->
+ <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper">
+ <div ref="customEmptyView" />
+ </div>
+
+ <!-- Default empty directory view -->
+ <NcEmptyContent v-else
+ :name="currentView?.emptyTitle || t('files', 'No files in here')"
+ :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
+ data-cy-files-content-empty>
+ <template v-if="directory !== '/'" #action>
+ <!-- Uploader -->
+ <UploadPicker v-if="canUpload && !isQuotaExceeded"
+ allow-folders
+ class="files-list__header-upload-button"
+ :content="getContent"
+ :destination="currentFolder"
+ :forbidden-characters="forbiddenCharacters"
+ multiple
+ @failed="onUploadFail"
+ @uploaded="onUpload" />
+ <NcButton v-else :to="toPreviousDir" type="primary">
+ {{ t('files', 'Go back') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :svg="currentView?.icon" />
+ </template>
+ </NcEmptyContent>
+ </template>
+ </FilesListVirtual>
+ </NcAppContent>
+</template>
+
+<script lang="ts">
+import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files'
+import type { Upload } from '@nextcloud/upload'
+import type { CancelablePromise } from 'cancelable-promise'
+import type { ComponentPublicInstance } from 'vue'
+import type { Route } from 'vue-router'
+import type { UserConfig } from '../types.ts'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files'
+import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
+import { translate as t } from '@nextcloud/l10n'
+import { join, dirname, normalize, relative } from 'path'
+import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
+import { ShareType } from '@nextcloud/sharing'
+import { UploadPicker, UploadStatus } from '@nextcloud/upload'
+import { loadState } from '@nextcloud/initial-state'
+import { useThrottleFn } from '@vueuse/core'
+import { defineComponent } from 'vue'
+
+import NcAppContent from '@nextcloud/vue/components/NcAppContent'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue'
+import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import IconReload from 'vue-material-design-icons/Reload.vue'
+import LinkIcon from 'vue-material-design-icons/Link.vue'
+import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
+import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.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'
+import { useActiveStore } from '../store/active.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useFiltersStore } from '../store/filters.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useUploaderStore } from '../store/uploader.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import { useViewConfigStore } from '../store/viewConfig.ts'
+import { humanizeWebDAVError } from '../utils/davUtils.ts'
+import { getSummaryFor } from '../utils/fileUtils.ts'
+import { defaultView } from '../utils/filesViews.ts'
+import BreadCrumbs from '../components/BreadCrumbs.vue'
+import DragAndDropNotice from '../components/DragAndDropNotice.vue'
+import FilesListVirtual from '../components/FilesListVirtual.vue'
+import filesSortingMixin from '../mixins/filesSorting.ts'
+import logger from '../logger.ts'
+
+const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
+
+export default defineComponent({
+ name: 'FilesList',
+
+ components: {
+ BreadCrumbs,
+ DragAndDropNotice,
+ FilesListVirtual,
+ LinkIcon,
+ ListViewIcon,
+ NcAppContent,
+ NcActions,
+ NcActionButton,
+ NcButton,
+ NcEmptyContent,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ AccountPlusIcon,
+ UploadPicker,
+ ViewGridIcon,
+ IconAlertCircleOutline,
+ IconReload,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ isPublic: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+ const { directory, fileId } = useRouteParameters()
+ const fileListWidth = useFileListWidth()
+
+ const activeStore = useActiveStore()
+ const filesStore = useFilesStore()
+ const filtersStore = useFiltersStore()
+ const pathsStore = usePathsStore()
+ const selectionStore = useSelectionStore()
+ const uploaderStore = useUploaderStore()
+ const userConfigStore = useUserConfigStore()
+ const viewConfigStore = useViewConfigStore()
+
+ const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
+ const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
+
+ return {
+ currentView,
+ directory,
+ fileId,
+ fileListWidth,
+ t,
+
+ activeStore,
+ filesStore,
+ filtersStore,
+ pathsStore,
+ selectionStore,
+ uploaderStore,
+ userConfigStore,
+ viewConfigStore,
+
+ // non reactive data
+ enableGridView,
+ forbiddenCharacters,
+ ShareType,
+ }
+ },
+
+ data() {
+ return {
+ loading: true,
+ loadingAction: null as string | null,
+ error: null as string | null,
+ promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
+
+ dirContentsFiltered: [] as INode[],
+ }
+ },
+
+ computed: {
+ /**
+ * Get a callback function for the uploader to fetch directory contents for conflict resolution
+ */
+ getContent() {
+ const view = this.currentView!
+ return async (path?: string) => {
+ // as the path is allowed to be undefined we need to normalize the path ('//' to '/')
+ const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`)
+ // Try cache first
+ const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath)
+ if (nodes.length > 0) {
+ return nodes
+ }
+ // If not found in the files store (cache)
+ // use the current view to fetch the content for the requested path
+ return (await view.getContents(normalizedPath)).contents
+ }
+ },
+
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
+ pageHeading(): string {
+ const title = this.currentView?.name ?? t('files', 'Files')
+
+ if (this.currentFolder === undefined || this.directory === '/') {
+ return title
+ }
+ return `${this.currentFolder.displayname} - ${title}`
+ },
+
+ /**
+ * The current folder.
+ */
+ currentFolder(): Folder {
+ // Temporary fake folder to use until we have the first valid folder
+ // fetched and cached. This allow us to mount the FilesListVirtual
+ // at all time and avoid unmount/mount and undesired rendering issues.
+ const dummyFolder = new Folder({
+ id: 0,
+ source: getRemoteURL() + getRootPath(),
+ root: getRootPath(),
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.NONE,
+ })
+
+ if (!this.currentView?.id) {
+ return dummyFolder
+ }
+
+ return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
+ },
+
+ dirContents(): Node[] {
+ return (this.currentFolder?._children || [])
+ .map(this.filesStore.getNode)
+ .filter((node: Node) => !!node)
+ },
+
+ /**
+ * The current directory contents.
+ */
+ dirContentsSorted(): INode[] {
+ if (!this.currentView) {
+ return []
+ }
+
+ const customColumn = (this.currentView?.columns || [])
+ .find(column => column.id === this.sortingMode)
+
+ // Custom column must provide their own sorting methods
+ if (customColumn?.sort && typeof customColumn.sort === 'function') {
+ const results = [...this.dirContentsFiltered].sort(customColumn.sort)
+ return this.isAscSorting ? results : results.reverse()
+ }
+
+ const nodes = sortNodes(this.dirContentsFiltered, {
+ sortFavoritesFirst: this.userConfig.sort_favorites_first,
+ sortFoldersFirst: this.userConfig.sort_folders_first,
+ sortingMode: this.sortingMode,
+ sortingOrder: this.isAscSorting ? 'asc' : 'desc',
+ })
+
+ // TODO upstream this
+ if (this.currentView.id === 'files') {
+ nodes.sort((a, b) => {
+ const aa = relative(a.source, this.currentFolder!.source) === '..'
+ const bb = relative(b.source, this.currentFolder!.source) === '..'
+ if (aa && bb) {
+ return 0
+ } else if (aa) {
+ return -1
+ }
+ return 1
+ })
+ }
+
+ return nodes
+ },
+
+ /**
+ * The current directory is empty.
+ */
+ isEmptyDir(): boolean {
+ return this.dirContents.length === 0
+ },
+
+ /**
+ * We are refreshing the current directory.
+ * But we already have a cached version of it
+ * that is not empty.
+ */
+ isRefreshing(): boolean {
+ return this.currentFolder !== undefined
+ && !this.isEmptyDir
+ && this.loading
+ },
+
+ /**
+ * Route to the previous directory.
+ */
+ toPreviousDir(): Route {
+ const dir = this.directory.split('/').slice(0, -1).join('/') || '/'
+ return { ...this.$route, query: { dir } }
+ },
+
+ shareTypesAttributes(): number[] | undefined {
+ if (!this.currentFolder?.attributes?.['share-types']) {
+ return undefined
+ }
+ return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[]
+ },
+ shareButtonLabel() {
+ if (!this.shareTypesAttributes) {
+ return t('files', 'Share')
+ }
+
+ if (this.shareButtonType === ShareType.Link) {
+ return t('files', 'Shared by link')
+ }
+ return t('files', 'Shared')
+ },
+ shareButtonType(): ShareType | null {
+ if (!this.shareTypesAttributes) {
+ return null
+ }
+
+ // If all types are links, show the link icon
+ if (this.shareTypesAttributes.some(type => type === ShareType.Link)) {
+ return ShareType.Link
+ }
+
+ return ShareType.User
+ },
+
+ gridViewButtonLabel() {
+ return this.userConfig.grid_view
+ ? t('files', 'Switch to list view')
+ : t('files', 'Switch to grid view')
+ },
+
+ /**
+ * Check if the current folder has create permissions
+ */
+ canUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+ },
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ /**
+ * Check if current folder has share permissions
+ */
+ canShare() {
+ return isSharingEnabled && !this.isPublic
+ && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
+ },
+
+ showCustomEmptyView() {
+ return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined
+ },
+
+ enabledFileListActions() {
+ if (!this.currentView || !this.currentFolder) {
+ return []
+ }
+
+ const actions = getFileListActions()
+ const enabledActions = actions
+ .filter(action => {
+ if (action.enabled === undefined) {
+ return true
+ }
+ return action.enabled(
+ this.currentView!,
+ this.dirContents,
+ this.currentFolder as Folder,
+ )
+ })
+ .toSorted((a, b) => a.order - b.order)
+ return enabledActions
+ },
+
+ /**
+ * Using the filtered content if filters are active
+ */
+ summary() {
+ const hidden = this.dirContents.length - this.dirContentsFiltered.length
+ return getSummaryFor(this.dirContentsFiltered, hidden)
+ },
+
+ debouncedFetchContent() {
+ return useThrottleFn(this.fetchContent, 800, true)
+ },
+ },
+
+ watch: {
+ /**
+ * Handle rendering the custom empty view
+ * @param show The current state if the custom empty view should be rendered
+ */
+ showCustomEmptyView(show: boolean) {
+ if (show) {
+ this.$nextTick(() => {
+ const el = this.$refs.customEmptyView as HTMLDivElement
+ // We can cast here because "showCustomEmptyView" assets that current view is set
+ this.currentView!.emptyView!(el)
+ })
+ }
+ },
+
+ currentFolder() {
+ this.activeStore.activeFolder = this.currentFolder
+ },
+
+ currentView(newView, oldView) {
+ if (newView?.id === oldView?.id) {
+ return
+ }
+
+ logger.debug('View changed', { newView, oldView })
+ this.selectionStore.reset()
+ this.fetchContent()
+ },
+
+ directory(newDir, oldDir) {
+ 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.fetchContent()
+
+ // Scroll to top, force virtual scroller to re-render
+ const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined
+ if (filesListVirtual?.$el) {
+ filesListVirtual.$el.scrollTop = 0
+ }
+ },
+
+ dirContents(contents) {
+ logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
+ emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
+ // Also refresh the filtered content
+ this.filterDirContent()
+ },
+ },
+
+ async mounted() {
+ subscribe('files:node:deleted', this.onNodeDeleted)
+ subscribe('files:node:updated', this.onUpdatedNode)
+
+ // reload on settings change
+ subscribe('files:config:updated', this.fetchContent)
+
+ // filter content if filter were changed
+ subscribe('files:filters:changed', this.filterDirContent)
+
+ subscribe('files:search:updated', this.onUpdateSearch)
+
+ // Finally, fetch the current directory contents
+ await this.fetchContent()
+ if (this.fileId) {
+ // If we have a fileId, let's check if the file exists
+ const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString())
+ // If the file isn't in the current directory nor if
+ // the current directory is the file, we show an error
+ if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) {
+ showError(t('files', 'The file could not be found'))
+ }
+ }
+ },
+
+ 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)
+ unsubscribe('files:search:updated', this.onUpdateSearch)
+ },
+
+ methods: {
+ onUpdateSearch({ query, scope }) {
+ if (query && scope !== 'filter') {
+ this.debouncedFetchContent()
+ }
+ },
+
+ async fetchContent() {
+ this.loading = true
+ this.error = null
+ const dir = this.directory
+ const currentView = this.currentView
+
+ if (!currentView) {
+ logger.debug('The current view does not exists or is not ready.', { currentView })
+
+ // If we still haven't a valid view, let's wait for the page to load
+ // then try again. Else redirect to the default view
+ window.addEventListener('DOMContentLoaded', () => {
+ if (!this.currentView) {
+ logger.warn('No current view after DOMContentLoaded, redirecting to the default view')
+ window.OCP.Files.Router.goToRoute(null, { view: defaultView() })
+ }
+ }, { once: true })
+ return
+ }
+
+ logger.debug('Fetching contents for directory', { dir, currentView })
+
+ // If we have a cancellable promise ongoing, cancel it
+ if (this.promise && 'cancel' in this.promise) {
+ this.promise.cancel()
+ logger.debug('Cancelled previous ongoing fetch')
+ }
+
+ // Fetch the current dir contents
+ this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
+ try {
+ const { folder, contents } = await this.promise
+ logger.debug('Fetched contents', { dir, folder, contents })
+
+ // Update store
+ this.filesStore.updateNodes(contents)
+
+ // Define current directory children
+ // TODO: make it more official
+ this.$set(folder, '_children', contents.map(node => node.source))
+
+ // If we're in the root dir, define the root
+ if (dir === '/') {
+ this.filesStore.setRoot({ service: currentView.id, root: folder })
+ } else {
+ // Otherwise, add the folder to the store
+ if (folder.fileid) {
+ this.filesStore.updateNodes([folder])
+ this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir })
+ } else {
+ // If we're here, the view API messed up
+ logger.fatal('Invalid root folder returned', { dir, folder, currentView })
+ }
+ }
+
+ // Update paths store
+ const folders = contents.filter(node => node.type === 'folder')
+ folders.forEach((node) => {
+ this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) })
+ })
+ } catch (error) {
+ logger.error('Error while fetching content', { error })
+ this.error = humanizeWebDAVError(error)
+ } finally {
+ this.loading = false
+ }
+
+ },
+
+ /**
+ * 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
+ * @param {Upload} upload the uploaded data
+ */
+ onUpload(upload: Upload) {
+ // Let's only refresh the current Folder
+ // Navigating to a different folder will refresh it anyway
+ const needsRefresh = dirname(upload.source) === this.currentFolder!.source
+
+ // TODO: fetch uploaded files data only
+ // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
+ if (needsRefresh) {
+ // fetchContent will cancel the previous ongoing promise
+ this.fetchContent()
+ }
+ },
+
+ async onUploadFail(upload: Upload) {
+ const status = upload.response?.status || 0
+
+ if (upload.status === UploadStatus.CANCELLED) {
+ showWarning(t('files', 'Upload was cancelled by user'))
+ return
+ }
+
+ // Check known status codes
+ if (status === 507) {
+ showError(t('files', 'Not enough free space'))
+ return
+ } else if (status === 404 || status === 409) {
+ showError(t('files', 'Target folder does not exist any more'))
+ return
+ } else if (status === 403) {
+ showError(t('files', 'Operation is blocked by access control'))
+ return
+ }
+
+ // Else we try to parse the response error message
+ if (typeof upload.response?.data === 'string') {
+ try {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(upload.response.data, 'text/xml')
+ const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? ''
+ if (message.trim() !== '') {
+ // The server message is also translated
+ showError(t('files', 'Error during upload: {message}', { message }))
+ return
+ }
+ } catch (error) {
+ logger.error('Could not parse message', { error })
+ }
+ }
+
+ // Finally, check the status code if we have one
+ if (status !== 0) {
+ showError(t('files', 'Error during upload, status code {status}', { status }))
+ return
+ }
+
+ showError(t('files', 'Unknown error during upload'))
+ },
+
+ /**
+ * Refreshes the current folder on update.
+ *
+ * @param node is the file/folder being updated.
+ */
+ onUpdatedNode(node?: Node) {
+ if (node?.fileid === this.currentFolder?.fileid) {
+ this.fetchContent()
+ }
+ },
+
+ openSharingSidebar() {
+ if (!this.currentFolder) {
+ logger.debug('No current folder found for opening sharing sidebar')
+ return
+ }
+
+ if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
+ window.OCA.Files.Sidebar.setActiveTab('sharing')
+ }
+ sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path)
+ },
+
+ toggleGridView() {
+ this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
+ },
+
+ filterDirContent() {
+ let nodes: INode[] = this.dirContents
+ for (const filter of this.filtersStore.sortedFilters) {
+ nodes = filter.filter(nodes)
+ }
+ this.dirContentsFiltered = nodes
+ },
+
+ actionDisplayName(action: FileListAction): string {
+ let displayName = action.id
+ try {
+ displayName = action.displayName(this.currentView!)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+ return displayName
+ },
+
+ async execFileListAction(action: FileListAction) {
+ this.loadingAction = action.id
+
+ const displayName = this.actionDisplayName(action)
+ try {
+ const success = await action.exec(this.source, this.dirContents, this.currentDir)
+ // If the action returns null, we stay silent
+ if (success === null || success === undefined) {
+ return
+ }
+
+ if (success) {
+ showSuccess(t('files', '{displayName}: done', { displayName }))
+ return
+ }
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } catch (error) {
+ logger.error('Error while executing action', { action, error })
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } finally {
+ this.loadingAction = null
+ }
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+:global(.toast-loading-icon) {
+ // Reduce start margin (it was made for text but this is an icon)
+ margin-inline-start: -4px;
+ // 16px icon + 5px on both sides
+ min-width: 26px;
+}
+
+.app-content {
+ // Virtual list needs to be full height and is scrollable
+ display: flex;
+ overflow: hidden;
+ flex-direction: column;
+ max-height: 100%;
+ position: relative !important;
+}
+
+.files-list {
+ &__header {
+ display: flex;
+ align-items: center;
+ // Do not grow or shrink (vertically)
+ flex: 0 0;
+ max-width: 100%;
+ // Align with the navigation toggle icon
+ margin-block: var(--app-navigation-padding, 4px);
+ margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
+
+ &--public {
+ // There is no navigation toggle on public shares
+ margin-inline: 0 var(--app-navigation-padding, 4px);
+ }
+
+ >* {
+ // Do not grow or shrink (horizontally)
+ // Only the breadcrumbs shrinks
+ flex: 0 0;
+ }
+
+ &-share-button {
+ color: var(--color-text-maxcontrast) !important;
+
+ &--shared {
+ color: var(--color-main-text) !important;
+ }
+ }
+
+ &-actions {
+ min-width: fit-content !important;
+ margin-inline: calc(var(--default-grid-baseline) * 2);
+ }
+ }
+
+ &__before {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--default-grid-baseline) * 2);
+ margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding));
+ }
+
+ &__empty-view-wrapper {
+ display: flex;
+ height: 100%;
+ }
+
+ &__refresh-icon {
+ flex: 0 0 var(--default-clickable-area);
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
+ }
+
+ &__loading-icon {
+ margin: auto;
+ }
+}
+</style>
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index c8b0f07dea1..7357943ee28 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -1,28 +1,61 @@
-import * as InitialState from '@nextcloud/initial-state'
-import * as L10n from '@nextcloud/l10n'
-import FolderSvg from '@mdi/svg/svg/folder.svg'
-import ShareSvg from '@mdi/svg/svg/share-variant.svg'
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Navigation } from '@nextcloud/files'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import { createTestingPinia } from '@pinia/testing'
-import NavigationService from '../services/Navigation'
import NavigationView from './Navigation.vue'
-import router from '../router/router.js'
-
-describe('Navigation renders', () => {
- const Navigation = new NavigationService()
+import { useViewConfigStore } from '../store/viewConfig'
+import { Folder, View, getNavigation } from '@nextcloud/files'
+
+import router from '../router/router.ts'
+import RouterService from '../services/RouterService'
+
+const resetNavigation = () => {
+ const nav = getNavigation()
+ ;[...nav.views].forEach(({ id }) => nav.remove(id))
+ nav.setActive(null)
+}
+
+const createView = (id: string, name: string, parent?: string) => new View({
+ id,
+ name,
+ getContents: async () => ({ folder: {} as Folder, contents: [] }),
+ icon: FolderSvg,
+ order: 1,
+ parent,
+})
- before(() => {
- cy.stub(InitialState, 'loadState')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: -1,
- })
+function mockWindow() {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router = new RouterService(router)
+}
+describe('Navigation renders', () => {
+ before(async () => {
+ delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
+
+ cy.mockInitialState('files', 'storageStats', {
+ used: 1000 * 1000 * 1000,
+ quota: -1,
+ })
})
+ after(() => cy.unmockInitialState())
+
it('renders', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -33,22 +66,31 @@ describe('Navigation renders', () => {
})
describe('Navigation API', () => {
- const Navigation = new NavigationService()
+ let Navigation: Navigation
+
+ before(async () => {
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ mockWindow()
+
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
+ })
+
+ beforeEach(() => resetNavigation())
it('Check API entries rendering', () => {
- Navigation.register({
- id: 'files',
- name: 'Files',
- getFiles: () => [],
- icon: FolderSvg,
- order: 1,
- })
+ Navigation.register(createView('files', 'Files'))
+ console.warn(Navigation.views)
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [
+ createTestingPinia({
+ createSpy: cy.spy,
+ }),
+ ],
+ },
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -58,19 +100,16 @@ describe('Navigation API', () => {
})
it('Adds a new entry and render', () => {
- Navigation.register({
- id: 'sharing',
- name: 'Sharing',
- getFiles: () => [],
- icon: ShareSvg,
- order: 2,
- })
+ Navigation.register(createView('files', 'Files'))
+ Navigation.register(createView('sharing', 'Sharing'))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -80,76 +119,67 @@ describe('Navigation API', () => {
})
it('Adds a new children, render and open menu', () => {
- Navigation.register({
- id: 'sharingin',
- name: 'Shared with me',
- getFiles: () => [],
- parent: 'sharing',
- icon: ShareSvg,
- order: 1,
- })
+ Navigation.register(createView('files', 'Files'))
+ Navigation.register(createView('sharing', 'Sharing'))
+ Navigation.register(createView('sharingin', 'Shared with me', 'sharing'))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
+ cy.wrap(useViewConfigStore()).as('viewConfigStore')
+
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 3)
- // Intercept collapse preference request
- cy.intercept('POST', '*/apps/files/api/v1/toggleShowFolder/*', {
- statusCode: 200,
- }).as('toggleShowFolder')
-
// Toggle the sharing entry children
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist')
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
- cy.wait('@toggleShowFolder')
+
+ // Expect store update to be called
+ cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true)
// Validate children
cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me')
+ // Toggle the sharing entry children 🇦again
+ cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
+ cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible')
+
+ // Expect store update to be called
+ cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false)
})
it('Throws when adding a duplicate entry', () => {
- expect(() => {
- Navigation.register({
- id: 'files',
- name: 'Files',
- getFiles: () => [],
- icon: FolderSvg,
- order: 1,
- })
- }).to.throw('Navigation id files is already registered')
+ Navigation.register(createView('files', 'Files'))
+ expect(() => Navigation.register(createView('files', 'Files')))
+ .to.throw('View id files is already registered')
})
})
describe('Quota rendering', () => {
- const Navigation = new NavigationService()
-
- beforeEach(() => {
- // TODO: remove when @nextcloud/l10n 2.0 is released
- // https://github.com/nextcloud/nextcloud-l10n/pull/542
- cy.stub(L10n, 'translate', (app, text, vars = {}, number) => {
- cy.log({app, text, vars, number})
- return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => {
- return vars[key]
- })
- })
+ before(async () => {
+ delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
- it('Unknown quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns(undefined)
+ afterEach(() => cy.unmockInitialState())
+ it('Unknown quota', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -157,16 +187,18 @@ describe('Quota rendering', () => {
})
it('Unlimited quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: -1,
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 1024 * 1024 * 1024,
+ quota: -1,
+ total: 50 * 1024 * 1024 * 1024,
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -176,44 +208,50 @@ describe('Quota rendering', () => {
})
it('Non-reached quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: 5 * 1024 * 1024 * 1024,
- relative: 20, // percent
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 1024 * 1024 * 1024,
+ quota: 5 * 1024 * 1024 * 1024,
+ total: 5 * 1024 * 1024 * 1024,
+ relative: 20, // percent
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20')
+ cy.get('[data-cy-files-navigation-settings-quota] progress')
+ .should('exist')
+ .and('have.attr', 'value', '20')
})
it('Reached quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 5 * 1024 * 1024 * 1024,
- quota: 1024 * 1024 * 1024,
- relative: 500, // percent
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 5 * 1024 * 1024 * 1024,
+ quota: 1024 * 1024 * 1024,
+ total: 1024 * 1024 * 1024,
+ relative: 500, // percent
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100
+ cy.get('[data-cy-files-navigation-settings-quota] progress')
+ .should('exist')
+ .and('have.attr', 'value', '100') // progress max is 100
})
})
diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue
index 040e1482e32..0f3c3647c6e 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -1,45 +1,24 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- -
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <NcAppNavigation data-cy-files-navigation>
- <template #list>
- <NcAppNavigationItem v-for="view in parentViews"
- :key="view.id"
- :allow-collapse="true"
- :data-cy-files-navigation-item="view.id"
- :icon="view.iconClass"
- :open="view.expanded"
- :pinned="view.sticky"
- :title="view.name"
- :to="generateToNavigation(view)"
- @update:open="onToggleExpand(view)">
- <NcAppNavigationItem v-for="child in childViews[view.id]"
- :key="child.id"
- :data-cy-files-navigation-item="child.id"
- :exact="true"
- :icon="child.iconClass"
- :title="child.name"
- :to="generateToNavigation(child)" />
- </NcAppNavigationItem>
+ <NcAppNavigation data-cy-files-navigation
+ class="files-navigation"
+ :aria-label="t('files', 'Files')">
+ <template #search>
+ <FilesNavigationSearch />
+ </template>
+ <template #default>
+ <NcAppNavigationList class="files-navigation__list"
+ :aria-label="t('files', 'Views')">
+ <FilesNavigationItem :views="viewMap" />
+ </NcAppNavigationList>
+
+ <!-- Settings modal-->
+ <SettingsModal :open.sync="settingsOpened"
+ data-cy-files-navigation-settings
+ @close="onSettingsClose" />
</template>
<!-- Non-scrollable navigation bottom elements -->
@@ -49,54 +28,75 @@
<NavigationQuota />
<!-- Files settings modal toggle-->
- <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')"
- :title="t('files', 'Files settings')"
+ <NcAppNavigationItem :name="t('files', 'Files settings')"
data-cy-files-navigation-settings-button
@click.prevent.stop="openSettings">
- <Cog slot="icon" :size="20" />
+ <IconCog slot="icon" :size="20" />
</NcAppNavigationItem>
</ul>
</template>
-
- <!-- Settings modal-->
- <SettingsModal :open="settingsOpened"
- data-cy-files-navigation-settings
- @close="onSettingsClose" />
</NcAppNavigation>
</template>
-<script>
-import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
-import { translate } from '@nextcloud/l10n'
-
-import axios from '@nextcloud/axios'
-import Cog from 'vue-material-design-icons/Cog.vue'
-import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+<script lang="ts">
+import type { View } from '@nextcloud/files'
+import type { ViewConfig } from '../types.ts'
-import logger from '../logger.js'
-import Navigation from '../services/Navigation.ts'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import IconCog from 'vue-material-design-icons/CogOutline.vue'
+import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
+import FilesNavigationItem from '../components/FilesNavigationItem.vue'
+import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
+
+import { useNavigation } from '../composables/useNavigation'
+import { useFiltersStore } from '../store/filters.ts'
+import { useViewConfigStore } from '../store/viewConfig.ts'
+import logger from '../logger.ts'
+
+const collator = Intl.Collator(
+ [getLanguage(), getCanonicalLocale()],
+ {
+ numeric: true,
+ usage: 'sort',
+ },
+)
-export default {
+export default defineComponent({
name: 'Navigation',
components: {
- Cog,
+ IconCog,
+ FilesNavigationItem,
+ FilesNavigationSearch,
+
+ NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
+ NcAppNavigationList,
SettingsModal,
- NavigationQuota,
},
- props: {
- // eslint-disable-next-line vue/prop-name-casing
- Navigation: {
- type: Navigation,
- required: true,
- },
+ setup() {
+ const filtersStore = useFiltersStore()
+ const viewConfigStore = useViewConfigStore()
+ const { currentView, views } = useNavigation()
+
+ return {
+ currentView,
+ t,
+ views,
+
+ filtersStore,
+ viewConfigStore,
+ }
},
data() {
@@ -106,134 +106,77 @@ export default {
},
computed: {
+ /**
+ * The current view ID from the route params
+ */
currentViewId() {
return this.$route?.params?.view || 'files'
},
- /** @return {Navigation} */
- currentView() {
- return this.views.find(view => view.id === this.currentViewId)
- },
-
- /** @return {Navigation[]} */
- views() {
- return this.Navigation.views
- },
-
- /** @return {Navigation[]} */
- parentViews() {
- return this.views
- // filter child views
- .filter(view => !view.parent)
- // sort views by order
- .sort((a, b) => {
- return a.order - b.order
- })
- },
-
- /** @return {Navigation[]} */
- childViews() {
+ /**
+ * Map of parent ids to views
+ */
+ viewMap(): Record<string, View[]> {
return this.views
- // filter parent views
- .filter(view => !!view.parent)
- // create a map of parents and their children
- .reduce((list, view) => {
- list[view.parent] = [...(list[view.parent] || []), view]
- // Sort children by order
- list[view.parent].sort((a, b) => {
- return a.order - b.order
+ .reduce((map, view) => {
+ map[view.parent!] = [...(map[view.parent!] || []), view]
+ map[view.parent!].sort((a, b) => {
+ if (typeof a.order === 'number' || typeof b.order === 'number') {
+ return (a.order ?? 0) - (b.order ?? 0)
+ }
+ return collator.compare(a.name, b.name)
})
- return list
- }, {})
+ return map
+ }, {} as Record<string, View[]>)
},
},
watch: {
- currentView(view, oldView) {
- logger.debug('View changed', { id: view.id, view })
- this.showView(view, oldView)
+ currentViewId(newView, oldView) {
+ if (this.currentViewId !== this.currentView?.id) {
+ // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
+ const view = this.views.find(({ id }) => id === this.currentViewId)!
+ // The new view as active
+ this.showView(view)
+ logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view })
+ }
},
},
- beforeMount() {
- if (this.currentView) {
- logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
- this.showView(this.currentView)
- }
+ created() {
+ subscribe('files:folder-tree:initialized', this.loadExpandedViews)
+ subscribe('files:folder-tree:expanded', this.loadExpandedViews)
+ },
- subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
+ beforeMount() {
+ // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
+ const view = this.views.find(({ id }) => id === this.currentViewId)!
+ this.showView(view)
+ logger.debug('Navigation mounted. Showing requested view', { view })
},
methods: {
- /**
- * @param {Navigation} view the new active view
- * @param {Navigation} oldView the old active view
- */
- showView(view, oldView) {
- // Closing any opened sidebar
- window?.OCA?.Files?.Sidebar?.close?.()
-
- if (view.legacy) {
- const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
- document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
- el.classList.add('hidden')
- })
- newAppContent.classList.remove('hidden')
-
- // Triggering legacy navigation events
- const { dir = '/' } = OC.Util.History.parseUrlQuery()
- const params = { itemId: view.id, dir }
-
- logger.debug('Triggering legacy navigation event', params)
- window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
- window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
-
- }
-
- this.Navigation.setActive(view)
- emit('files:navigation:changed', view)
- },
-
- /**
- * Coming from the legacy files app.
- * TODO: remove when all views are migrated.
- *
- * @param {Navigation} view the new active view
- */
- onLegacyNavigationChanged({ id } = { id: 'files' }) {
- const view = this.Navigation.views.find(view => view.id === id)
- if (view && view.legacy && view.id !== this.currentView.id) {
- // Force update the current route as the request comes
- // from the legacy files app router
- this.$router.replace({ ...this.$route, params: { view: view.id } })
- this.Navigation.setActive(view)
- this.showView(view)
+ async loadExpandedViews() {
+ const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>)
+ .filter(([, config]) => config.expanded === true)
+ .map(([viewId]) => this.views.find(view => view.id === viewId))
+ // eslint-disable-next-line no-use-before-define
+ .filter(Boolean as unknown as ((u: unknown) => u is View))
+ .filter((view) => view.loadChildViews && !view.loaded)
+ for (const view of viewsToLoad) {
+ await view.loadChildViews(view)
}
},
/**
- * Expand/collapse a a view with children and permanently
- * save this setting in the server.
- *
- * @param {Navigation} view the view to toggle
- */
- onToggleExpand(view) {
- // Invert state
- view.expanded = !view.expanded
- axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded })
- },
-
- /**
- * Generate the route to a view
- *
- * @param {Navigation} view the view to toggle
+ * Set the view as active on the navigation and handle internal state
+ * @param view View to set active
*/
- generateToNavigation(view) {
- if (view.params) {
- const { dir, fileid } = view.params
- return { name: 'filelist', params: view.params, query: { dir, fileid } }
- }
- return { name: 'filelist', params: { view: view.id } }
+ showView(view: View) {
+ // Closing any opened sidebar
+ window.OCA?.Files?.Sidebar?.close?.()
+ getNavigation().setActive(view)
+ emit('files:navigation:changed', view)
},
/**
@@ -249,22 +192,20 @@ export default {
onSettingsClose() {
this.settingsOpened = false
},
-
- t: translate,
},
-}
+})
</script>
<style scoped lang="scss">
-// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in
-.app-navigation::v-deep .app-navigation-entry-icon {
- background-repeat: no-repeat;
- background-position: center;
-}
-
-.app-navigation > ul.app-navigation__list {
- // Use flex gap value for more elegant spacing
- padding-bottom: var(--default-grid-baseline, 4px);
+.app-navigation {
+ :deep(.app-navigation-entry.active .button-vue.icon-collapse:not(:hover)) {
+ color: var(--color-primary-element-text);
+ }
+
+ > ul.app-navigation__list {
+ // Use flex gap value for more elegant spacing
+ padding-bottom: var(--default-grid-baseline, 4px);
+ }
}
.app-navigation-entry__settings {
@@ -274,4 +215,14 @@ export default {
// Prevent shrinking or growing
flex: 0 0 auto;
}
+
+.files-navigation {
+ &__list {
+ height: 100%; // Fill all available space for sticky views
+ }
+
+ :deep(.app-navigation__content > ul.app-navigation__list) {
+ will-change: scroll-position;
+ }
+}
</style>
diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue
new file mode 100644
index 00000000000..9db346ea35d
--- /dev/null
+++ b/apps/files/src/views/ReferenceFileWidget.vue
@@ -0,0 +1,306 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div v-if="!accessible" class="widget-file widget-file--no-access">
+ <span class="widget-file__image widget-file__image--icon">
+ <FolderIcon v-if="isFolder" :size="88" />
+ <FileIcon v-else :size="88" />
+ </span>
+ <span class="widget-file__details">
+ <p class="widget-file__title">
+ {{ t('files', 'File cannot be accessed') }}
+ </p>
+ <p class="widget-file__description">
+ {{ t('files', 'The file could not be found or you do not have permissions to view it. Ask the sender to share it.') }}
+ </p>
+ </span>
+ </div>
+
+ <!-- Live preview if a handler is available -->
+ <component :is="viewerHandler.component"
+ v-else-if="interactive && viewerHandler && !failedViewer"
+ :active="false /* prevent video from autoplaying */"
+ :can-swipe="false"
+ :can-zoom="false"
+ :is-embedded="true"
+ v-bind="viewerFile"
+ :file-list="[viewerFile]"
+ :is-full-screen="false"
+ :is-sidebar-shown="false"
+ class="widget-file widget-file--interactive"
+ @error="failedViewer = true" />
+
+ <!-- The file is accessible -->
+ <a v-else
+ class="widget-file widget-file--link"
+ :href="richObject.link"
+ target="_blank"
+ @click="navigate">
+ <span class="widget-file__image" :class="filePreviewClass" :style="filePreviewStyle">
+ <template v-if="!previewUrl">
+ <FolderIcon v-if="isFolder" :size="88" fill-color="var(--color-primary-element)" />
+ <FileIcon v-else :size="88" />
+ </template>
+ </span>
+ <span class="widget-file__details">
+ <p class="widget-file__title">{{ richObject.name }}</p>
+ <p class="widget-file__description">{{ fileSize }}<br>{{ fileMtime }}</p>
+ <p class="widget-file__link">{{ filePath }}</p>
+ </span>
+ </a>
+</template>
+
+<script lang="ts">
+import { defineComponent, type Component, type PropType } from 'vue'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { Node } from '@nextcloud/files'
+import FileIcon from 'vue-material-design-icons/File.vue'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import path from 'path'
+
+// see lib/private/Collaboration/Reference/File/FileReferenceProvider.php
+type Ressource = {
+ id: number
+ name: string
+ size: number
+ path: string
+ link: string
+ mimetype: string
+ mtime: number // as unix timestamp
+ 'preview-available': boolean
+}
+
+type ViewerHandler = {
+ id: string
+ group: string
+ mimes: string[]
+ component: Component
+}
+
+/**
+ * Minimal mock of the legacy Viewer FileInfo
+ * TODO: replace by Node object
+ */
+type ViewerFile = {
+ filename: string // the path to the root folder
+ basename: string // the file name
+ lastmod: Date // the last modification date
+ size: number // the file size in bytes
+ type: string
+ mime: string
+ fileid: number
+ failed: boolean
+ loaded: boolean
+ davPath: string
+ source: string
+}
+
+export default defineComponent({
+ name: 'ReferenceFileWidget',
+ components: {
+ FolderIcon,
+ FileIcon,
+ },
+ props: {
+ richObject: {
+ type: Object as PropType<Ressource>,
+ required: true,
+ },
+ accessible: {
+ type: Boolean,
+ default: true,
+ },
+ interactive: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ data() {
+ return {
+ previewUrl: null as string | null,
+ failedViewer: false,
+ }
+ },
+
+ computed: {
+ availableViewerHandlers(): ViewerHandler[] {
+ return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[]
+ },
+ viewerHandler(): ViewerHandler | undefined {
+ return this.availableViewerHandlers
+ .find(handler => handler.mimes.includes(this.richObject.mimetype))
+ },
+ viewerFile(): ViewerFile {
+ const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`)
+ .replace(/\/\/$/, '/')
+ return {
+ filename: this.richObject.path,
+ basename: this.richObject.name,
+ lastmod: new Date(this.richObject.mtime * 1000),
+ size: this.richObject.size,
+ type: 'file',
+ mime: this.richObject.mimetype,
+ fileid: this.richObject.id,
+ failed: false,
+ loaded: true,
+ davPath: davSource,
+ source: davSource,
+ }
+ },
+
+ fileSize() {
+ return window.OC.Util.humanFileSize(this.richObject.size)
+ },
+ fileMtime() {
+ return window.OC.Util.relativeModifiedDate(this.richObject.mtime * 1000)
+ },
+ filePath() {
+ return path.dirname(this.richObject.path)
+ },
+ filePreviewStyle() {
+ if (this.previewUrl) {
+ return {
+ backgroundImage: 'url(' + this.previewUrl + ')',
+ }
+ }
+ return {}
+ },
+ filePreviewClass() {
+ if (this.previewUrl) {
+ return 'widget-file__image--preview'
+ }
+ return 'widget-file__image--icon'
+
+ },
+ isFolder() {
+ return this.richObject.mimetype === 'httpd/unix-directory'
+ },
+ },
+
+ mounted() {
+ if (this.richObject['preview-available']) {
+ const previewUrl = generateUrl('/core/preview?fileId={fileId}&x=250&y=250', {
+ fileId: this.richObject.id,
+ })
+ const img = new Image()
+ img.onload = () => {
+ this.previewUrl = previewUrl
+ }
+ img.onerror = err => {
+ console.error('could not load recommendation preview', err)
+ }
+ img.src = previewUrl
+ }
+ },
+ methods: {
+ navigate(event) {
+ if (this.isFolder) {
+ event.stopPropagation()
+ event.preventDefault()
+ this.openFilePicker()
+ } else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) {
+ event.stopPropagation()
+ event.preventDefault()
+ window?.OCA?.Viewer?.open({ path: this.richObject.path })
+ }
+ },
+
+ openFilePicker() {
+ const picker = getFilePickerBuilder(t('settings', 'Your files'))
+ .allowDirectories(true)
+ .setMultiSelect(false)
+ .addButton({
+ id: 'open',
+ label: this.t('settings', 'Open in files'),
+ callback(nodes: Node[]) {
+ if (nodes[0]) {
+ window.open(generateUrl('/f/{fileid}', {
+ fileid: nodes[0].fileid,
+ }))
+ }
+ },
+ type: 'primary',
+ })
+ .disableNavigation()
+ .startAt(this.richObject.path)
+ .build()
+ picker.pick()
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.widget-file {
+ display: flex;
+ flex-grow: 1;
+ color: var(--color-main-text) !important;
+ text-decoration: none !important;
+ padding: 0 !important;
+
+ &__image {
+ width: 30%;
+ min-width: 160px;
+ max-width: 320px;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+
+ &--icon {
+ min-width: 88px;
+ max-width: 88px;
+ padding: 12px;
+ padding-inline-end: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ &__title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: bold;
+ }
+
+ &__details {
+ padding: 12px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ &__description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ }
+
+ // No preview, standard link to ressource
+ &--link {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &--interactive {
+ position: relative;
+ height: 400px;
+ max-height: 50vh;
+ margin: 0;
+ }
+}
+</style>
diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue
new file mode 100644
index 00000000000..904e1b0831d
--- /dev/null
+++ b/apps/files/src/views/SearchEmptyView.vue
@@ -0,0 +1,53 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import { mdiMagnifyClose } from '@mdi/js'
+import { t } from '@nextcloud/l10n'
+import debounce from 'debounce'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import { getPinia } from '../store/index.ts'
+import { useSearchStore } from '../store/search.ts'
+
+const searchStore = useSearchStore(getPinia())
+const debouncedUpdate = debounce((value: string) => {
+ searchStore.query = value
+}, 500)
+</script>
+
+<template>
+ <NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnifyClose" />
+ </template>
+ <template #action>
+ <div class="search-empty-view__wrapper">
+ <NcInputField class="search-empty-view__input"
+ :label="t('files', 'Search for files')"
+ :model-value="searchStore.query"
+ type="search"
+ @update:model-value="debouncedUpdate" />
+ </div>
+ </template>
+ </NcEmptyContent>
+</template>
+
+<style scoped lang="scss">
+.search-empty-view {
+ &__input {
+ flex: 0 1;
+ min-width: min(400px, 50vw);
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: baseline;
+ }
+}
+</style>
diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue
index 9a63fea4924..bfac8e0b3d6 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -1,36 +1,70 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- -
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcAppSettingsDialog :open="open"
:show-navigation="true"
- :title="t('files', 'Files settings')"
+ :name="t('files', 'Files settings')"
@update:open="onClose">
<!-- Settings API-->
- <NcAppSettingsSection id="settings" :title="t('files', 'Files settings')">
- <NcCheckboxRadioSwitch :checked.sync="show_hidden"
+ <NcAppSettingsSection id="settings" :name="t('files', 'General')">
+ <fieldset class="files-settings__default-view"
+ data-cy-files-settings-setting="default_view">
+ <legend>
+ {{ t('files', 'Default view') }}
+ </legend>
+ <NcCheckboxRadioSwitch :model-value="userConfig.default_view"
+ name="default_view"
+ type="radio"
+ value="files"
+ @update:model-value="setConfig('default_view', $event)">
+ {{ t('files', 'All files') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :model-value="userConfig.default_view"
+ name="default_view"
+ type="radio"
+ value="personal"
+ @update:model-value="setConfig('default_view', $event)">
+ {{ t('files', 'Personal files') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first"
+ :checked="userConfig.sort_favorites_first"
+ @update:checked="setConfig('sort_favorites_first', $event)">
+ {{ t('files', 'Sort favorites first') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_folders_first"
+ :checked="userConfig.sort_folders_first"
+ @update:checked="setConfig('sort_folders_first', $event)">
+ {{ t('files', 'Sort folders before files') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree"
+ :checked="userConfig.folder_tree"
+ @update:checked="setConfig('folder_tree', $event)">
+ {{ t('files', 'Folder tree') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <!-- Appearance -->
+ <NcAppSettingsSection id="settings" :name="t('files', 'Appearance')">
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden"
+ :checked="userConfig.show_hidden"
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch :checked.sync="crop_image_previews"
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column"
+ :checked="userConfig.show_mime_column"
+ @update:checked="setConfig('show_mime_column', $event)">
+ {{ t('files', 'Show file type column') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_files_extensions"
+ :checked="userConfig.show_files_extensions"
+ @update:checked="setConfig('show_files_extensions', $event)">
+ {{ t('files', 'Show file extensions') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews"
+ :checked="userConfig.crop_image_previews"
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
@@ -39,19 +73,21 @@
<!-- Settings API-->
<NcAppSettingsSection v-if="settings.length !== 0"
id="more-settings"
- :title="t('files', 'Additional settings')">
+ :name="t('files', 'Additional settings')">
<template v-for="setting in settings">
<Setting :key="setting.name" :el="setting.el" />
</template>
</NcAppSettingsSection>
<!-- Webdav URL-->
- <NcAppSettingsSection id="webdav" :title="t('files', 'Webdav')">
+ <NcAppSettingsSection id="webdav" :name="t('files', 'WebDAV')">
<NcInputField id="webdav-url-input"
+ :label="t('files', 'WebDAV URL')"
:show-trailing-button="true"
:success="webdavUrlCopied"
- :trailing-button-label="t('files', 'Copy to clipboard')"
+ :trailing-button-label="t('files', 'Copy')"
:value="webdavUrl"
+ class="webdav-url-input"
readonly="readonly"
type="url"
@focus="$event.target.select()"
@@ -61,34 +97,209 @@
</template>
</NcInputField>
<em>
- <a :href="webdavDocs" target="_blank" rel="noreferrer noopener">
- {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗
+ <a class="setting-link"
+ :href="webdavDocs"
+ target="_blank"
+ rel="noreferrer noopener">
+ {{ t('files', 'How to access files using WebDAV') }} ↗
</a>
</em>
+ <br>
+ <em v-if="isTwoFactorEnabled">
+ <a class="setting-link" :href="appPasswordUrl">
+ {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗
+ </a>
+ </em>
+ </NcAppSettingsSection>
+
+ <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')">
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="userConfig.show_dialog_file_extension"
+ @update:checked="setConfig('show_dialog_file_extension', $event)">
+ {{ t('files', 'Warn before changing a file extension') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="userConfig.show_dialog_deletion"
+ @update:checked="setConfig('show_dialog_deletion', $event)">
+ {{ t('files', 'Warn before deleting files') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <NcAppSettingsSection id="shortcuts"
+ :name="t('files', 'Keyboard shortcuts')">
+
+ <h3>{{ t('files', 'Actions') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>a</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'File actions') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>F2</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Rename') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Del</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Delete') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>s</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Add or remove favorite') }}
+ </dd>
+ </div>
+ <div v-if="isSystemtagsEnabled">
+ <dt class="shortcut-key">
+ <kbd>t</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Manage tags') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'Selection') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>A</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select all files') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>ESC</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Deselect all') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>Space</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select or deselect') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>Shift</kbd> <span>+ <kbd>Space</kbd></span>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select a range') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'Navigation') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Alt</kbd> + <kbd>↑</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to parent folder') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>↑</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to file above') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>↓</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to file below') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>←</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go left in grid') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>→</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go right in grid') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'View') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>V</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Toggle grid view') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>D</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Open file sidebar') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>?</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Show those shortcuts') }}
+ </dd>
+ </div>
+ </dl>
</NcAppSettingsSection>
</NcAppSettingsDialog>
</template>
<script>
-import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js'
-import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import Clipboard from 'vue-material-design-icons/Clipboard.vue'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField'
-import Setting from '../components/Setting.vue'
-
-import { emit } from '@nextcloud/event-bus'
-import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
-import { loadState } from '@nextcloud/initial-state'
+import { getCapabilities } from '@nextcloud/capabilities'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
-const userConfig = loadState('files', 'config', {
- show_hidden: false,
- crop_image_previews: true,
-})
+import Clipboard from 'vue-material-design-icons/ContentCopy.vue'
+import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
+import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+
+import { useUserConfigStore } from '../store/userconfig.ts'
+import Setting from '../components/Setting.vue'
export default {
name: 'Settings',
@@ -108,21 +319,55 @@ export default {
},
},
- data() {
+ setup() {
+ const userConfigStore = useUserConfigStore()
+ const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true
return {
+ isSystemtagsEnabled,
+ userConfigStore,
+ t,
+ }
+ },
- ...userConfig,
-
+ data() {
+ return {
// Settings API
settings: window.OCA?.Files?.Settings?.settings || [],
// Webdav infos
webdavUrl: generateRemoteUrl('dav/files/' + encodeURIComponent(getCurrentUser()?.uid)),
webdavDocs: 'https://docs.nextcloud.com/server/stable/go.php?to=user-webdav',
+ appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'),
webdavUrlCopied: false,
+ enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true),
+ isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)),
}
},
+ computed: {
+ userConfig() {
+ return this.userConfigStore.userConfig
+ },
+
+ sortedSettings() {
+ // Sort settings by name
+ return [...this.settings].sort((a, b) => {
+ if (a.order && b.order) {
+ return a.order - b.order
+ }
+ return a.name.localeCompare(b.name)
+ })
+ },
+ },
+
+ created() {
+ // ? opens the settings dialog on the keyboard shortcuts section
+ useHotKey('?', this.showKeyboardShortcuts, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
beforeMount() {
// Update the settings API entries state
this.settings.forEach(setting => setting.open())
@@ -139,10 +384,7 @@ export default {
},
setConfig(key, value) {
- emit('files:config:updated', { key, value })
- axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
- value,
- })
+ this.userConfigStore.update(key, value)
},
async copyCloudId() {
@@ -156,17 +398,47 @@ export default {
await navigator.clipboard.writeText(this.webdavUrl)
this.webdavUrlCopied = true
- showSuccess(t('files', 'Webdav URL copied to clipboard'))
+ showSuccess(t('files', 'WebDAV URL copied'))
setTimeout(() => {
this.webdavUrlCopied = false
}, 5000)
},
- t: translate,
+ async showKeyboardShortcuts() {
+ this.$emit('update:open', true)
+
+ await this.$nextTick()
+ document.getElementById('settings-section_shortcuts').scrollIntoView({
+ behavior: 'smooth',
+ inline: 'nearest',
+ })
+ },
},
}
</script>
<style lang="scss" scoped>
+.files-settings {
+ &__default-view {
+ margin-bottom: 0.5rem;
+ }
+}
+
+.setting-link:hover {
+ text-decoration: underline;
+}
+.shortcut-key {
+ width: 160px;
+ // some shortcuts are too long to fit in one line
+ white-space: normal;
+ span {
+ // force portion of a shortcut on a new line for nicer display
+ white-space: nowrap;
+ }
+}
+
+.webdav-url-input {
+ margin-block-end: 0.5rem;
+}
</style>
diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue
index c97fb304c32..40a16d42b42 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -1,49 +1,60 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcAppSidebar v-if="file"
ref="sidebar"
+ data-cy-sidebar
v-bind="appSidebar"
:force-menu="true"
- tabindex="0"
@close="close"
@update:active="setActiveTab"
- @update:starred="toggleStarred"
@[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>
- <LegacyView v-for="view in views"
- :key="view.cid"
- :component="view"
- :file-info="fileInfo" />
+ <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>
</template>
<!-- Actions menu -->
<template v-if="fileInfo" #secondary-actions>
+ <NcActionButton :close-after-click="true"
+ @click="toggleStarred(!fileInfo.isFavourited)">
+ <template #icon>
+ <NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStarOutline : mdiStar" />
+ </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"
@@ -81,41 +92,71 @@
</template>
</NcAppSidebar>
</template>
-<script>
+<script lang="ts">
+import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files'
+import { defineComponent } from 'vue'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { encodePath } from '@nextcloud/paths'
+import { fetchNode } from '../services/WebdavClient.ts'
+import { generateUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { getCurrentUser } from '@nextcloud/auth'
+import { mdiStar, mdiStarOutline } from '@mdi/js'
+import { ShareType } from '@nextcloud/sharing'
+import { showError } from '@nextcloud/dialogs'
import $ from 'jquery'
import axios from '@nextcloud/axios'
-import { emit } from '@nextcloud/event-bus'
-import moment from '@nextcloud/moment'
-import { Type as ShareTypes } from '@nextcloud/sharing'
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
+import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+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 FileInfo from '../services/FileInfo'
-import SidebarTab from '../components/SidebarTab'
-import LegacyView from '../components/LegacyView'
+import FileInfo from '../services/FileInfo.js'
+import LegacyView from '../components/LegacyView.vue'
+import SidebarTab from '../components/SidebarTab.vue'
+import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
+import logger from '../logger.ts'
-export default {
+export default defineComponent({
name: 'Sidebar',
components: {
+ LegacyView,
NcActionButton,
NcAppSidebar,
+ NcDateTime,
NcEmptyContent,
- LegacyView,
+ NcIconSvgWrapper,
SidebarTab,
+ SystemTags,
+ NcUserBubble,
+ },
+
+ setup() {
+ const currentUser = getCurrentUser()
+
+ // Non reactive properties
+ return {
+ currentUser,
+
+ mdiStar,
+ mdiStarOutline,
+ }
},
data() {
return {
// reactive state
Sidebar: OCA.Files.Sidebar.state,
+ showTags: false,
+ showTagsDefault: true,
error: null,
loading: true,
fileInfo: null,
- starLoading: false,
+ node: null,
isFullScreen: false,
hasLowHeight: false,
}
@@ -157,14 +198,12 @@ export default {
* @return {string}
*/
davPath() {
- const user = OC.getCurrentUser().uid
- return OC.linkToRemote(`dav/files/${user}${encodePath(this.file)}`)
+ return `${davRemoteURL}${davRootPath}${encodePath(this.file)}`
},
/**
* Current active tab handler
*
- * @param {string} id the tab id to set as active
* @return {string} the current active tab
*/
activeTab() {
@@ -172,39 +211,12 @@ export default {
},
/**
- * Sidebar subtitle
- *
- * @return {string}
- */
- subtitle() {
- return `${this.size}, ${this.time}`
- },
-
- /**
- * File last modified formatted string
- *
- * @return {string}
- */
- time() {
- return OC.Util.relativeModifiedDate(this.fileInfo.mtime)
- },
-
- /**
- * File last modified full string
- *
- * @return {string}
- */
- fullTime() {
- return moment(this.fileInfo.mtime).format('LLL')
- },
-
- /**
* File size formatted string
*
* @return {string}
*/
size() {
- return OC.Util.humanFileSize(this.fileInfo.size)
+ return formatFileSize(this.fileInfo?.size)
},
/**
@@ -225,7 +237,6 @@ export default {
if (this.fileInfo) {
return {
'data-mimetype': this.fileInfo.mimetype,
- 'star-loading': this.starLoading,
active: this.activeTab,
background: this.background,
class: {
@@ -234,24 +245,27 @@ export default {
},
compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen,
loading: this.loading,
- starred: this.fileInfo.isFavourited,
- subtitle: this.subtitle,
- subtitleTooltip: this.fullTime,
- title: this.fileInfo.name,
- titleTooltip: this.fileInfo.name,
+ 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
- subtitle: '',
- title: '',
+ subname: '',
+ name: '',
+ class: {
+ 'app-sidebar--full': this.isFullScreen,
+ },
}
}
// no fileInfo yet, showing empty data
return {
loading: this.loading,
- subtitle: '',
- title: '',
+ subname: '',
+ name: '',
+ class: {
+ 'app-sidebar--full': this.isFullScreen,
+ },
}
},
@@ -282,14 +296,36 @@ export default {
},
isSystemTagsEnabled() {
- return OCA && 'SystemTags' in OCA
+ 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()
},
beforeDestroy() {
+ unsubscribe('file:node:deleted', this.onNodeDeleted)
window.removeEventListener('resize', this.handleWindowResize)
},
@@ -314,8 +350,9 @@ export default {
},
getPreviewIfAny(fileInfo) {
- if (fileInfo.hasPreview && !this.isFullScreen) {
- return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`)
+ 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)
},
@@ -328,7 +365,7 @@ export default {
* @return {string} Url to the icon for mimeType
*/
getIconUrl(fileInfo) {
- const mimeType = fileInfo.mimetype || 'application/octet-stream'
+ const mimeType = fileInfo?.mimetype || 'application/octet-stream'
if (mimeType === 'httpd/unix-directory') {
// use default folder icon
if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') {
@@ -338,8 +375,8 @@ export default {
} else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') {
return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType)
} else if (fileInfo.shareTypes && (
- fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) > -1
- || fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_EMAIL) > -1)
+ 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) {
@@ -357,17 +394,23 @@ export default {
*/
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 favourite state
+ * Toggle favorite state
* TODO: better implementation
*
- * @param {boolean} state favourited or not
+ * @param {boolean} state is favorite or not
*/
async toggleStarred(state) {
try {
- this.starLoading = true
await axios({
method: 'PROPPATCH',
url: this.davPath,
@@ -381,17 +424,28 @@ export default {
</d:propertyupdate>`,
})
- // TODO: Obliterate as soon as possible and use events with new files app
- // Terrible fallback for legacy files: toggle filelist as well
- if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) {
- OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList)
- }
+ /**
+ * 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({
+ fileid: this.fileInfo.id,
+ source: `${davRemoteURL}${davRootPath}${this.file}`,
+ root: davRootPath,
+ mime: isDir ? undefined : this.fileInfo.mimetype,
+ attributes: {
+ favorite: 1,
+ },
+ })
+ emit(state ? 'files:favorites:added' : 'files:favorites:removed', node)
+ this.fileInfo.isFavourited = state
} catch (error) {
- OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
- console.error('Unable to change favourite state', error)
+ showError(t('files', 'Unable to change the favorite state of the file'))
+ logger.error('Unable to change favorite state', { error })
}
- this.starLoading = false
},
onDefaultAction() {
@@ -410,9 +464,10 @@ export default {
* Toggle the tags selector
*/
toggleTags() {
- if (OCA.SystemTags && OCA.SystemTags.View) {
- OCA.SystemTags.View.toggle()
- }
+ // toggle
+ this.showTags = !this.showTags
+ // save the new state
+ this.setShowTagsDefault(this.showTags)
},
/**
@@ -423,38 +478,50 @@ export default {
* @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
- if (path && path.trim() !== '') {
- // reset data, keep old fileInfo to not reload all tabs and just hide them
- this.error = null
- this.loading = true
+ // reset data, keep old fileInfo to not reload all tabs and just hide them
+ this.error = null
+ this.loading = true
- try {
- this.fileInfo = await FileInfo(this.davPath)
- // 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)
- })
-
- this.$nextTick(() => {
- if (this.$refs.tabs) {
- this.$refs.tabs.updateTabs()
- }
- })
- } catch (error) {
- this.error = t('files', 'Error while loading the file data')
- console.error('Error while loading the file data', error)
+ 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()
- throw new Error(error)
- } finally {
- this.loading = false
+ 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')
+ console.error('Error while loading the file data', error)
+
+ throw new Error(error)
}
},
@@ -463,10 +530,21 @@ export default {
*/
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.
@@ -483,6 +561,15 @@ export default {
},
/**
+ * 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() {
@@ -501,11 +588,11 @@ export default {
this.hasLowHeight = document.documentElement.clientHeight < 1024
},
},
-}
+})
</script>
<style lang="scss" scoped>
.app-sidebar {
- &--has-preview::v-deep {
+ &--has-preview:deep {
.app-sidebar-header__figure {
background-size: cover;
}
@@ -525,12 +612,40 @@ export default {
height: 100% !important;
}
+ :deep {
+ .app-sidebar-header__description {
+ margin: 0 16px 4px 16px !important;
+ }
+ }
+
.svg-icon {
- ::v-deep svg {
+ :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
}
}
+
+.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;
+ width: 100%;
+ gap: 8px 0;
+ }
</style>
diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue
index 33b925aa2ed..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -1,30 +1,13 @@
<!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcModal v-if="opened"
:clear-view-delay="-1"
class="templates-picker"
- size="normal"
+ size="large"
@close="close">
<form class="templates-picker__form"
:style="style"
@@ -34,7 +17,9 @@
<!-- Templates list -->
<ul class="templates-picker__list">
<TemplatePreview v-bind="emptyTemplate"
+ ref="emptyTemplatePreview"
:checked="checked === emptyTemplate.fileid"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
<TemplatePreview v-for="template in provider.templates"
@@ -42,14 +27,12 @@
v-bind="template"
:checked="checked === template.fileid"
:ratio="provider.ratio"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
</ul>
<!-- Cancel and submit -->
<div class="templates-picker__buttons">
- <button @click="close">
- {{ t('files', 'Cancel') }}
- </button>
<input type="submit"
class="primary"
:value="t('files', 'Create')"
@@ -63,21 +46,29 @@
</NcModal>
</template>
-<script>
-import { normalize } from 'path'
-import { showError } from '@nextcloud/dialogs'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal'
-
-import { getCurrentDirectory } from '../utils/davUtils'
-import { createFromTemplate, getTemplates } from '../services/Templates'
-import TemplatePreview from '../components/TemplatePreview'
+<script lang="ts">
+import type { TemplateFile } from '../types.ts'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError, spawnDialog } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import { File } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { normalize, extname, join } from 'path'
+import { defineComponent } from 'vue'
+import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js'
+
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import TemplatePreview from '../components/TemplatePreview.vue'
+import TemplateFiller from '../components/TemplateFiller.vue'
+import logger from '../logger.ts'
const border = 2
const margin = 8
-const width = margin * 20
-export default {
+export default defineComponent({
name: 'TemplatePicker',
components: {
@@ -87,9 +78,12 @@ export default {
},
props: {
- logger: {
+ /**
+ * The parent folder where to create the node
+ */
+ parent: {
type: Object,
- required: true,
+ default: () => null,
},
},
@@ -98,44 +92,57 @@ export default {
// Check empty template by default
checked: -1,
loading: false,
- name: null,
+ name: null as string|null,
opened: false,
- provider: null,
+ provider: null as TemplateFile|null,
}
},
computed: {
- /**
- * Strip away extension from name
- *
- * @return {string}
- */
+ extension() {
+ return extname(this.name ?? '')
+ },
+
nameWithoutExt() {
- return this.name.indexOf('.') > -1
- ? this.name.split('.').slice(0, -1).join('.')
- : this.name
+ // Strip extension from name if defined
+ return !this.extension
+ ? this.name!
+ : this.name!.slice(0, 0 - this.extension.length)
},
emptyTemplate() {
return {
basename: t('files', 'Blank'),
fileid: -1,
- filename: this.t('files', 'Blank'),
+ filename: t('files', 'Blank'),
hasPreview: false,
mime: this.provider?.mimetypes[0] || this.provider?.mimetypes,
}
},
selectedTemplate() {
- return this.provider.templates.find(template => template.fileid === this.checked)
+ if (!this.provider) {
+ return null
+ }
+
+ return this.provider.templates!.find((template) => template.fileid === this.checked)
},
/**
- * Style css vars bin,d
+ * Style css vars bind
*
* @return {object}
*/
style() {
+ if (!this.provider) {
+ return {}
+ }
+
+ // Fallback to 16:9 landscape ratio
+ const ratio = this.provider.ratio ? this.provider.ratio : 1.77
+ // Landscape templates should be wider than tall ones
+ // We fit 3 templates per row at max for landscape and 4 for portrait
+ const width = ratio > 1 ? margin * 30 : margin * 20
return {
'--margin': margin + 'px',
'--width': width + 'px',
@@ -147,14 +154,15 @@ export default {
},
methods: {
+ t,
+
/**
* Open the picker
*
* @param {string} name the file name to create
* @param {object} provider the template provider picked
*/
- async open(name, provider) {
-
+ async open(name: string, provider) {
this.checked = this.emptyTemplate.fileid
this.name = name
this.provider = provider
@@ -174,6 +182,11 @@ export default {
// Else, open the picker
this.opened = true
+
+ // Set initial focus to the empty template preview
+ this.$nextTick(() => {
+ this.$refs.emptyTemplatePreview?.focus()
+ })
},
/**
@@ -190,60 +203,98 @@ export default {
/**
* Manages the radio template picker change
*
- * @param {number} fileid the selected template file id
+ * @param fileid the selected template file id
*/
- onCheck(fileid) {
+ onCheck(fileid: number) {
this.checked = fileid
},
- async onSubmit() {
- this.loading = true
- const currentDirectory = getCurrentDirectory()
- const fileList = OCA?.Files?.App?.currentFileList
+ onConfirmClick(fileid: number) {
+ if (fileid === this.checked) {
+ this.onSubmit()
+ }
+ },
+
+ async createFile(templateFields = []) {
+ const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/'
// If the file doesn't have an extension, add the default one
if (this.nameWithoutExt === this.name) {
- this.logger.debug('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
- this.name = this.name + this.provider?.extension
+ logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
+ this.name = `${this.name}${this.provider?.extension ?? ''}`
}
try {
const fileInfo = await createFromTemplate(
normalize(`${currentDirectory}/${this.name}`),
- this.selectedTemplate?.filename,
- this.selectedTemplate?.templateType,
+ this.selectedTemplate?.filename as string ?? '',
+ this.selectedTemplate?.templateType as string ?? '',
+ templateFields,
)
- this.logger.debug('Created new file', fileInfo)
-
- // Fetch FileInfo and model
- const data = await fileList?.addAndFetchFileInfo(this.name).then((status, data) => data)
- const model = new OCA.Files.FileInfoModel(data, {
- filesClient: fileList?.filesClient,
+ logger.debug('Created new file', fileInfo)
+
+ const owner = getCurrentUser()?.uid || null
+ const node = new File({
+ id: fileInfo.fileid,
+ source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)),
+ root: `/files/${owner}`,
+ mime: fileInfo.mime,
+ mtime: new Date(fileInfo.lastmod * 1000),
+ owner,
+ size: fileInfo.size,
+ permissions: fileInfo.permissions,
+ attributes: {
+ // Inherit some attributes from parent folder like the mount type and real owner
+ 'mount-type': this.parent?.attributes?.['mount-type'],
+ 'owner-id': this.parent?.attributes?.['owner-id'],
+ 'owner-display-name': this.parent?.attributes?.['owner-display-name'],
+ ...fileInfo,
+ 'has-preview': fileInfo.hasPreview,
+ },
})
- // Run default action
- const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL)
- if (fileAction) {
- fileAction.action(fileInfo.basename, {
- $file: fileList?.findFileEl(this.name),
- dir: currentDirectory,
- fileList,
- fileActions: fileList?.fileActions,
- fileInfoModel: model,
- })
- }
+ // Update files list
+ emit('files:node:created', node)
+
+ // Open the new file
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: node.fileid },
+ { dir: node.dirname, openfile: 'true' },
+ )
+ // Close the picker
this.close()
} catch (error) {
- this.logger.error('Error while creating the new file from template')
- console.error(error)
- showError(this.t('files', 'Unable to create new file from template'))
+ logger.error('Error while creating the new file from template', { error })
+ showError(t('files', 'Unable to create new file from template'))
} finally {
this.loading = false
}
},
+
+ async onSubmit() {
+ const fileId = this.selectedTemplate?.fileid
+
+ // Only request field extraction if there is a valid template
+ // selected and it's not the blank template
+ let fields = []
+ if (fileId && fileId !== this.emptyTemplate.fileid) {
+ fields = await getTemplateFields(fileId)
+ }
+
+ if (fields.length > 0) {
+ spawnDialog(TemplateFiller, {
+ fields,
+ onSubmit: this.createFile,
+ })
+ } else {
+ this.loading = true
+ await this.createFile()
+ }
+ },
},
-}
+})
</script>
<style lang="scss" scoped>
@@ -275,11 +326,11 @@ export default {
&__buttons {
display: flex;
- justify-content: space-between;
+ justify-content: end;
padding: calc(var(--margin) * 2) var(--margin);
position: sticky;
bottom: 0;
- background-image: linear-gradient(0, var(--gradient-main-background));
+ background-image: linear-gradient(0deg, var(--gradient-main-background));
button, input[type='submit'] {
height: 44px;
@@ -287,14 +338,14 @@ export default {
}
// Make sure we're relative for the loading emptycontent on top
- ::v-deep .modal-container {
+ :deep(.modal-container) {
position: relative;
}
&__loading {
position: absolute;
top: 0;
- left: 0;
+ inset-inline-start: 0;
justify-content: center;
width: 100%;
height: 100%;
diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts
new file mode 100644
index 00000000000..f793eb9f54c
--- /dev/null
+++ b/apps/files/src/views/favorites.spec.ts
@@ -0,0 +1,261 @@
+/* eslint-disable import/no-named-as-default-member */
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Folder as CFolder, Navigation } from '@nextcloud/files'
+
+import * as filesUtils from '@nextcloud/files'
+import * as filesDavUtils from '@nextcloud/files/dav'
+import { CancelablePromise } from 'cancelable-promise'
+import { basename } from 'path'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import * as eventBus from '@nextcloud/event-bus'
+
+import { action } from '../actions/favoriteAction'
+import * as favoritesService from '../services/Favorites'
+import { registerFavoritesView } from './favorites'
+
+// eslint-disable-next-line import/namespace
+const { Folder, getNavigation } = filesUtils
+
+vi.mock('@nextcloud/axios')
+
+window.OC = {
+ ...window.OC,
+ TAG_FAVORITE: '_$!<Favorite>!$_',
+}
+
+declare global {
+ interface Window {
+ _nc_navigation?: Navigation
+ }
+}
+
+describe('Favorites view definition', () => {
+ let Navigation
+ beforeEach(() => {
+ vi.resetAllMocks()
+
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
+ })
+
+ test('Default empty favorite view', async () => {
+ vi.spyOn(eventBus, 'subscribe')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ expect(eventBus.subscribe).toHaveBeenCalledTimes(3)
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything())
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything())
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything())
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ expect(favoritesView?.id).toBe('favorites')
+ expect(favoritesView?.name).toBe('Favorites')
+ expect(favoritesView?.caption).toBeDefined()
+ expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoritesView?.order).toBe(15)
+ expect(favoritesView?.columns).toStrictEqual([])
+ expect(favoritesView?.getContents).toBeDefined()
+ })
+
+ test('Default with favorites', async () => {
+ const favoriteFolders = [
+ new Folder({
+ id: 1,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foo',
+ owner: 'admin',
+ }),
+ new Folder({
+ id: 2,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/bar',
+ owner: 'admin',
+ }),
+ new Folder({
+ id: 3,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar',
+ owner: 'admin',
+ }),
+ new Folder({
+ id: 4,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar/yabadaba',
+ owner: 'admin',
+ }),
+ ]
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and 3 children
+ expect(Navigation.views.length).toBe(5)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(4)
+
+ // Sorted by basename: bar, bar, foo
+ const expectedOrder = [2, 0, 1, 3]
+
+ favoriteFolders.forEach((folder, index) => {
+ const favoriteView = favoriteFoldersViews[index]
+ expect(favoriteView).toBeDefined()
+ expect(favoriteView?.id).toBeDefined()
+ expect(favoriteView?.name).toBe(basename(folder.path))
+ expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoriteView?.order).toBe(expectedOrder[index])
+ expect(favoriteView?.params).toStrictEqual({
+ dir: folder.path,
+ fileid: String(folder.fileid),
+ view: 'favorites',
+ })
+ expect(favoriteView?.parent).toBe('favorites')
+ expect(favoriteView?.columns).toStrictEqual([])
+ expect(favoriteView?.getContents).toBeDefined()
+ })
+ })
+})
+
+describe('Dynamic update of favorite folders', () => {
+ let Navigation
+ beforeEach(() => {
+ vi.restoreAllMocks()
+
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ })
+
+ test('Add a favorite folder creates a new entry in the navigation', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
+ })
+
+ test('Remove a favorite folder remove the entry from the navigation column', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([
+ new Folder({
+ id: 42,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ }),
+ ]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ let favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(2)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(1)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ root: '/files/admin',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const fo = vi.fn()
+ eventBus.subscribe('files:favorites:removed', fo)
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder)
+ expect(fo).toHaveBeenCalled()
+
+ favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+ })
+
+ test('Renaming a favorite folder updates the navigation', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ // expect(eventBus.emit).toHaveBeenCalledTimes(2)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder)
+
+ // Create a folder with the same id but renamed
+ const renamedFolder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed',
+ owner: 'admin',
+ })
+
+ // Exec the rename action
+ eventBus.emit('files:node:renamed', renamedFolder)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder)
+ })
+})
diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts
new file mode 100644
index 00000000000..cac776507ef
--- /dev/null
+++ b/apps/files/src/views/favorites.ts
@@ -0,0 +1,183 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Folder, Node } from '@nextcloud/files'
+
+import { FileType, View, getNavigation } from '@nextcloud/files'
+import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
+import { getFavoriteNodes } from '@nextcloud/files/dav'
+import { subscribe } from '@nextcloud/event-bus'
+
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import StarSvg from '@mdi/svg/svg/star-outline.svg?raw'
+
+import { client } from '../services/WebdavClient.ts'
+import { getContents } from '../services/Favorites'
+import { hashCode } from '../utils/hashUtils'
+import logger from '../logger'
+
+const generateFavoriteFolderView = function(folder: Folder, index = 0): View {
+ return new View({
+ id: generateIdFromPath(folder.path),
+ name: folder.displayname,
+
+ icon: FolderSvg,
+ order: index,
+
+ params: {
+ dir: folder.path,
+ fileid: String(folder.fileid),
+ view: 'favorites',
+ },
+
+ parent: 'favorites',
+
+ columns: [],
+
+ getContents,
+ })
+}
+
+const generateIdFromPath = function(path: string): string {
+ return `favorite-${hashCode(path)}`
+}
+
+export const registerFavoritesView = async () => {
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: 'favorites',
+ name: t('files', 'Favorites'),
+ caption: t('files', 'List of favorite files and folders.'),
+
+ emptyTitle: t('files', 'No favorites yet'),
+ emptyCaption: t('files', 'Files and folders you mark as favorite will show up here'),
+
+ icon: StarSvg,
+ order: 15,
+
+ columns: [],
+
+ getContents,
+ }))
+
+ const favoriteFolders = (await getFavoriteNodes(client)).filter(node => node.type === FileType.Folder) as Folder[]
+ const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
+ logger.debug('Generating favorites view', { favoriteFolders })
+ favoriteFoldersViews.forEach(view => Navigation.register(view))
+
+ /**
+ * Update favorites navigation when a new folder is added
+ */
+ subscribe('files:favorites:added', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ addToFavorites(node as Folder)
+ })
+
+ /**
+ * Remove favorites navigation when a folder is removed
+ */
+ subscribe('files:favorites:removed', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ removePathFromFavorites(node.path)
+ })
+
+ /**
+ * Update favorites navigation when a folder is renamed
+ */
+ subscribe('files:node:renamed', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ if (node.attributes.favorite !== 1) {
+ return
+ }
+
+ updateNodeFromFavorites(node as Folder)
+ })
+
+ /**
+ * Sort the favorites paths array and
+ * update the order property of the existing views
+ */
+ const updateAndSortViews = function() {
+ favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' }))
+ favoriteFolders.forEach((folder, index) => {
+ const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path))
+ if (view) {
+ view.order = index
+ }
+ })
+ }
+
+ // Add a folder to the favorites paths array and update the views
+ const addToFavorites = function(node: Folder) {
+ const view = generateFavoriteFolderView(node)
+
+ // Skip if already exists
+ if (favoriteFolders.find((folder) => folder.path === node.path)) {
+ return
+ }
+
+ // Update arrays
+ favoriteFolders.push(node)
+ favoriteFoldersViews.push(view)
+
+ // Update and sort views
+ updateAndSortViews()
+ Navigation.register(view)
+ }
+
+ // Remove a folder from the favorites paths array and update the views
+ const removePathFromFavorites = function(path: string) {
+ const id = generateIdFromPath(path)
+ const index = favoriteFolders.findIndex((folder) => folder.path === path)
+
+ // Skip if not exists
+ if (index === -1) {
+ return
+ }
+
+ // Update arrays
+ favoriteFolders.splice(index, 1)
+ favoriteFoldersViews.splice(index, 1)
+
+ // Update and sort views
+ Navigation.remove(id)
+ updateAndSortViews()
+ }
+
+ // Update a folder from the favorites paths array and update the views
+ const updateNodeFromFavorites = function(node: Folder) {
+ const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid)
+
+ // Skip if it does not exists
+ if (favoriteFolder === undefined) {
+ return
+ }
+
+ removePathFromFavorites(favoriteFolder.path)
+ addToFavorites(node)
+ }
+
+ updateAndSortViews()
+}
diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts
new file mode 100644
index 00000000000..a94aab0f14b
--- /dev/null
+++ b/apps/files/src/views/files.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { View, getNavigation } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { getContents } from '../services/Files.ts'
+import { useActiveStore } from '../store/active.ts'
+import { defaultView } from '../utils/filesViews.ts'
+
+import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw'
+
+export const VIEW_ID = 'files'
+
+/**
+ * Register the files view to the navigation
+ */
+export function registerFilesView() {
+ // we cache the query to allow more performant search (see below in event listener)
+ let oldQuery = ''
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: VIEW_ID,
+ name: t('files', 'All files'),
+ caption: t('files', 'List of your files and folders.'),
+
+ icon: FolderSvg,
+ // if this is the default view we set it at the top of the list - otherwise below it
+ order: defaultView() === VIEW_ID ? 0 : 5,
+
+ getContents,
+ }))
+
+ // when the search is updated
+ // and we are in the files view
+ // and there is already a folder fetched
+ // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered
+ subscribe('files:search:updated', ({ scope, query }) => {
+ if (scope === 'globally') {
+ return
+ }
+
+ if (Navigation.active?.id !== VIEW_ID) {
+ return
+ }
+
+ // If neither the old query nor the new query is longer than the search minimum
+ // then we do not need to trigger a new PROPFIND / SEARCH
+ // so we skip unneccessary requests here
+ if (oldQuery.length < 3 && query.length < 3) {
+ return
+ }
+
+ const store = useActiveStore()
+ if (!store.activeFolder) {
+ return
+ }
+
+ oldQuery = query
+ emit('files:node:updated', store.activeFolder)
+ })
+}
diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts
new file mode 100644
index 00000000000..2ce4e501e6f
--- /dev/null
+++ b/apps/files/src/views/folderTree.ts
@@ -0,0 +1,176 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { TreeNode } from '../services/FolderTree.ts'
+
+import PQueue from 'p-queue'
+import { FileType, Folder, Node, View, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { isSamePath } from '@nextcloud/paths'
+import { loadState } from '@nextcloud/initial-state'
+
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
+
+import {
+ folderTreeId,
+ getContents,
+ getFolderTreeNodes,
+ getSourceParent,
+ sourceRoot,
+} from '../services/FolderTree.ts'
+
+const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree
+
+let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden
+
+const Navigation = getNavigation()
+
+const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
+
+const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
+
+const registerTreeChildren = async (path: string = '/') => {
+ await queue.add(async () => {
+ const nodes = await getFolderTreeNodes(path)
+ const promises = nodes.map(node => registerQueue.add(() => registerNodeView(node)))
+ await Promise.allSettled(promises)
+ })
+}
+
+const getLoadChildViews = (node: TreeNode | Folder) => {
+ return async (view: View): Promise<void> => {
+ // @ts-expect-error Custom property on View instance
+ if (view.loading || view.loaded) {
+ return
+ }
+ // @ts-expect-error Custom property
+ view.loading = true
+ await registerTreeChildren(node.path)
+ // @ts-expect-error Custom property
+ view.loading = false
+ // @ts-expect-error Custom property
+ view.loaded = true
+ // @ts-expect-error No payload
+ emit('files:navigation:updated')
+ // @ts-expect-error No payload
+ emit('files:folder-tree:expanded')
+ }
+}
+
+const registerNodeView = (node: TreeNode | Folder) => {
+ const registeredView = Navigation.views.find(view => view.id === node.encodedSource)
+ if (registeredView) {
+ Navigation.remove(registeredView.id)
+ }
+ if (!showHiddenFiles && node.basename.startsWith('.')) {
+ return
+ }
+ Navigation.register(new View({
+ id: node.encodedSource,
+ parent: getSourceParent(node.source),
+
+ // @ts-expect-error Casing differences
+ name: node.displayName ?? node.displayname ?? node.basename,
+
+ icon: FolderSvg,
+
+ getContents,
+ loadChildViews: getLoadChildViews(node),
+
+ params: {
+ view: folderTreeId,
+ fileid: String(node.fileid), // Needed for matching exact routes
+ dir: node.path,
+ },
+ }))
+}
+
+const removeFolderView = (folder: Folder) => {
+ const viewId = folder.encodedSource
+ Navigation.remove(viewId)
+}
+
+const removeFolderViewSource = (source: string) => {
+ Navigation.remove(source)
+}
+
+const onCreateNode = (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ registerNodeView(node)
+}
+
+const onDeleteNode = (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ removeFolderView(node)
+}
+
+const onMoveNode = ({ node, oldSource }) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ removeFolderViewSource(oldSource)
+ registerNodeView(node)
+
+ const newPath = node.source.replace(sourceRoot, '')
+ const oldPath = oldSource.replace(sourceRoot, '')
+ const childViews = Navigation.views.filter(view => {
+ if (!view.params?.dir) {
+ return false
+ }
+ if (isSamePath(view.params.dir, oldPath)) {
+ return false
+ }
+ return view.params.dir.startsWith(oldPath)
+ })
+ for (const view of childViews) {
+ // @ts-expect-error FIXME Allow setting parent
+ view.parent = getSourceParent(node.source)
+ // @ts-expect-error dir param is defined
+ view.params.dir = view.params.dir.replace(oldPath, newPath)
+ }
+}
+
+const onUserConfigUpdated = async ({ key, value }) => {
+ if (key === 'show_hidden') {
+ showHiddenFiles = value
+ await registerTreeChildren()
+ // @ts-expect-error No payload
+ emit('files:folder-tree:initialized')
+ }
+}
+
+const registerTreeRoot = () => {
+ Navigation.register(new View({
+ id: folderTreeId,
+
+ name: t('files', 'Folder tree'),
+ caption: t('files', 'List of your files and folders.'),
+
+ icon: FolderMultipleSvg,
+ order: 50, // Below all other views
+
+ getContents,
+ }))
+}
+
+export const registerFolderTreeView = async () => {
+ if (!isFolderTreeEnabled) {
+ return
+ }
+ registerTreeRoot()
+ await registerTreeChildren()
+ subscribe('files:node:created', onCreateNode)
+ subscribe('files:node:deleted', onDeleteNode)
+ subscribe('files:node:moved', onMoveNode)
+ subscribe('files:config:updated', onUserConfigUpdated)
+ // @ts-expect-error No payload
+ emit('files:folder-tree:initialized')
+}
diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts
new file mode 100644
index 00000000000..241582057d1
--- /dev/null
+++ b/apps/files/src/views/personal-files.ts
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+import { View, getNavigation } from '@nextcloud/files'
+import { getContents } from '../services/PersonalFiles.ts'
+import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts'
+
+import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw'
+
+export const VIEW_ID = 'personal'
+
+/**
+ * Register the personal files view if allowed
+ */
+export function registerPersonalFilesView(): void {
+ if (!hasPersonalFilesView()) {
+ return
+ }
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: VIEW_ID,
+ name: t('files', 'Personal files'),
+ caption: t('files', 'List of your files and folders that are not shared.'),
+
+ emptyTitle: t('files', 'No personal files found'),
+ emptyCaption: t('files', 'Files that are not shared will show up here.'),
+
+ icon: AccountIcon,
+ // if this is the default view we set it at the top of the list - otherwise default position of fifth
+ order: defaultView() === VIEW_ID ? 0 : 5,
+
+ getContents,
+ }))
+}
diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts
new file mode 100644
index 00000000000..fda1d99e13d
--- /dev/null
+++ b/apps/files/src/views/recent.ts
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { View, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import HistorySvg from '@mdi/svg/svg/history.svg?raw'
+
+import { getContents } from '../services/Recent'
+
+export default () => {
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: 'recent',
+ name: t('files', 'Recent'),
+ caption: t('files', 'List of recently modified files and folders.'),
+
+ emptyTitle: t('files', 'No recently modified files'),
+ emptyCaption: t('files', 'Files and folders you recently modified will show up here.'),
+
+ icon: HistorySvg,
+ order: 10,
+
+ defaultSortKey: 'mtime',
+
+ getContents,
+ }))
+}
diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts
new file mode 100644
index 00000000000..a30f732163c
--- /dev/null
+++ b/apps/files/src/views/search.ts
@@ -0,0 +1,51 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance'
+
+import { View, getNavigation } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { getContents } from '../services/Search.ts'
+import { VIEW_ID as FILES_VIEW_ID } from './files.ts'
+import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw'
+import Vue from 'vue'
+
+export const VIEW_ID = 'search'
+
+/**
+ * Register the search-in-files view
+ */
+export function registerSearchView() {
+ let instance: Vue
+ let view: ComponentPublicInstanceConstructor
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: VIEW_ID,
+ name: t('files', 'Search'),
+ caption: t('files', 'Search results within your files.'),
+
+ async emptyView(el) {
+ if (!view) {
+ view = (await import('./SearchEmptyView.vue')).default
+ } else {
+ instance.$destroy()
+ }
+ instance = new Vue(view)
+ instance.$mount(el)
+ },
+
+ icon: MagnifySvg,
+ order: 10,
+
+ parent: FILES_VIEW_ID,
+ // it should be shown expanded
+ expanded: true,
+ // this view is hidden by default and only shown when active
+ hidden: true,
+
+ getContents,
+ }))
+}