aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/FilesApp.vue21
-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.ts227
-rw-r--r--apps/files/src/actions/deleteAction.ts186
-rw-r--r--apps/files/src/actions/deleteUtils.ts141
-rw-r--r--apps/files/src/actions/downloadAction.spec.ts41
-rw-r--r--apps/files/src/actions/downloadAction.ts116
-rw-r--r--apps/files/src/actions/editLocallyAction.ts51
-rw-r--r--apps/files/src/actions/favoriteAction.spec.ts60
-rw-r--r--apps/files/src/actions/favoriteAction.ts48
-rw-r--r--apps/files/src/actions/moveOrCopyAction.ts125
-rw-r--r--apps/files/src/actions/moveOrCopyActionUtils.ts32
-rw-r--r--apps/files/src/actions/openFolderAction.spec.ts15
-rw-r--r--apps/files/src/actions/openFolderAction.ts4
-rw-r--r--apps/files/src/actions/openInFilesAction.spec.ts14
-rw-r--r--apps/files/src/actions/openInFilesAction.ts20
-rw-r--r--apps/files/src/actions/openLocallyAction.spec.ts (renamed from apps/files/src/actions/editLocallyAction.spec.ts)72
-rw-r--r--apps/files/src/actions/openLocallyAction.ts114
-rw-r--r--apps/files/src/actions/renameAction.spec.ts32
-rw-r--r--apps/files/src/actions/renameAction.ts36
-rw-r--r--apps/files/src/actions/sidebarAction.spec.ts58
-rw-r--r--apps/files/src/actions/sidebarAction.ts28
-rw-r--r--apps/files/src/actions/viewInFolderAction.spec.ts34
-rw-r--r--apps/files/src/actions/viewInFolderAction.ts22
-rw-r--r--apps/files/src/components/BreadCrumbs.vue60
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue47
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue24
-rw-r--r--apps/files/src/components/FileEntry.vue106
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue6
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue329
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue60
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue252
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue118
-rw-r--r--apps/files/src/components/FileEntryGrid.vue23
-rw-r--r--apps/files/src/components/FileEntryMixin.ts231
-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.vue63
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue22
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue46
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue206
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue5
-rw-r--r--apps/files/src/components/FilesListVirtual.vue544
-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.vue4
-rw-r--r--apps/files/src/components/NavigationQuota.vue40
-rw-r--r--apps/files/src/components/NewNodeDialog.vue255
-rw-r--r--apps/files/src/components/SidebarTab.vue6
-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.vue22
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue37
-rw-r--r--apps/files/src/components/VirtualList.vue252
-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.ts34
-rw-r--r--apps/files/src/composables/useNavigation.ts15
-rw-r--r--apps/files/src/composables/useRouteParameters.ts58
-rw-r--r--apps/files/src/eventbus.d.ts22
-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.ts44
-rw-r--r--apps/files/src/logger.ts (renamed from apps/files/src/logger.js)0
-rw-r--r--apps/files/src/main-personal-settings.js12
-rw-r--r--apps/files/src/main.ts34
-rw-r--r--apps/files/src/mixins/actionsMixin.ts65
-rw-r--r--apps/files/src/mixins/filesListWidth.ts33
-rw-r--r--apps/files/src/mixins/filesSorting.ts14
-rw-r--r--apps/files/src/models/Setting.js21
-rw-r--r--apps/files/src/models/Tab.js7
-rw-r--r--apps/files/src/newMenu/newFolder.ts31
-rw-r--r--apps/files/src/newMenu/newFromTemplate.ts2
-rw-r--r--apps/files/src/newMenu/newTemplatesFolder.ts12
-rw-r--r--apps/files/src/plugins/search/folderSearch.ts54
-rw-r--r--apps/files/src/reference-files.ts4
-rw-r--r--apps/files/src/router/router.ts109
-rw-r--r--apps/files/src/services/DropService.ts5
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts17
-rw-r--r--apps/files/src/services/DropServiceUtils.ts2
-rw-r--r--apps/files/src/services/FileInfo.js29
-rw-r--r--apps/files/src/services/FileInfo.ts36
-rw-r--r--apps/files/src/services/Files.ts71
-rw-r--r--apps/files/src/services/FolderTree.ts95
-rw-r--r--apps/files/src/services/LivePhotos.ts3
-rw-r--r--apps/files/src/services/PreviewService.ts15
-rw-r--r--apps/files/src/services/Recent.ts18
-rw-r--r--apps/files/src/services/RouterService.ts35
-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.js14
-rw-r--r--apps/files/src/services/SortingService.spec.ts100
-rw-r--r--apps/files/src/services/SortingService.ts59
-rw-r--r--apps/files/src/services/Templates.js9
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
-rw-r--r--apps/files/src/services/WebdavClient.ts16
-rw-r--r--apps/files/src/sidebar.ts (renamed from apps/files/src/sidebar.js)12
-rw-r--r--apps/files/src/store/active.ts86
-rw-r--r--apps/files/src/store/dragging.ts6
-rw-r--r--apps/files/src/store/files.ts94
-rw-r--r--apps/files/src/store/filters.ts133
-rw-r--r--apps/files/src/store/index.ts9
-rw-r--r--apps/files/src/store/keyboard.ts1
-rw-r--r--apps/files/src/store/paths.spec.ts166
-rw-r--r--apps/files/src/store/paths.ts106
-rw-r--r--apps/files/src/store/renaming.ts183
-rw-r--r--apps/files/src/store/search.ts153
-rw-r--r--apps/files/src/store/selection.ts2
-rw-r--r--apps/files/src/store/userconfig.ts85
-rw-r--r--apps/files/src/store/viewConfig.ts139
-rw-r--r--apps/files/src/types.ts45
-rw-r--r--apps/files/src/utils/actionUtils.ts74
-rw-r--r--apps/files/src/utils/davUtils.js14
-rw-r--r--apps/files/src/utils/davUtils.ts41
-rw-r--r--apps/files/src/utils/fileUtils.ts38
-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/newNodeDialog.ts2
-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.vue23
-rw-r--r--apps/files/src/views/FilesList.vue631
-rw-r--r--apps/files/src/views/Navigation.cy.ts48
-rw-r--r--apps/files/src/views/Navigation.vue212
-rw-r--r--apps/files/src/views/ReferenceFileWidget.vue2
-rw-r--r--apps/files/src/views/SearchEmptyView.vue53
-rw-r--r--apps/files/src/views/Settings.vue300
-rw-r--r--apps/files/src/views/Sidebar.vue147
-rw-r--r--apps/files/src/views/TemplatePicker.vue56
-rw-r--r--apps/files/src/views/favorites.spec.ts146
-rw-r--r--apps/files/src/views/favorites.ts53
-rw-r--r--apps/files/src/views/files.ts54
-rw-r--r--apps/files/src/views/folderTree.ts176
-rw-r--r--apps/files/src/views/personal-files.ts26
-rw-r--r--apps/files/src/views/search.ts51
-rw-r--r--apps/files/src/vue.d.ts11
151 files changed, 8948 insertions, 2516 deletions
diff --git a/apps/files/src/FilesApp.vue b/apps/files/src/FilesApp.vue
index 73a14a157b4..6fc02113162 100644
--- a/apps/files/src/FilesApp.vue
+++ b/apps/files/src/FilesApp.vue
@@ -4,18 +4,18 @@
-->
<template>
<NcContent app-name="files">
- <Navigation />
- <FilesList />
+ <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/dist/Components/NcContent.js'
-
+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',
@@ -25,5 +25,16 @@ export default defineComponent({
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
index 0dda5788a85..845d29962a7 100644
--- a/apps/files/src/actions/deleteAction.spec.ts
+++ b/apps/files/src/actions/deleteAction.spec.ts
@@ -2,13 +2,20 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './deleteAction'
-import { expect } from '@jest/globals'
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+
import axios from '@nextcloud/axios'
-import eventBus from '@nextcloud/event-bus'
+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',
@@ -21,8 +28,8 @@ const trashbinView = {
} as View
describe('Delete action conditions tests', () => {
- afterEach(() => {
- jest.restoreAllMocks()
+ beforeEach(() => {
+ vi.restoreAllMocks()
})
const file = new File({
@@ -81,7 +88,7 @@ describe('Delete action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('delete')
expect(action.displayName([file], view)).toBe('Delete file')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(100)
})
@@ -94,6 +101,16 @@ describe('Delete action conditions tests', () => {
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')
@@ -111,6 +128,22 @@ describe('Delete action conditions tests', () => {
})
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,
@@ -161,12 +194,24 @@ describe('Delete action enabled tests', () => {
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 () => {
- jest.spyOn(axios, 'delete')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -187,10 +232,10 @@ describe('Delete action execute tests', () => {
})
test('Delete action batch', async () => {
- jest.spyOn(axios, 'delete')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
- const confirmMock = jest.fn()
+ const confirmMock = vi.fn()
window.OC = { dialogs: { confirmDestructive: confirmMock } }
const file1 = new File({
@@ -224,9 +269,129 @@ describe('Delete action execute tests', () => {
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 () => {
- jest.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ 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,
@@ -245,4 +410,40 @@ describe('Delete action execute tests', () => {
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
index be39ecdcf04..fa4fdfe8cdc 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -2,111 +2,24 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { emit } from '@nextcloud/event-bus'
-import { Permission, Node, View, FileAction, FileType } from '@nextcloud/files'
-import { showInfo } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
+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.svg?raw'
+import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw'
-import logger from '../logger.js'
-import PQueue from 'p-queue'
-
-const canUnshareOnly = (nodes: Node[]) => {
- return nodes.every(node => node.attributes['is-mount-root'] === true
- && node.attributes['mount-type'] === 'shared')
-}
-
-const canDisconnectOnly = (nodes: Node[]) => {
- return nodes.every(node => node.attributes['is-mount-root'] === true
- && node.attributes['mount-type'] === 'external')
-}
-
-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
-}
-
-const isAllFiles = (nodes: Node[]) => {
- return !nodes.some(node => node.type !== FileType.File)
-}
-
-const isAllFolders = (nodes: Node[]) => {
- return !nodes.some(node => node.type !== FileType.Folder)
-}
-
-const displayName = (nodes: Node[], view: View) => {
- /**
- * If we're in the trashbin, we can only delete permanently
- */
- if (view.id === 'trashbin') {
- 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 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 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')
-}
+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: 'delete',
+ id: ACTION_DELETE,
displayName,
iconSvgInline: (nodes: Node[]) => {
if (canUnshareOnly(nodes)) {
@@ -120,20 +33,39 @@ export const action = new FileAction({
return TrashCanSvg
},
- enabled(nodes: Node[]) {
+ 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, dir: string) {
+ async exec(node: Node, view: View) {
try {
- await axios.delete(node.encodedSource)
+ 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
+ }
- // 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)
+ await deleteNode(node)
return true
} catch (error) {
@@ -142,41 +74,32 @@ export const action = new FileAction({
}
},
- async execBatch(nodes: Node[], view: View, dir: string): Promise<(boolean | null)[]> {
- const confirm = await new Promise<boolean>(resolve => {
- if (nodes.length >= 5 && !canUnshareOnly(nodes) && !canDisconnectOnly(nodes)) {
- // TODO use a proper dialog from @nextcloud/dialogs when available
- window.OC.dialogs.confirmDestructive(
- t('files', 'You are about to delete {count} items.', { count: nodes.length }),
- 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)
- },
- )
- return
- }
- resolve(true)
- })
+ 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) {
- showInfo(t('files', 'Deletion cancelled'))
- return Promise.all(nodes.map(() => 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 => {
+ // Create a promise that resolves with the result of exec(node)
+ const promise = new Promise<boolean>(resolve => {
queue.add(async () => {
- const result = await this.exec(node, view, dir)
- resolve(result !== null ? result : false)
+ try {
+ await deleteNode(node)
+ resolve(true)
+ } catch (error) {
+ logger.error('Error while deleting a file', { error, source: node.source, node })
+ resolve(false)
+ }
})
})
return promise
@@ -185,5 +108,6 @@ export const action = new FileAction({
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
index 8aca20eb4d4..8d5612d982b 100644
--- a/apps/files/src/actions/downloadAction.spec.ts
+++ b/apps/files/src/actions/downloadAction.spec.ts
@@ -2,9 +2,10 @@
* 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'
-import { expect } from '@jest/globals'
-import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
const view = {
id: 'files',
@@ -22,8 +23,8 @@ describe('Download action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('download')
expect(action.displayName([], view)).toBe('Download')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
- expect(action.default).toBeUndefined()
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBe(DefaultType.DEFAULT)
expect(action.order).toBe(30)
})
})
@@ -83,11 +84,12 @@ describe('Download action enabled tests', () => {
describe('Download action execute tests', () => {
const link = {
- click: jest.fn(),
+ click: vi.fn(),
} as unknown as HTMLAnchorElement
beforeEach(() => {
- jest.spyOn(document, 'createElement').mockImplementation(() => link)
+ vi.resetAllMocks()
+ vi.spyOn(document, 'createElement').mockImplementation(() => link)
})
test('Download single file', async () => {
@@ -103,7 +105,7 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toBe(null)
- expect(link.download).toEqual('')
+ 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)
})
@@ -121,7 +123,26 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toStrictEqual([null])
- expect(link.download).toEqual('')
+ 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)
})
@@ -139,7 +160,7 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(link.download).toEqual('')
- expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=')).toBe(true)
+ expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/FooBar/?accept=zip')
expect(link.click).toHaveBeenCalledTimes(1)
})
@@ -164,7 +185,7 @@ describe('Download action execute tests', () => {
// Silent action
expect(exec).toStrictEqual([null, null])
expect(link.download).toEqual('')
- expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2FDir&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=')).toBe(true)
+ 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
index 28a52551d22..8abd87972ee 100644
--- a/apps/files/src/actions/downloadAction.ts
+++ b/apps/files/src/actions/downloadAction.ts
@@ -2,83 +2,111 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { generateUrl } from '@nextcloud/router'
-import { FileAction, Permission, Node, FileType, View } from '@nextcloud/files'
-import { translate as t } from '@nextcloud/l10n'
+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'
-const triggerDownload = function(url: string) {
+/**
+ * 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 = ''
+ hiddenElement.download = name ?? ''
hiddenElement.href = url
hiddenElement.click()
}
-const downloadNodes = function(dir: string, nodes: Node[]) {
- const secret = Math.random().toString(36).substring(2)
- const url = generateUrl('/apps/files/ajax/download.php?dir={dir}&files={files}&downloadStartSecret={secret}', {
- dir,
- secret,
- files: JSON.stringify(nodes.map(node => node.basename)),
- })
- triggerDownload(url)
+/**
+ * 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 isDownloadable = function(node: Node) {
- if ((node.permissions & Permission.READ) === 0) {
- return false
- }
+const downloadNodes = function(nodes: Node[]) {
+ let url: URL
- // If the mount type is a share, ensure it got download permissions.
- if (node.attributes['mount-type'] === 'shared') {
- const shareAttributes = JSON.parse(node.attributes['share-attributes'] ?? 'null')
- const downloadAttribute = shareAttributes?.find?.((attribute: { scope: string; key: string }) => attribute.scope === 'permissions' && attribute.key === 'download')
- if (downloadAttribute !== undefined && downloadAttribute.enabled === false) {
- return false
+ 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 true
+ return triggerDownload(url.href)
}
export const action = new FileAction({
id: 'download',
+ default: DefaultType.DEFAULT,
+
displayName: () => t('files', 'Download'),
iconSvgInline: () => ArrowDownSvg,
- enabled(nodes: Node[]) {
+ enabled(nodes: Node[], view: View) {
if (nodes.length === 0) {
return false
}
- // We can download direct dav files. But if we have
- // some folders, we need to use the /apps/files/ajax/download.php
- // endpoint, which only supports user root folder.
- if (nodes.some(node => node.type === FileType.Folder)
- && nodes.some(node => !node.root?.startsWith('/files'))) {
+ // 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, view: View, dir: string) {
- if (node.type === FileType.Folder) {
- downloadNodes(dir, [node])
- return null
- }
-
- triggerDownload(node.encodedSource)
+ async exec(node: Node) {
+ downloadNodes([node])
return null
},
- async execBatch(nodes: Node[], view: View, dir: string) {
- if (nodes.length === 1) {
- this.exec(nodes[0], view, dir)
- return [null]
- }
-
- downloadNodes(dir, nodes)
+ async execBatch(nodes: Node[]) {
+ downloadNodes(nodes)
return new Array(nodes.length).fill(null)
},
diff --git a/apps/files/src/actions/editLocallyAction.ts b/apps/files/src/actions/editLocallyAction.ts
deleted file mode 100644
index 7e44927eba2..00000000000
--- a/apps/files/src/actions/editLocallyAction.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * 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 } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-
-import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
-
-const openLocalClient = async function(path: string) {
- 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.location.href = url
- } catch (error) {
- showError(t('files', 'Failed to redirect to client'))
- }
-}
-
-export const action = new FileAction({
- id: 'edit-locally',
- displayName: () => t('files', 'Edit locally'),
- iconSvgInline: () => LaptopSvg,
-
- // Only works on single files
- enabled(nodes: Node[]) {
- // Only works on single node
- if (nodes.length !== 1) {
- return false
- }
-
- return (nodes[0].permissions & Permission.UPDATE) !== 0
- },
-
- async exec(node: Node) {
- openLocalClient(node.path)
- return null
- },
-
- order: 25,
-})
diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts
index e8bb1ce28b5..96768c4887a 100644
--- a/apps/files/src/actions/favoriteAction.spec.ts
+++ b/apps/files/src/actions/favoriteAction.spec.ts
@@ -2,14 +2,18 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './favoriteAction'
-import { expect } from '@jest/globals'
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 eventBus from '@nextcloud/event-bus'
+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',
@@ -20,12 +24,12 @@ const favoriteView = {
name: 'Favorites',
} as View
-global.window.OC = {
- TAG_FAVORITE: '_$!<Favorite>!$_',
-}
-
// 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 = ''
})
@@ -42,7 +46,7 @@ describe('Favorite action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('favorite')
expect(action.displayName([file], view)).toBe('Add to favorites')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
@@ -128,13 +132,11 @@ describe('Favorite action enabled tests', () => {
})
describe('Favorite action execute tests', () => {
- afterEach(() => {
- jest.spyOn(axios, 'post').mockRestore()
- })
+ beforeEach(() => { vi.resetAllMocks() })
test('Favorite triggers tag addition', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -158,8 +160,8 @@ describe('Favorite action execute tests', () => {
})
test('Favorite triggers tag removal', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -186,8 +188,8 @@ describe('Favorite action execute tests', () => {
})
test('Favorite triggers node removal if favorite view and root dir', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -215,8 +217,8 @@ describe('Favorite action execute tests', () => {
})
test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -245,8 +247,8 @@ describe('Favorite action execute tests', () => {
test('Favorite fails and show error', async () => {
const error = new Error('Mock error')
- jest.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
const file = new File({
id: 1,
@@ -275,8 +277,8 @@ describe('Favorite action execute tests', () => {
test('Removing from favorites fails and show error', async () => {
const error = new Error('Mock error')
- jest.spyOn(axios, 'post').mockImplementation(() => { throw error })
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw error })
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
const file = new File({
id: 1,
@@ -305,9 +307,11 @@ describe('Favorite action execute tests', () => {
})
describe('Favorite action batch execute tests', () => {
+ beforeEach(() => { vi.restoreAllMocks() })
+
test('Favorite action batch execute with mixed files', async () => {
- jest.spyOn(favoriteAction, 'favoriteNode')
- jest.spyOn(axios, 'post')
+ vi.spyOn(favoriteAction, 'favoriteNode')
+ vi.spyOn(axios, 'post')
const file1 = new File({
id: 1,
@@ -335,15 +339,14 @@ describe('Favorite action batch execute tests', () => {
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every(file => file.attributes.favorite === 1)).toBe(true)
- expect(favoriteAction.favoriteNode).toBeCalledTimes(2)
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 () => {
- jest.spyOn(favoriteAction, 'favoriteNode')
- jest.spyOn(axios, 'post')
+ vi.spyOn(favoriteAction, 'favoriteNode')
+ vi.spyOn(axios, 'post')
const file1 = new File({
id: 1,
@@ -371,7 +374,6 @@ describe('Favorite action batch execute tests', () => {
expect(exec).toStrictEqual([true, true])
expect([file1, file2].every(file => file.attributes.favorite === 0)).toBe(true)
- expect(favoriteAction.favoriteNode).toBeCalledTimes(2)
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
index 3815fc1a9a3..b0e1e3a0817 100644
--- a/apps/files/src/actions/favoriteAction.ts
+++ b/apps/files/src/actions/favoriteAction.ts
@@ -2,18 +2,26 @@
* 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 { generateUrl } from '@nextcloud/router'
-import { Permission, type Node, View, FileAction } from '@nextcloud/files'
+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.js'
-import { encodePath } from '@nextcloud/paths'
+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 => {
@@ -56,7 +64,7 @@ export const favoriteNode = async (node: Node, view: View, willFavorite: boolean
}
export const action = new FileAction({
- id: 'favorite',
+ id: ACTION_FAVORITE,
displayName(nodes: Node[]) {
return shouldFavorite(nodes)
? t('files', 'Add to favorites')
@@ -69,8 +77,14 @@ export const action = new FileAction({
},
enabled(nodes: Node[]) {
- // We can only favorite nodes within files and with permissions
- return !nodes.some(node => !node.root?.startsWith?.('/files'))
+ // 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)
},
@@ -80,7 +94,25 @@ export const action = new FileAction({
},
async execBatch(nodes: Node[], view: View) {
const willFavorite = shouldFavorite(nodes)
- return Promise.all(nodes.map(async node => await favoriteNode(node, view, willFavorite)))
+
+ // 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
index 5d3a7afa0e2..06e32c98090 100644
--- a/apps/files/src/actions/moveOrCopyAction.ts
+++ b/apps/files/src/actions/moveOrCopyAction.ts
@@ -4,11 +4,11 @@
*/
import type { Folder, Node, View } from '@nextcloud/files'
import type { IFilePickerButton } from '@nextcloud/dialogs'
-import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
import type { MoveCopyResult } from './moveOrCopyActionUtils'
import { isAxiosError } from '@nextcloud/axios'
-import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs'
+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'
@@ -16,8 +16,8 @@ import { openConflictPicker, hasConflict } from '@nextcloud/upload'
import { basename, join } from 'path'
import Vue from 'vue'
-import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
-import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
+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'
@@ -41,6 +41,28 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
}
/**
+ * 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
@@ -80,6 +102,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
// 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 () => {
@@ -123,27 +146,36 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
}
} else {
// show conflict file popup if we do not allow overwriting
- 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)
- // if the user selected to keep the old file, and did not select the new file
- // that means they opted to delete the current node
- if (!selected.length && !renamed.length) {
- await client.deleteFile(currentPath)
- emit('files:node:deleted', node)
+ 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
}
- } catch (error) {
- // User cancelled
- showError(t('files', 'Move 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
- await client.moveFile(currentPath, join(destinationPath, node.basename))
+ 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)
@@ -163,19 +195,25 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
logger.debug(error as Error)
throw new Error()
} finally {
- Vue.set(node, 'status', undefined)
+ Vue.set(node, 'status', '')
+ actionFinished()
}
})
}
/**
* Open a file picker for the given action
- * @param {MoveCopyAction} action The action to open the file picker for
- * @param {string} dir The directory to start the file picker in
- * @param {Node[]} nodes The nodes to move/copy
- * @return {Promise<MoveCopyResult>} The picked destination
+ * @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
*/
-const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: Node[]): Promise<MoveCopyResult> => {
+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)
@@ -186,9 +224,7 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
.setMimeTypeFilter([])
.setMultiSelect(false)
.startAt(dir)
-
- return new Promise((resolve, reject) => {
- filePicker.setButtonFactory((selection: Node[], path: string) => {
+ .setButtonFactory((selection: Node[], path: string) => {
const buttons: IFilePickerButton[] = []
const target = basename(path)
@@ -221,6 +257,11 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
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'),
@@ -237,21 +278,24 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
return buttons
})
+ .build()
- const picker = filePicker.build()
- picker.pick().catch((error) => {
+ filePicker.pick()
+ .catch((error: Error) => {
logger.debug(error as Error)
if (error instanceof FilePickerClosed) {
- reject(new Error(t('files', 'Cancelled move or copy operation')))
+ 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: 'move-copy',
+ id: ACTION_COPY_MOVE,
displayName(nodes: Node[]) {
switch (getActionForNodes(nodes)) {
case MoveCopyAction.MOVE:
@@ -263,7 +307,11 @@ export const action = new FileAction({
}
},
iconSvgInline: () => FolderMoveSvg,
- enabled(nodes: Node[]) {
+ 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
@@ -280,6 +328,10 @@ export const action = new FileAction({
logger.error(e as Error)
return false
}
+ if (result === false) {
+ return null
+ }
+
try {
await handleCopyMoveNodeTo(node, result.destination, result.action)
return true
@@ -296,6 +348,11 @@ export const action = new FileAction({
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)
diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts
index d55247c8662..0372e8f4bc7 100644
--- a/apps/files/src/actions/moveOrCopyActionUtils.ts
+++ b/apps/files/src/actions/moveOrCopyActionUtils.ts
@@ -4,8 +4,14 @@
*/
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
@@ -23,12 +29,6 @@ export const getQueue = () => {
return queue
}
-type ShareAttribute = {
- enabled: boolean
- key: string
- scope: string
-}
-
export enum MoveCopyAction {
MOVE = 'Move',
COPY = 'Copy',
@@ -42,20 +42,30 @@ export type MoveCopyResult = {
export const canMove = (nodes: Node[]) => {
const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
- return (minPermission & Permission.UPDATE) !== 0
+ 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.enabled === false && attribute.key === 'download')
+ 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
- // it can be copied if the user has at least read permissions
- return canDownload(nodes)
- && !nodes.some(node => node.permissions === Permission.NONE)
+ 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
index 57827ddc208..066ad5d86d8 100644
--- a/apps/files/src/actions/openFolderAction.spec.ts
+++ b/apps/files/src/actions/openFolderAction.spec.ts
@@ -2,8 +2,8 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { expect } from '@jest/globals'
import { File, Folder, Node, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
import { action } from './openFolderAction'
@@ -24,7 +24,7 @@ describe('Open folder action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-folder')
expect(action.displayName([folder], view)).toBe('Open folder FooBar')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-100)
})
@@ -100,7 +100,8 @@ describe('Open folder action enabled tests', () => {
describe('Open folder action execute tests', () => {
test('Open folder', async () => {
- const goToRouteMock = jest.fn()
+ 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({
@@ -114,11 +115,12 @@ describe('Open folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/FooBar' })
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/FooBar' })
})
test('Open folder fails without node', async () => {
- const goToRouteMock = jest.fn()
+ 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, '/')
@@ -127,7 +129,8 @@ describe('Open folder action execute tests', () => {
})
test('Open folder fails without Folder', async () => {
- const goToRouteMock = jest.fn()
+ 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({
diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts
index 8c40ac6ede8..8719f7a93fb 100644
--- a/apps/files/src/actions/openFolderAction.ts
+++ b/apps/files/src/actions/openFolderAction.ts
@@ -10,7 +10,7 @@ export const action = new FileAction({
id: 'open-folder',
displayName(files: Node[]) {
// Only works on single node
- const displayName = files[0].attributes.displayname || files[0].basename
+ const displayName = files[0].displayname
return t('files', 'Open folder {displayName}', { displayName })
},
iconSvgInline: () => FolderSvg,
@@ -38,7 +38,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null,
- { view: view.id, fileid: node.fileid },
+ { view: view.id, fileid: String(node.fileid) },
{ dir: node.path },
)
return null
diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts
index f8745174b96..3ccd15fa2d2 100644
--- a/apps/files/src/actions/openInFilesAction.spec.ts
+++ b/apps/files/src/actions/openInFilesAction.spec.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { action } from './openInFilesAction'
-import { expect } from '@jest/globals'
+import { describe, expect, test, vi } from 'vitest'
import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
const view = {
@@ -19,7 +19,7 @@ const recentView = {
describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
- expect(action.id).toBe('open-in-files-recent')
+ 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)
@@ -42,7 +42,8 @@ describe('Open in files action enabled tests', () => {
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
- const goToRouteMock = jest.fn()
+ 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({
@@ -59,11 +60,12 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo', openfile: 'true' })
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' })
})
test('Open in files with folder', async () => {
- const goToRouteMock = jest.fn()
+ 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({
@@ -79,6 +81,6 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo/Bar', openfile: 'true' })
+ 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
index 0022d8c5e76..9e10b1ac74e 100644
--- a/apps/files/src/actions/openInFilesAction.ts
+++ b/apps/files/src/actions/openInFilesAction.ts
@@ -2,19 +2,21 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
-import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files'
-/**
- * TODO: Move away from a redirect and handle
- * navigation straight out of the recent view
- */
+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-recent',
+ id: 'open-in-files',
displayName: () => t('files', 'Open in Files'),
iconSvgInline: () => '',
- enabled: (nodes, view) => view.id === 'recent',
+ enabled(nodes, view) {
+ return view.id === 'recent' || view.id === SEARCH_VIEW_ID
+ },
async exec(node: Node) {
let dir = node.dirname
@@ -24,7 +26,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null, // use default route
- { view: 'files', fileid: node.fileid },
+ { view: 'files', fileid: String(node.fileid) },
{ dir, openfile: 'true' },
)
return null
diff --git a/apps/files/src/actions/editLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts
index 7131cb86f16..860bd6233f4 100644
--- a/apps/files/src/actions/editLocallyAction.spec.ts
+++ b/apps/files/src/actions/openLocallyAction.spec.ts
@@ -2,35 +2,41 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './editLocallyAction'
-import { expect } from '@jest/globals'
import { File, Permission, View, FileAction } from '@nextcloud/files'
-import * as ncDialogs from '@nextcloud/dialogs'
+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 webroot variable
+// Mock web root variable
beforeAll(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- (window as any)._oc_webroot = ''
+ (window as any)._oc_webroot = '';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).OCA = { Viewer: { open: vi.fn() } }
})
-describe('Edit locally action conditions tests', () => {
+describe('Open locally action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('edit-locally')
- expect(action.displayName([], view)).toBe('Edit locally')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.displayName([], view)).toBe('Open locally')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(25)
})
})
-describe('Edit locally action enabled tests', () => {
+describe('Open locally action enabled tests', () => {
test('Enabled for file with UPDATE permission', () => {
const file = new File({
id: 1,
@@ -44,7 +50,7 @@ describe('Edit locally action enabled tests', () => {
expect(action.enabled!([file], view)).toBe(true)
})
- test('Disabled for non-dav ressources', () => {
+ test('Disabled for non-dav resources', () => {
const file = new File({
id: 1,
source: 'https://domain.com/data/foobar.txt',
@@ -102,14 +108,24 @@ describe('Edit locally action enabled tests', () => {
})
})
-describe('Edit locally action execute tests', () => {
- test('Edit locally opens proper URL', async () => {
- jest.spyOn(axios, 'post').mockImplementation(async () => ({ data: { ocs: { data: { token: 'foobar' } } } }))
- jest.spyOn(ncDialogs, 'showError')
+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://localhost/remote.php/dav/files/admin/foobar.txt',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE,
@@ -117,21 +133,23 @@ describe('Edit locally action execute tests', () => {
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://localhost/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
- expect(ncDialogs.showError).toBeCalledTimes(0)
- expect(window.location.href).toBe('nc://open/test@localhost/foobar.txt?token=foobar')
+ 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('Edit locally fails and show error', async () => {
- jest.spyOn(axios, 'post').mockImplementation(async () => ({}))
- jest.spyOn(ncDialogs, 'showError')
+ 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://localhost/remote.php/dav/files/admin/foobar.txt',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.UPDATE,
@@ -139,12 +157,14 @@ describe('Edit locally action execute tests', () => {
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://localhost/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' })
- expect(ncDialogs.showError).toBeCalledTimes(1)
- expect(ncDialogs.showError).toBeCalledWith('Failed to redirect to client')
- expect(window.location.href).toBe('http://localhost/')
+ 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
index f0428996972..1f9c9209d41 100644
--- a/apps/files/src/actions/renameAction.spec.ts
+++ b/apps/files/src/actions/renameAction.spec.ts
@@ -3,21 +3,29 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { action } from './renameAction'
-import { expect } from '@jest/globals'
-import { File, Permission, View, FileAction } from '@nextcloud/files'
-import eventBus from '@nextcloud/event-bus'
+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)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(10)
})
@@ -26,20 +34,20 @@ describe('Rename action conditions tests', () => {
describe('Rename action enabled tests', () => {
test('Enabled for node with UPDATE permission', () => {
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
- permissions: Permission.UPDATE,
+ permissions: Permission.UPDATE | Permission.DELETE,
})
expect(action.enabled).toBeDefined()
expect(action.enabled!([file], view)).toBe(true)
})
- test('Disabled for node without UPDATE permission', () => {
+ test('Disabled for node without DELETE permission', () => {
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
@@ -54,13 +62,13 @@ describe('Rename action enabled tests', () => {
window.OCA = { Files: { Sidebar: {} } }
const file1 = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
owner: 'admin',
mime: 'text/plain',
})
const file2 = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
owner: 'admin',
mime: 'text/plain',
@@ -73,10 +81,10 @@ describe('Rename action enabled tests', () => {
describe('Rename action exec tests', () => {
test('Rename', async () => {
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts
index c00a99b4de1..715ecb7563e 100644
--- a/apps/files/src/actions/renameAction.ts
+++ b/apps/files/src/actions/renameAction.ts
@@ -3,21 +3,41 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { emit } from '@nextcloud/event-bus'
-import { Permission, type Node, FileAction } from '@nextcloud/files'
+import { Permission, type Node, FileAction, View } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import PencilSvg from '@mdi/svg/svg/pencil.svg?raw'
+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_DETAILS = 'details'
+export const ACTION_RENAME = 'rename'
export const action = new FileAction({
- id: 'rename',
+ id: ACTION_RENAME,
displayName: () => t('files', 'Rename'),
iconSvgInline: () => PencilSvg,
- enabled: (nodes: Node[]) => {
- return nodes.length > 0 && nodes
- .map(node => node.permissions)
- .every(permission => (permission & Permission.UPDATE) !== 0)
+ 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) {
diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts
index 9e1415b0b2c..9085bf595ad 100644
--- a/apps/files/src/actions/sidebarAction.spec.ts
+++ b/apps/files/src/actions/sidebarAction.spec.ts
@@ -2,8 +2,8 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { expect } from '@jest/globals'
-import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { File, Permission, View, FileAction, Folder } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
import { action } from './sidebarAction'
import logger from '../logger'
@@ -17,8 +17,8 @@ describe('Open sidebar action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('details')
- expect(action.displayName([], view)).toBe('Open details')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.displayName([], view)).toBe('Details')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(-50)
})
@@ -107,9 +107,12 @@ describe('Open sidebar action enabled tests', () => {
describe('Open sidebar action exec tests', () => {
test('Open sidebar', async () => {
- const openMock = jest.fn()
- window.OCA = { Files: { Sidebar: { open: openMock } } }
- const goToRouteMock = jest.fn()
+ 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({
@@ -123,18 +126,49 @@ describe('Open sidebar action exec tests', () => {
// 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: '/' },
+ { view: view.id, fileid: '1' },
+ { dir: '/', opendetails: 'true' },
true,
)
})
test('Open sidebar fails', async () => {
- const openMock = jest.fn(() => { throw new Error('Mock error') })
- window.OCA = { Files: { Sidebar: { open: openMock } } }
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
+ 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,
diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts
index aeb09490fb0..8f020b4ee8d 100644
--- a/apps/files/src/actions/sidebarAction.ts
+++ b/apps/files/src/actions/sidebarAction.ts
@@ -2,21 +2,29 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { Permission, type Node, View, FileAction, FileType } from '@nextcloud/files'
+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.js'
+import logger from '../logger.ts'
export const ACTION_DETAILS = 'details'
export const action = new FileAction({
id: ACTION_DETAILS,
- displayName: () => t('files', 'Open 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
@@ -36,14 +44,22 @@ export const action = new FileAction({
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(
+ window.OCP?.Files?.Router?.goToRoute(
null,
- { view: view.id, fileid: node.fileid },
- { ...window.OCP.Files.Router.query, dir },
+ { view: view.id, fileid: String(node.fileid) },
+ { ...window.OCP.Files.Router.query, dir, opendetails: 'true' },
true,
)
diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts
index cad99d25ab0..bd618c8a89f 100644
--- a/apps/files/src/actions/viewInFolderAction.spec.ts
+++ b/apps/files/src/actions/viewInFolderAction.spec.ts
@@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './viewInFolderAction'
-import { expect } from '@jest/globals'
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',
@@ -21,7 +21,7 @@ describe('View in folder action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('view-in-folder')
expect(action.displayName([], view)).toBe('View in folder')
- expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(80)
expect(action.enabled).toBeDefined()
@@ -109,11 +109,24 @@ describe('View in folder action enabled tests', () => {
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 = jest.fn()
+ 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({
@@ -128,11 +141,12 @@ describe('View in folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/' })
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/' })
})
test('View in (sub) folder', async () => {
- const goToRouteMock = jest.fn()
+ 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({
@@ -148,11 +162,12 @@ describe('View in folder action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo/Bar' })
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo/Bar' })
})
test('View in folder fails without node', async () => {
- const goToRouteMock = jest.fn()
+ 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, '/')
@@ -161,7 +176,8 @@ describe('View in folder action execute tests', () => {
})
test('View in folder fails without File', async () => {
- const goToRouteMock = jest.fn()
+ 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({
diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts
index 6cf6266fb64..b22393c1152 100644
--- a/apps/files/src/actions/viewInFolderAction.ts
+++ b/apps/files/src/actions/viewInFolderAction.ts
@@ -2,9 +2,13 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { Node, FileType, Permission, View, FileAction } from '@nextcloud/files'
-import { translate as t } from '@nextcloud/l10n'
-import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw'
+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',
@@ -14,6 +18,11 @@ export const action = new FileAction({
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
@@ -30,6 +39,11 @@ export const action = new FileAction({
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
}
@@ -44,7 +58,7 @@ export const action = new FileAction({
window.OCP.Files.Router.goToRoute(
null,
- { view: 'files', fileid: node.fileid },
+ { view: 'files', fileid: String(node.fileid) },
{ dir: node.dirname },
)
return null
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index 02ccac8b669..8458fd65f3d 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -14,7 +14,7 @@
v-bind="section"
dir="auto"
:to="section.to"
- :force-icon-text="index === 0 && filesListWidth >= 486"
+ :force-icon-text="index === 0 && fileListWidth >= 486"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
@click.native="onClick(section.to)"
@@ -42,19 +42,19 @@ 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/dist/Components/NcBreadcrumb.js'
-import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+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'
-import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+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 filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger'
export default defineComponent({
@@ -66,10 +66,6 @@ export default defineComponent({
NcIconSvgWrapper,
},
- mixins: [
- filesListWidthMixin,
- ],
-
props: {
path: {
type: String,
@@ -83,7 +79,8 @@ export default defineComponent({
const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()
- const { currentView } = useNavigation()
+ const fileListWidth = useFileListWidth()
+ const { currentView, views } = useNavigation()
return {
draggingStore,
@@ -93,6 +90,8 @@ export default defineComponent({
uploaderStore,
currentView,
+ fileListWidth,
+ views,
}
},
@@ -109,12 +108,11 @@ export default defineComponent({
return this.dirs.map((dir: string, index: number) => {
const source = this.getFileSourceFromPath(dir)
const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
- const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } }
return {
dir,
exact: true,
name: this.getDirDisplayName(dir),
- to,
+ to: this.getTo(dir, node),
// disable drop on current directory
disableDrop: index === this.dirs.length - 1,
}
@@ -129,7 +127,7 @@ export default defineComponent({
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.filesListWidth < 512
+ return this.isUploadInProgress && this.fileListWidth < 512
},
// used to show the views icon for the first breadcrumb
@@ -155,12 +153,35 @@ export default defineComponent({
},
getDirDisplayName(path: string): string {
if (path === '/') {
- return this.$navigation?.active?.name || t('files', 'Home')
+ return this.currentView?.name || t('files', 'Home')
}
- const source: FileSource | null = this.getFileSourceFromPath(path)
- const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
- return node?.attributes?.displayname || basename(path)
+ 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) {
@@ -273,6 +294,7 @@ export default defineComponent({
height: 100%;
margin-block: 0;
margin-inline: 10px;
+ min-width: 0;
:deep() {
a {
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 0483e71f43b..c7684d5c205 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -27,17 +27,20 @@
<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.js'
+import logger from '../logger.ts'
+import type { RawLocation } from 'vue-router'
export default defineComponent({
name: 'DragAndDropNotice',
@@ -82,22 +85,33 @@ export default defineComponent({
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 don’t have permission to upload or create files here')
+ 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.querySelector('main.app-content') as HTMLElement
+ 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.querySelector('main.app-content') as HTMLElement
+ 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)
@@ -112,6 +126,7 @@ export default defineComponent({
if (isForeignFile) {
// Only handle uploading of outside files (not Nextcloud files)
this.dragover = true
+ this.resetDragOver()
}
},
@@ -126,6 +141,7 @@ export default defineComponent({
if (this.dragover) {
this.dragover = false
+ this.resetDragOver.clear()
}
},
@@ -134,6 +150,7 @@ export default defineComponent({
event.preventDefault()
if (this.dragover) {
this.dragover = false
+ this.resetDragOver.clear()
}
},
@@ -186,16 +203,24 @@ export default defineComponent({
if (lastUpload !== undefined) {
logger.debug('Scrolling to last upload in current folder', { lastUpload })
- this.$router.push({
- ...this.$route,
+ const location: RawLocation = {
+ path: this.$route.path,
+ // Keep params but change file id
params: {
- view: this.$route.params?.view ?? 'files',
- fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
+ ...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,
@@ -210,7 +235,7 @@ export default defineComponent({
justify-content: center;
width: 100%;
// Breadcrumbs height + row thead height
- min-height: calc(58px + 55px);
+ min-height: calc(58px + 44px);
margin: 0;
user-select: none;
color: var(--color-text-maxcontrast);
@@ -218,7 +243,7 @@ export default defineComponent({
border-color: black;
h3 {
- margin-left: 16px;
+ margin-inline-start: 16px;
color: inherit;
}
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
index c19b166581f..72fd98d43fb 100644
--- a/apps/files/src/components/DragAndDropPreview.vue
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -92,34 +92,34 @@ export default Vue.extend({
</script>
<style lang="scss">
-$size: 32px;
+$size: 28px;
$stack-shift: 6px;
.files-list-drag-image {
position: absolute;
top: -9999px;
- left: -9999px;
+ inset-inline-start: -9999px;
display: flex;
overflow: hidden;
align-items: center;
- height: 44px;
- padding: 6px 12px;
+ height: $size + $stack-shift;
+ padding: $stack-shift $stack-shift * 2;
background: var(--color-main-background);
&__icon,
- .files-list__row-icon {
+ .files-list__row-icon-preview-container {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
- width: 32px;
- height: 32px;
+ width: $size - $stack-shift;
+ height: $size - $stack-shift;;
border-radius: var(--border-radius);
}
&__icon {
overflow: visible;
- margin-right: 12px;
+ margin-inline-end: $stack-shift * 2;
img {
max-width: 100%;
@@ -138,13 +138,15 @@ $stack-shift: 6px;
display: flex;
// Stack effect if more than one element
- .files-list__row-icon + .files-list__row-icon {
+ // Max 3 elements
+ > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container {
margin-top: $stack-shift;
- margin-left: $stack-shift - $size;
- & + .files-list__row-icon {
+ 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) + * {
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index fdc800b3464..d66c3fa0ed7 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -34,9 +34,8 @@
@click.native="execDefaultAction" />
<FileEntryName ref="name"
- :display-name="displayName"
+ :basename="basename"
:extension="extension"
- :files-list-width="filesListWidth"
:nodes="nodes"
:source="source"
@auxclick.native="execDefaultAction"
@@ -47,11 +46,18 @@
<FileEntryActions v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
- :files-list-width="filesListWidth"
- :loading.sync="loading"
: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"
@@ -67,13 +73,16 @@
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
- <NcDateTime v-if="source.mtime" :timestamp="source.mtime" :ignore-seconds="true" />
+ <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-${currentView.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
@@ -85,22 +94,25 @@
</template>
<script lang="ts">
+import { FileType, formatFileSize } from '@nextcloud/files'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
-import { formatFileSize } from '@nextcloud/files'
-import moment from '@nextcloud/moment'
+import { t } from '@nextcloud/l10n'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
-import { useNavigation } from '../composables/useNavigation'
+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 FileEntryMixin from './FileEntryMixin.ts'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
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'
@@ -121,6 +133,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
@@ -133,7 +149,13 @@ export default defineComponent({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
- const { currentView } = useNavigation()
+ 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,
@@ -142,7 +164,10 @@ export default defineComponent({
renamingStore,
selectionStore,
+ currentDir,
+ currentFileId,
currentView,
+ filesListWidth,
}
},
@@ -172,12 +197,42 @@ export default defineComponent({
if (this.filesListWidth < 512 || this.compact) {
return []
}
- return this.currentView?.columns || []
+ 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 || size < 0) {
+ if (size === undefined || isNaN(size) || size < 0) {
return this.t('files', 'Pending')
}
return formatFileSize(size, true)
@@ -187,7 +242,7 @@ export default defineComponent({
const maxOpacitySize = 10 * 1024 * 1024
const size = this.source.size
- if (!size || isNaN(size) || size < 0) {
+ if (size === undefined || isNaN(size) || size < 0) {
return {}
}
@@ -196,17 +251,26 @@ export default defineComponent({
color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
}
},
+ },
- mtimeTitle() {
- if (this.source.mtime) {
- return moment(this.source.mtime).format('LLL')
- }
- return ''
- },
+ 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/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index 84f9fd828fc..c66cb8fbd7f 100644
--- a/apps/files/src/components/FileEntry/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -11,7 +11,7 @@ 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/dist/Components/NcIconSvgWrapper.js'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
/**
* A favorite icon to be used for overlaying favorite entries like the file preview / icon
@@ -56,8 +56,8 @@ export default defineComponent({
:deep() {
svg {
// We added a stroke for a11y so we must increase the size to include the stroke
- width: 26px !important;
- height: 26px !important;
+ width: 20px !important;
+ height: 20px !important;
// Override NcIconSvgWrapper defaults of 20px
max-width: unset !important;
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index 3df4289b1a0..5c537d878fe 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -22,36 +22,72 @@
type="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
- :open.sync="openedMenu"
- @close="openedSubmenu = null">
- <!-- Default actions list-->
- <NcActionButton v-for="action in enabledMenuActions"
+ :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--menu`]: isMenu(action.id)
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
}"
- :close-after-click="!isMenu(action.id)"
+ :close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
- :is-menu="isMenu(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="loading === action.id" :size="18" />
- <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
</template>
- {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
+ {{ 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" @click="onBackToMenuClick(openedSubmenu)">
+ <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
- {{ actionDisplayName(openedSubmenu) }}
+ {{ t('files', 'Back') }}
</NcActionButton>
<NcActionSeparator />
@@ -62,10 +98,11 @@
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="loading === action.id" :size="18" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@@ -76,27 +113,29 @@
</template>
<script lang="ts">
-import type { PropType, ShallowRef } from 'vue'
-import type { FileAction, Node, View } from '@nextcloud/files'
-
-import { DefaultType, NodeStatus, getFileActions } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { defineComponent } from 'vue'
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+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 logger from '../../logger.js'
-
-// The registered actions list
-const actions = getFileActions()
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import actionsMixins from '../../mixins/actionsMixin.ts'
+import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryActions',
@@ -111,15 +150,9 @@ export default defineComponent({
NcLoadingIcon,
},
+ mixins: [actionsMixins],
+
props: {
- filesListWidth: {
- type: Number,
- required: true,
- },
- loading: {
- type: String,
- required: true,
- },
opened: {
type: Boolean,
default: false,
@@ -135,46 +168,45 @@ export default defineComponent({
},
setup() {
- const { currentView } = useNavigation()
-
- return {
- // The file list is guaranteed to be only shown with active view
- currentView: currentView as ShallowRef<View>,
- }
- },
+ // 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()
- data() {
+ const activeStore = useActiveStore()
+ const filesListWidth = useFileListWidth()
+ const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
- openedSubmenu: null as FileAction | null,
+ activeStore,
+ currentDir,
+ currentView,
+ enabledFileActions,
+ filesListWidth,
+ t,
}
},
computed: {
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
+ isActive() {
+ return this.activeStore?.activeNode?.source === this.source.source
},
+
isLoading() {
return this.source.status === NodeStatus.LOADING
},
- // Sorted actions that are enabled for this node
- enabledActions() {
- if (this.source.status === NodeStatus.FAILED) {
- return []
- }
-
- return actions
- .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- },
-
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
- return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ 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
@@ -182,12 +214,7 @@ export default defineComponent({
if (this.gridMode) {
return []
}
- return this.enabledActions.filter(action => typeof action.renderInline === 'function')
- },
-
- // Default actions
- enabledDefaultActions() {
- return this.enabledActions.filter(action => !!action?.default)
+ return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
},
// Actions shown in the menu
@@ -202,7 +229,7 @@ export default defineComponent({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
- ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
+ ...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)
@@ -215,16 +242,12 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
- enabledSubmenuActions() {
- return this.enabledActions
- .filter(action => action.parent)
- .reduce((arr, action) => {
- if (!arr[action.parent!]) {
- arr[action.parent!] = []
- }
- arr[action.parent!].push(action)
- return arr
- }, {} as Record<string, FileAction[]>)
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
},
openedMenu: {
@@ -244,96 +267,91 @@ export default defineComponent({
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
+ },
- mountType() {
- return this.source.attributes['mount-type']
+ 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) {
- 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
+ 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
}
- return action.displayName([this.source], this.currentView)
},
- async onActionClick(action, isSubmenu = false) {
- // Skip click on loading
- if (this.isLoading || this.loading !== '') {
- return
+ 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
}
- const displayName = action.displayName([this.source], this.currentView)
- try {
- // Set the loading marker
- this.$emit('update:loading', action.id)
- this.$set(this.source, 'status', NodeStatus.LOADING)
-
- const success = await action.exec(this.source, this.currentView, this.currentDir)
+ // Make sure we set the node as active
+ this.activeStore.activeNode = this.source
- // If the action returns null, we stay silent
- if (success === null || success === undefined) {
- return
- }
+ // Execute the action
+ await executeAction(action)
+ },
- if (success) {
- showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
- return
- }
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } catch (e) {
- logger.error('Error while executing action', { action, e })
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } finally {
- // Reset the loading marker
- this.$emit('update:loading', '')
- this.$set(this.source, 'status', undefined)
-
- // If that was a submenu, we just go back after the action
- if (isSubmenu) {
- this.openedSubmenu = null
- }
+ onKeyDown(event: KeyboardEvent) {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
}
- },
- execDefaultAction(event) {
- if (this.enabledDefaultActions.length > 0) {
- event.preventDefault()
- event.stopPropagation()
- // Execute the first default action if any
- this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
+
+ // ESC close the action menu if opened
+ if (event.key === 'Escape' && this.openedMenu) {
+ this.openedMenu = false
}
- },
- isMenu(id: string) {
- return this.enabledSubmenuActions[id]?.length > 0
+ // a open the action menu
+ if (event.key === 'a' && !this.openedMenu) {
+ this.openedMenu = true
+ }
},
- async onBackToMenuClick(action: FileAction) {
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
this.openedSubmenu = null
- // Wait for first render
- await this.$nextTick()
-
- // Focus the previous menu action button
- this.$nextTick(() => {
- // Focus the action button
- const menuAction = this.$refs[`action-${action.id}`]?.[0]
- if (menuAction) {
- menuAction.$el.querySelector('button')?.focus()
- }
- })
},
- t,
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
},
})
</script>
@@ -356,13 +374,26 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper {
}
</style>
-<style lang="scss" scoped>
-:deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) {
- .button-vue__text {
- color: var(--color-primary-element);
+<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;
}
- .button-vue__icon {
- color: var(--color-primary-element);
+
+ &.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
index 987b48ef8ae..5b80a971118 100644
--- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -5,26 +5,32 @@
<template>
<td class="files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
- <NcLoadingIcon v-if="isLoading" />
+ <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 { Node, FileType } from '@nextcloud/files'
+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 { type PropType, defineComponent } from 'vue'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+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.js'
-import type { FileSource } from '../../types.ts'
+import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryCheckbox',
@@ -56,13 +62,21 @@ export default defineComponent({
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
},
@@ -80,6 +94,28 @@ export default defineComponent({
? 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: {
@@ -123,7 +159,15 @@ export default defineComponent({
this.selectionStore.reset()
},
- t,
+ 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
index 4e5a3571e74..418f9581eb6 100644
--- a/apps/files/src/components/FileEntry/FileEntryName.vue
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -5,6 +5,7 @@
<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"
@@ -16,45 +17,42 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
- @keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<component :is="linkTo.is"
v-else
ref="basename"
- :aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo.params">
- <!-- File name -->
- <span class="files-list__row-name-text">
- <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
- <span class="files-list__row-name-" v-text="displayName" />
- <span class="files-list__row-name-ext" v-text="extension" />
+ <!-- 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 { Node } from '@nextcloud/files'
+import type { FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import { FileType, NodeStatus, Permission } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
+import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import axios, { isAxiosError } from '@nextcloud/axios'
-import { defineComponent } from 'vue'
+import { defineComponent, inject } from 'vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-import { useNavigation } from '../../composables/useNavigation'
+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 logger from '../../logger.js'
-
-const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryName',
@@ -64,18 +62,20 @@ export default defineComponent({
},
props: {
- displayName: {
+ /**
+ * The filename without extension
+ */
+ basename: {
type: String,
required: true,
},
+ /**
+ * The extension of the filename
+ */
extension: {
type: String,
required: true,
},
- filesListWidth: {
- type: Number,
- required: true,
- },
nodes: {
type: Array as PropType<Node[]>,
required: true,
@@ -91,13 +91,23 @@ export default defineComponent({
},
setup() {
- const { currentView } = useNavigation()
+ // 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,
}
},
@@ -109,17 +119,17 @@ export default defineComponent({
return this.isRenaming && this.filesListWidth < 512
},
newName: {
- get() {
- return this.renamingStore.newName
+ get(): string {
+ return this.renamingStore.newNodeName
},
- set(newName) {
- this.renamingStore.newName = newName
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
},
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
- [FileType.File]: t('files', 'File name'),
+ [FileType.File]: t('files', 'Filename'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
@@ -135,32 +145,20 @@ export default defineComponent({
}
}
- const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
- if (enabledDefaultActions?.length > 0) {
- const action = enabledDefaultActions[0]
- const displayName = action.displayName([this.source], this.currentView)
+ if (this.defaultFileAction) {
+ const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
return {
- is: 'a',
+ is: 'button',
params: {
+ 'aria-label': displayName,
title: displayName,
- role: 'button',
- tabindex: '0',
- },
- }
- }
-
- if (this.source?.permissions & Permission.READ) {
- return {
- is: 'a',
- params: {
- download: this.source.basename,
- href: this.source.source,
- title: t('files', 'Download file {name}', { name: this.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',
}
@@ -169,7 +167,7 @@ export default defineComponent({
watch: {
/**
- * If renaming starts, select the file name
+ * If renaming starts, select the filename
* in the input, without the extension.
* @param renaming
*/
@@ -181,56 +179,30 @@ export default defineComponent({
}
},
},
- },
- methods: {
- /**
- * Check if the file name is valid and update the
- * input validity using browser's native validation.
- * @param event the keyup event
- */
- checkInputValidity(event: KeyboardEvent) {
- const input = event.target as HTMLInputElement
+ newName() {
+ // Check validity of the new name
const newName = this.newName.trim?.() || ''
- logger.debug('Checking input validity', { newName })
- try {
- this.isFileNameValid(newName)
- input.setCustomValidity('')
- input.title = ''
- } catch (e) {
- if (e instanceof Error) {
- input.setCustomValidity(e.message)
- input.title = e.message
- } else {
- input.setCustomValidity(t('files', 'Invalid file name'))
- }
- } finally {
- input.reportValidity()
- }
- },
-
- isFileNameValid(name: string) {
- const trimmedName = name.trim()
- if (trimmedName === '.' || trimmedName === '..') {
- throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
- } else if (trimmedName.length === 0) {
- throw new Error(t('files', 'File name cannot be empty.'))
- } else if (trimmedName.indexOf('/') !== -1) {
- throw new Error(t('files', '"/" is not allowed inside a file name.'))
- } else if (trimmedName.match(window.OC.config.blacklist_files_regex)) {
- throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
- } else if (this.checkIfNodeExists(name)) {
- throw new Error(t('files', '{newName} already exists.', { newName: name }))
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ return
}
- const char = forbiddenCharacters.find((char) => trimmedName.includes(char))
- if (char) {
- throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char }))
+ let validity = getFilenameValidity(newName)
+ // Checking if already exists
+ if (validity === '' && this.checkIfNodeExists(newName)) {
+ validity = t('files', 'Another entry with the same name already exists.')
}
-
- return true
+ 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)
},
@@ -238,20 +210,20 @@ export default defineComponent({
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
- const extLength = (this.source.extension || '').split('').length
- const length = this.source.basename.split('').length - extLength
- const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
logger.error('Could not find the rename input')
return
}
- input.setSelectionRange(0, length)
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
@@ -263,72 +235,37 @@ export default defineComponent({
// Rename and move the file
async onRename() {
- const oldName = this.source.basename
- const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
- if (newName === '') {
- showError(t('files', 'Name cannot be empty'))
+ const form = this.$refs.renameForm as HTMLFormElement
+ if (!form.checkValidity()) {
+ showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
return
}
- if (oldName === newName) {
+ const oldName = this.source.basename
+ if (newName === oldName) {
this.stopRenaming()
return
}
- // Checking if already exists
- if (this.checkIfNodeExists(newName)) {
- showError(t('files', 'Another entry with the same name already exists'))
- return
- }
-
- // Set loading state
- this.$set(this.source, 'status', NodeStatus.LOADING)
-
- // Update node
- this.source.rename(newName)
-
- logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
- await axios({
- method: 'MOVE',
- url: oldEncodedSource,
- headers: {
- Destination: this.source.encodedSource,
- Overwrite: 'F',
- },
- })
-
- // Success 🎉
- emit('files:node:updated', this.source)
- emit('files:node:renamed', this.source)
- showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
-
- // Reset the renaming store
- this.stopRenaming()
- this.$nextTick(() => {
- this.$refs.basename?.focus()
- })
- } catch (error) {
- logger.error('Error while renaming file', { error })
- this.source.rename(oldName)
- this.$refs.renameInput?.focus()
-
- if (isAxiosError(error)) {
- // TODO: 409 means current folder does not exist, redirect ?
- if (error?.response?.status === 404) {
- showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
- return
- } else if (error?.response?.status === 412) {
- showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
- return
- }
+ 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
}
-
- // Unknown error
- showError(t('files', 'Could not rename "{oldName}"', { oldName }))
- } finally {
- this.$set(this.source, 'status', undefined)
+ } catch (error) {
+ logger.error(error as Error)
+ showError((error as Error).message)
+ // And ensure we reset to the renaming state
+ this.startRenaming()
}
},
@@ -336,3 +273,16 @@ export default defineComponent({
},
})
</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
index 8022af9218e..3d0fffe7584 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -14,16 +14,23 @@
</template>
</template>
- <!-- Decorative image, should not be aria documented -->
- <img v-else-if="previewUrl && backgroundFailed !== true"
- 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="backgroundFailed = false">
+ <!-- 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 />
@@ -43,11 +50,13 @@ import type { PropType } from 'vue'
import type { UserConfig } from '../../types.ts'
import { Node, FileType } from '@nextcloud/files'
-import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
+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 Vue 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'
@@ -55,7 +64,7 @@ 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/Network.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'
@@ -64,8 +73,9 @@ import FavoriteIcon from './FavoriteIcon.vue'
import { isLivePhoto } from '../../services/LivePhotos'
import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryPreview',
components: {
@@ -99,21 +109,25 @@ export default Vue.extend({
setup() {
const userConfigStore = useUserConfigStore()
+ const isPublic = isPublicShare()
+ const publicSharingToken = getSharingToken()
+
return {
userConfigStore,
+
+ isPublic,
+ publicSharingToken,
}
},
data() {
return {
backgroundFailed: undefined as boolean | undefined,
+ backgroundLoaded: false,
}
},
computed: {
- fileid() {
- return this.source?.fileid?.toString?.()
- },
isFavorite(): boolean {
return this.source.attributes.favorite === 1
},
@@ -134,11 +148,28 @@ export default Vue.extend({
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
- || generateUrl('/core/preview?fileId={fileid}', {
- fileid: this.fileid,
- })
+ || (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
@@ -183,7 +214,7 @@ export default Vue.extend({
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
- if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
+ if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
return LinkIcon
}
@@ -200,10 +231,22 @@ export default Vue.extend({
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: {
@@ -211,17 +254,44 @@ export default Vue.extend({
reset() {
// Reset background state to cancel any ongoing requests
this.backgroundFailed = undefined
- if (this.$refs.previewImg) {
- this.$refs.previewImg.src = ''
+ 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,
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
index ed8175fcda7..1bd0572f53b 100644
--- a/apps/files/src/components/FileEntryGrid.vue
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -36,9 +36,8 @@
@click.native="execDefaultAction" />
<FileEntryName ref="name"
- :display-name="displayName"
+ :basename="basename"
:extension="extension"
- :files-list-width="filesListWidth"
:grid-mode="true"
:nodes="nodes"
:source="source"
@@ -52,15 +51,15 @@
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
- <NcDateTime v-if="source.mtime" :timestamp="source.mtime" :ignore-seconds="true" />
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
</td>
<!-- Actions -->
<FileEntryActions ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
- :files-list-width="filesListWidth"
:grid-mode="true"
- :loading.sync="loading"
:opened.sync="openedMenu"
:source="source" />
</tr>
@@ -69,9 +68,10 @@
<script lang="ts">
import { defineComponent } from 'vue'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
-import { useNavigation } from '../composables/useNavigation'
+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'
@@ -106,7 +106,12 @@ export default defineComponent({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
- const { currentView } = useNavigation()
+ // 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,
@@ -115,6 +120,8 @@ export default defineComponent({
renamingStore,
selectionStore,
+ currentDir,
+ currentFileId,
currentView,
}
},
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index 243b963c7b2..735490c45b3 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -3,26 +3,29 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ComponentPublicInstance, PropType } from 'vue'
+import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
-import { showError } from '@nextcloud/dialogs'
-import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
-import { translate as t } from '@nextcloud/l10n'
+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 { extname } from 'path'
-import Vue, { defineComponent } from 'vue'
+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 { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
-import logger from '../logger.js'
-import FileEntryActions from '../components/FileEntry/FileEntryActions.vue'
+import { isDownloadable } from '../utils/permissions.ts'
+import logger from '../logger.ts'
Vue.directive('onClickOutside', vOnClickOutside)
+const actions = getFileActions()
+
export default defineComponent({
props: {
source: {
@@ -47,46 +50,59 @@ export default defineComponent({
},
},
+ provide() {
+ return {
+ defaultFileAction: computed(() => this.defaultFileAction),
+ enabledFileActions: computed(() => this.enabledFileActions),
+ }
+ },
+
data() {
return {
- loading: '',
dragover: false,
gridMode: false,
}
},
computed: {
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentFileId() {
- return this.$route.params?.fileid || this.$route.query?.fileid || null
- },
-
fileid() {
return this.source.fileid ?? 0
},
+
uniqueId() {
return hashCode(this.source.source)
},
+
isLoading() {
return this.source.status === NodeStatus.LOADING
},
- extension() {
- if (this.source.attributes?.displayname) {
- return extname(this.source.attributes.displayname)
+ /**
+ * 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.source.extension || ''
+ return this.displayName.slice(0, 0 - this.extension.length)
},
- displayName() {
- const ext = this.extension
- const name = String(this.source.attributes.displayname
- || this.source.basename)
+ /**
+ * The extension of the file
+ */
+ extension() {
+ if (this.source.type === FileType.Folder) {
+ return ''
+ }
- // Strip extension from name if defined
- return !ext ? name : name.slice(0, 0 - ext.length)
+ return extname(this.displayName)
},
draggingFiles() {
@@ -117,11 +133,16 @@ export default defineComponent({
return this.source.status === NodeStatus.FAILED
},
- canDrag() {
+ 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
}
@@ -134,11 +155,16 @@ export default defineComponent({
return canDrag(this.source)
},
- canDrop() {
+ 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
@@ -152,26 +178,83 @@ export default defineComponent({
return this.actionsMenuStore.opened === this.uniqueId.toString()
},
set(opened) {
- this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null
+ // 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
},
},
- mtimeOpacity() {
- const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
+ 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
+ },
- const mtime = this.source.mtime?.getTime?.()
- if (!mtime) {
+ mtimeOpacity() {
+ if (!this.mtime) {
return {}
}
- // 1 = today, 0 = 31 days ago
- const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime))
- if (ratio < 0) {
+ // 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) ${ratio}%, var(--color-text-maxcontrast))`,
+ 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)
},
},
@@ -179,12 +262,28 @@ export default defineComponent({
/**
* 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(a: Node, b: Node) {
- if (a.source !== b.source) {
+ 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() {
@@ -193,9 +292,6 @@ export default defineComponent({
methods: {
resetState() {
- // Reset loading state
- this.loading = ''
-
// Reset the preview state
this.$refs?.preview?.reset?.()
@@ -210,6 +306,11 @@ export default defineComponent({
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) {
@@ -218,6 +319,7 @@ export default defineComponent({
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 {
@@ -236,26 +338,47 @@ export default defineComponent({
event.stopPropagation()
},
- execDefaultAction(event) {
+ execDefaultAction(event: MouseEvent) {
// Ignore click if we are renaming
if (this.isRenaming) {
return
}
- // Ignore right click.
- if (event.button > 1) {
+ // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4)
+ if (Boolean(event.button & 2) || event.button > 4) {
return
}
- // if ctrl+click or middle mouse button, open in new tab
- if (event.ctrlKey || event.metaKey || event.button === 1) {
+ // 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()
- window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
- return false
+ 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
}
- const actions = this.$refs.actions as ComponentPublicInstance<typeof FileEntryActions>
- actions.execDefaultAction(event)
+ // 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) {
@@ -364,7 +487,7 @@ export default defineComponent({
logger.debug('Dropped', { event, folder, selection, fileTree })
// Check whether we're uploading files
- if (fileTree.contents.length > 0) {
+ if (selection.length === 0 && fileTree.contents.length > 0) {
await onDropExternalFiles(fileTree, folder, contents.contents)
return
}
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
index 96d465a23d2..31458398028 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -9,6 +9,13 @@
</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
@@ -19,21 +26,29 @@ export default {
name: 'FilesListHeader',
props: {
header: {
- type: Object,
+ type: Object as PropType<Header>,
required: true,
},
currentFolder: {
- type: Object,
+ type: Object as PropType<Folder>,
required: true,
},
currentView: {
- type: Object,
+ 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)
+ return this.header.enabled?.(this.currentFolder, this.currentView) ?? true
},
},
watch: {
@@ -41,15 +56,45 @@ export default {
if (!enabled) {
return
}
- this.header.updated(this.currentFolder, this.currentView)
+ // 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)
},
- currentFolder() {
- this.header.updated(this.currentFolder, this.currentView)
+ currentView(view: View) {
+ this.queueUpdate(this.currentFolder, view)
},
},
+
mounted() {
- console.debug('Mounted', this.header.id)
- this.header.render(this.$refs.mount, this.currentFolder, this.currentView)
+ 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
index debace7a681..9e8cdc159ee 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -21,6 +21,10 @@
<!-- 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">
@@ -50,6 +54,7 @@ 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',
@@ -59,6 +64,10 @@ export default defineComponent({
type: View,
required: true,
},
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -84,27 +93,24 @@ export default defineComponent({
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
+ const { directory } = useRouteParameters()
return {
filesStore,
pathsStore,
+ directory,
}
},
computed: {
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
-
currentFolder() {
if (!this.currentView?.id) {
return
}
- if (this.dir === '/') {
+ if (this.directory === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
- const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)!
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)!
return this.filesStore.getNode(fileId)
},
@@ -143,7 +149,7 @@ export default defineComponent({
<style scoped lang="scss">
// Scoped row
tr {
- margin-bottom: 300px;
+ margin-bottom: var(--body-container-margin);
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
index 723dd574da8..23e631199eb 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -6,7 +6,7 @@
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
- <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
+ <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" />
</th>
<!-- Columns display -->
@@ -24,6 +24,14 @@
<!-- 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"
@@ -59,16 +67,16 @@ 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 NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
-
-import { useNavigation } from '../composables/useNavigation'
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.js'
+import logger from '../logger.ts'
export default defineComponent({
name: 'FilesListTableHeader',
@@ -83,6 +91,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -155,8 +167,23 @@ export default defineComponent({
},
},
+ 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): ARIAMixin['ariaSort'] {
+ ariaSortForMode(mode: string): 'ascending'|'descending'|null {
if (this.sortingMode === mode) {
return this.isAscSorting ? 'ascending' : 'descending'
}
@@ -172,7 +199,7 @@ export default defineComponent({
}
},
- onToggleAll(selected) {
+ 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 })
@@ -185,6 +212,9 @@ export default defineComponent({
},
resetSelection() {
+ if (this.isNoneSelected) {
+ return
+ }
this.selectionStore.reset()
},
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index c73cd05d016..6a808355c58 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -3,16 +3,29 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <div class="files-list__column files-list__row-actions-batch">
+ <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="inlineActions"
- :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
- :open.sync="openedMenu">
- <NcActionButton v-for="action in enabledActions"
+ :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"
- :class="'files-list__row-actions-batch-' + 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" />
@@ -20,26 +33,62 @@
</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 { Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
+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 NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import Vue, { defineComponent, type PropType } from 'vue'
+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 filesListWidthMixin from '../mixins/filesListWidth.ts'
-import logger from '../logger.js'
-import type { FileSource } from '../types'
+import actionsMixins from '../mixins/actionsMixin.ts'
+import logger from '../logger.ts'
// The registered actions list
const actions = getFileActions()
@@ -48,15 +97,14 @@ export default defineComponent({
name: 'FilesListTableHeaderActions',
components: {
+ ArrowLeftIcon,
NcActions,
NcActionButton,
NcIconSvgWrapper,
NcLoadingIcon,
},
- mixins: [
- filesListWidthMixin,
- ],
+ mixins: [actionsMixins],
props: {
currentView: {
@@ -73,10 +121,20 @@ export default defineComponent({
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,
}
},
@@ -87,17 +145,78 @@ export default defineComponent({
},
computed: {
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
- enabledActions() {
+ enabledFileActions(): FileAction[] {
return actions
- .filter(action => action.execBatch)
+ // 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))
@@ -118,13 +237,13 @@ export default defineComponent({
},
inlineActions() {
- if (this.filesListWidth < 512) {
+ if (this.fileListWidth < 512) {
return 0
}
- if (this.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return 1
}
- if (this.filesListWidth < 1024) {
+ if (this.fileListWidth < 1024) {
return 2
}
return 3
@@ -135,25 +254,36 @@ export default defineComponent({
/**
* Get a cached note from the store
*
- * @param {number} fileId the file id to get
- * @return {Folder|File}
+ * @param source The source of the node to get
*/
- getNode(fileId) {
- return this.filesStore.getNode(fileId)
+ getNode(source: string): Node|undefined {
+ return this.filesStore.getNode(source)
},
async onActionClick(action) {
- const displayName = action.displayName(this.nodes, this.currentView)
+ // 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 => {
- Vue.set(node, 'status', NodeStatus.LOADING)
+ this.$set(node, 'status', NodeStatus.LOADING)
})
// Dispatch action execution
- const results = await action.execBatch(this.nodes, this.currentView, this.dir)
+ const results = await action.execBatch(this.nodes, this.currentView, this.directory)
// Check if all actions returned null
if (!results.some(result => result !== null)) {
@@ -175,21 +305,21 @@ export default defineComponent({
return
}
- showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
+ showError(this.t('files', '{displayName}: failed on some elements', { displayName }))
return
}
// Show success message and clear selection
- showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
+ 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}" action failed', { displayName }))
+ showError(this.t('files', '{displayName}: failed', { displayName }))
} finally {
// Remove loading markers
this.loading = null
this.nodes.forEach(node => {
- Vue.set(node, 'status', undefined)
+ this.$set(node, 'status', undefined)
})
}
},
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
index 7a5e900807f..d2e14a5495f 100644
--- a/apps/files/src/components/FilesListTableHeaderButton.vue
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -9,6 +9,7 @@
}]"
: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" />
@@ -24,7 +25,7 @@ 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/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import filesSortingMixin from '../mixins/filesSorting.ts'
@@ -61,7 +62,7 @@ export default defineComponent({
<style scoped lang="scss">
.files-list__column-sort-button {
// Compensate for cells margin
- margin: 0 calc(var(--cell-margin) * -1);
+ margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1);
min-width: calc(100% - 3 * var(--cell-margin))!important;
&-text {
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 9fd056c3aaf..47b8ef19b19 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -9,22 +9,28 @@
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
+ isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
- filesListWidth,
}"
:scroll-to-index="scrollToIndex"
:caption="caption">
+ <template #filters>
+ <FileListFilters />
+ </template>
+
<template v-if="!isNoneSelected" #header-overlay>
- <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span>
+ <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 sortedHeaders"
+ <FilesListHeader v-for="header in headers"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
@@ -35,16 +41,23 @@
<template #header>
<!-- Table header and sort buttons -->
<FilesListTableHeader ref="thead"
- :files-list-width="filesListWidth"
+ :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="filesListWidth"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
@@ -54,35 +67,40 @@
</template>
<script lang="ts">
+import type { UserConfig } from '../types'
import type { Node as NcNode } from '@nextcloud/files'
import type { ComponentPublicInstance, PropType } from 'vue'
-import type { UserConfig } from '../types'
-import { getFileListHeaders, Folder, View, getFileActions, FileType } from '@nextcloud/files'
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
-import { loadState } from '@nextcloud/initial-state'
-import { translate as t } from '@nextcloud/l10n'
+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 { getSummaryFor } from '../utils/fileUtils'
+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 filesListWidthMixin from '../mixins/filesListWidth.ts'
-import VirtualList from './VirtualList.vue'
-import logger from '../logger.js'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
+import VirtualList from './VirtualList.vue'
export default defineComponent({
name: 'FilesListVirtual',
components: {
+ FileListFilters,
FilesListHeader,
FilesListTableFooter,
FilesListTableHeader,
@@ -90,10 +108,6 @@ export default defineComponent({
FilesListTableHeaderActions,
},
- mixins: [
- filesListWidthMixin,
- ],
-
props: {
currentView: {
type: View,
@@ -107,14 +121,33 @@ export default defineComponent({
type: Array as PropType<NcNode[]>,
required: true,
},
+ summary: {
+ type: String,
+ required: true,
+ },
},
setup() {
- const userConfigStore = useUserConfigStore()
+ const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+
+ const fileListWidth = useFileListWidth()
+ const { fileId, openDetails, openFile } = useRouteParameters()
+
return {
- userConfigStore,
+ fileId,
+ fileListWidth,
+ headers: useFileListHeaders(),
+ openDetails,
+ openFile,
+
+ activeStore,
selectionStore,
+ userConfigStore,
+
+ n,
+ t,
}
},
@@ -122,9 +155,7 @@ export default defineComponent({
return {
FileEntry,
FileEntryGrid,
- headers: getFileListHeaders(),
scrollToIndex: 0,
- openFileId: null as number|null,
}
},
@@ -133,51 +164,53 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
- fileId() {
- return parseInt(this.$route.params.fileid) || null
- },
-
- /**
- * If the current `fileId` should be opened
- * The state of the `openfile` query param
- */
- openFile() {
- return !!this.$route.query.openfile
- },
-
- summary() {
- return getSummaryFor(this.nodes)
+ 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.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return false
}
return this.nodes.some(node => node.mtime !== undefined)
},
isSizeAvailable() {
// Hide size column on narrow screens
- if (this.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return false
}
return this.nodes.some(node => node.size !== undefined)
},
- sortedHeaders() {
- if (!this.currentFolder || !this.currentView) {
- return []
- }
+ cantUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
+ },
- return [...this.headers].sort((a, b) => a.order - b.order)
+ 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}\n${sortableCaption}\n${virtualListNote}`
+ return [
+ viewCaption,
+ cantUploadCaption,
+ quotaExceededCaption,
+ sortableCaption,
+ virtualListNote,
+ ].filter(Boolean).join('\n')
},
selectedNodes() {
@@ -187,93 +220,179 @@ export default defineComponent({
isNoneSelected() {
return this.selectedNodes.length === 0
},
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
},
watch: {
- fileId(fileId) {
- this.scrollToFile(fileId, false)
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
},
-
- openFile(open: boolean) {
- if (open) {
- this.$nextTick(() => this.handleOpenFile(this.fileId))
- }
+ 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)
-
- const { id } = loadState<{ id?: number }>('files', 'fileInfo', {})
- this.scrollToFile(id ?? this.fileId)
- this.openSidebarForFile(id ?? this.fileId)
- this.handleOpenFile(id ?? null)
+ 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: {
- // Open the file sidebar if we have the room for it
- // but don't open the sidebar for the current folder
+ 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) {
- if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== 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)
- }
+ // 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(this.t('files', 'File not found'))
+ showError(t('files', 'File not found'))
}
+
this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
/**
- * Handle opening a file (e.g. by ?openfile=true)
- * @param fileId File to open
+ * Unselect the current file and clear open parameters from the URL
*/
- handleOpenFile(fileId: number|null) {
- if (!this.openFile) {
- return
- }
+ 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,
+ )
+ },
- if (fileId === null || this.openFileId === fileId) {
- return
+ // 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 || node.type === FileType.Folder) {
+ if (node === undefined) {
return
}
- logger.debug('Opening file ' + node.path, { node })
- this.openFileId = fileId
- 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))
- // 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
- defaultAction?.exec(node, this.currentView, this.currentFolder.path)
+ 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) {
@@ -304,25 +423,83 @@ export default defineComponent({
}
},
- t,
+ 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: 55px;
+ --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: 32px;
+ --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 {
@@ -351,7 +528,7 @@ export default defineComponent({
}
.files-list__selected {
- padding-right: 12px;
+ padding-inline-end: 12px;
white-space: nowrap;
}
@@ -360,16 +537,36 @@ export default defineComponent({
&.files-list__table--with-thead-overlay {
// Hide the table header below the overlay
- margin-top: calc(-1 * var(--row-height));
+ 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: 0;
+ top: var(--fixed-block-start-position);
// Save space for a row checkbox
- margin-left: var(--row-height);
+ margin-inline-start: var(--row-height);
// More than .files-list__thead
z-index: 20;
@@ -378,8 +575,9 @@ export default defineComponent({
// Reuse row styles
background-color: var(--color-main-background);
- border-bottom: 1px solid var(--color-border);
+ border-block-end: 1px solid var(--color-border);
height: var(--row-height);
+ flex: 0 0 var(--row-height);
}
.files-list__thead,
@@ -388,7 +586,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);
-
}
// Table header
@@ -396,12 +593,17 @@ export default defineComponent({
// Pinned on top when scrolling
position: sticky;
z-index: 10;
- top: 0;
+ top: var(--fixed-block-start-position);
}
- // Table footer
- .files-list__tfoot {
- min-height: 300px;
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
}
tr {
@@ -409,8 +611,7 @@ export default defineComponent({
display: flex;
align-items: center;
width: 100%;
- user-select: none;
- border-bottom: 1px solid var(--color-border);
+ border-block-end: 1px solid var(--color-border);
box-sizing: border-box;
user-select: none;
height: var(--row-height);
@@ -420,7 +621,7 @@ export default defineComponent({
display: flex;
align-items: center;
flex: 0 0 auto;
- justify-content: left;
+ justify-content: start;
width: var(--row-height);
height: var(--row-height);
margin: 0;
@@ -442,8 +643,7 @@ export default defineComponent({
position: absolute;
display: block;
top: 0;
- left: 0;
- right: 0;
+ inset-inline: 0;
bottom: 0;
opacity: .1;
z-index: -1;
@@ -507,7 +707,7 @@ export default defineComponent({
width: var(--icon-preview-size);
height: 100%;
// Show same padding as the checkbox right padding for visual balance
- margin-right: var(--checkbox-padding);
+ margin-inline-end: var(--checkbox-padding);
color: var(--color-primary-element);
// Icon is also clickable
@@ -534,15 +734,31 @@ export default defineComponent({
}
}
- &-preview {
+ &-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);
@@ -553,17 +769,17 @@ export default defineComponent({
&-favorite {
position: absolute;
top: 0px;
- right: -10px;
+ inset-inline-end: -10px;
}
// File and folder overlay
&-overlay {
position: absolute;
- max-height: calc(var(--icon-preview-size) * 0.5);
- max-width: calc(var(--icon-preview-size) * 0.5);
+ 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-top: 2px;
+ margin-block-start: 2px;
// Improve icon contrast with a background for files
&--file {
@@ -581,24 +797,27 @@ export default defineComponent({
// Take as much space as possible
flex: 1 1 auto;
- a {
+ 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;
+ outline: none !important;
}
// Keyboard indicator a11y
&:focus .files-list__row-name-text {
- outline: 2px solid var(--color-main-text) !important;
- border-radius: 20px;
+ 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;
@@ -608,8 +827,8 @@ export default defineComponent({
.files-list__row-name-text {
color: var(--color-main-text);
// Make some space for the outline
- padding: 5px 10px;
- margin-left: -10px;
+ padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
+ padding-inline-start: -10px;
// Align two name and ext
display: inline-flex;
}
@@ -628,7 +847,7 @@ export default defineComponent({
input {
width: 100%;
// Align with text, 0 - padding - border
- margin-left: -8px;
+ margin-inline-start: -8px;
padding: 2px 6px;
border-width: 2px;
@@ -659,52 +878,61 @@ export default defineComponent({
}
.files-list__row-action--inline {
- margin-right: 7px;
+ 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) * 1.5);
+ width: calc(var(--row-height) * 2);
// Right align content/text
justify-content: flex-end;
}
.files-list__row-mtime {
- width: calc(var(--row-height) * 2);
+ 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);
+ 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
-tbody.files-list__tbody.files-list__tbody--grid {
- --half-clickable-area: calc(var(--clickable-area) / 2);
+.files-list--grid tbody.files-list__tbody {
--item-padding: 16px;
- --icon-preview-size: 208px;
- --name-height: 32px;
- --mtime-height: 16px;
- --row-width: calc(var(--icon-preview-size));
- --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height));
+ --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));
- gap: 22px;
align-content: center;
align-items: center;
justify-content: space-around;
justify-items: center;
- margin: 16px;
- width: calc(100% - 32px);
tr {
display: flex;
@@ -712,16 +940,16 @@ tbody.files-list__tbody.files-list__tbody--grid {
width: var(--row-width);
height: var(--row-height);
border: none;
+ border-radius: var(--border-radius-large);
padding: var(--item-padding);
- box-sizing: content-box
}
// Checkbox in the top left
.files-list__row-checkbox {
position: absolute;
z-index: 9;
- top: calc(var(--item-padding)/2);
- left: calc(var(--item-padding)/2);
+ 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);
@@ -733,7 +961,8 @@ tbody.files-list__tbody.files-list__tbody--grid {
width: 16px;
height: 16px;
position: absolute;
- left: 14px;
+ inset-inline-start: 50%;
+ margin-inline-start: -8px;
z-index: -1;
background: var(--color-main-background);
}
@@ -743,7 +972,7 @@ tbody.files-list__tbody.files-list__tbody--grid {
.files-list__row-icon-favorite {
position: absolute;
top: 0;
- right: 0;
+ inset-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -764,18 +993,10 @@ tbody.files-list__tbody.files-list__tbody--grid {
height: var(--icon-preview-size);
}
- .files-list__row-icon-preview {
- border-radius: 0;
- }
-
- a.files-list__row-name-link {
- height: var(--name-height);
- }
-
.files-list__row-name-text {
margin: 0;
// Ensure that the outline is not too close to the text.
- margin-left: -4px;
+ margin-inline-start: -4px;
padding: 0px 4px;
}
}
@@ -783,15 +1004,32 @@ tbody.files-list__tbody.files-list__tbody--grid {
.files-list__row-mtime {
width: var(--icon-preview-size);
height: var(--mtime-height);
- font-size: calc(var(--default-font-size) - 4px);
+ font-size: var(--font-size-small);
}
.files-list__row-actions {
position: absolute;
- right: calc(var(--half-clickable-area) / 2);
- bottom: calc(var(--mtime-height) / 2);
+ 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 b3ec4095fc2..b5a792d9029 100644
--- a/apps/files/src/components/LegacyView.vue
+++ b/apps/files/src/components/LegacyView.vue
@@ -33,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 9cbee4c6672..46c8e5c9af4 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -4,7 +4,7 @@
-->
<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"
@@ -17,6 +17,7 @@
<!-- 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>
@@ -32,11 +33,11 @@ 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 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.js'
+import logger from '../logger.ts'
export default {
name: 'NavigationQuota',
@@ -57,7 +58,7 @@ export default {
computed: {
storageStatsTitle() {
const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false)
- const quotaByte = formatFileSize(this.storageStats?.quota, false, false)
+ const quotaByte = formatFileSize(this.storageStats?.total, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
@@ -94,15 +95,15 @@ export default {
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*
+ // 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
+ // 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
+ // 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) {
+ if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) {
this.showStorageFullWarning()
}
},
@@ -121,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) {
@@ -135,9 +136,9 @@ export default {
throw new Error('Invalid storage stats')
}
- // Warn the user if the available account storage changed from > 0 to 0
+ // 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) {
+ if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) {
this.showStorageFullWarning()
}
@@ -166,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__name {
- margin-top: -6px;
+ --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: 12px;
- 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
index 1ac65421dfe..ca10935940d 100644
--- a/apps/files/src/components/NewNodeDialog.vue
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -3,147 +3,166 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <NcDialog :name="name"
+ <NcDialog data-cy-files-new-node-dialog
+ :name="name"
:open="open"
close-on-click-outside
out-transition
- @update:open="onClose">
+ @update:open="emit('close', null)">
<template #actions>
- <NcButton type="primary"
- :disabled="!isUniqueName"
- @click="onCreate">
+ <NcButton data-cy-files-new-node-dialog-submit
+ type="primary"
+ :disabled="validity !== ''"
+ @click="submit">
{{ t('files', 'Create') }}
</NcButton>
</template>
- <form @submit.prevent="onCreate">
- <NcTextField ref="input"
- :error="!isUniqueName"
- :helper-text="errorMessage"
+ <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 lang="ts">
-import type { PropType } from 'vue'
-
-import { defineComponent } from 'vue'
-import { translate as t } from '@nextcloud/l10n'
+<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/dist/Components/NcButton.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-
-interface ICanFocus {
- focus: () => void
-}
+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'
-export default defineComponent({
- name: 'NewNodeDialog',
- components: {
- NcButton,
- NcDialog,
- NcTextField,
+const props = defineProps({
+ /**
+ * The name to be used by default
+ */
+ defaultName: {
+ type: String,
+ default: t('files', 'New folder'),
},
- props: {
- /**
- * 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'),
- },
+ /**
+ * Other files that are in the current directory
+ */
+ otherNames: {
+ type: Array as PropType<string[]>,
+ default: () => [],
},
- emits: {
- close: (name: string|null) => name === null || name,
+ /**
+ * Open state of the dialog
+ */
+ open: {
+ type: Boolean,
+ default: true,
},
- data() {
- return {
- localDefaultName: this.defaultName || t('files', 'New folder'),
- }
+ /**
+ * Dialog name
+ */
+ name: {
+ type: String,
+ default: t('files', 'Create new folder'),
},
- computed: {
- errorMessage() {
- if (this.isUniqueName) {
- return ''
- } else {
- return t('files', 'A file or folder with that name already exists.')
- }
- },
- uniqueName() {
- return getUniqueName(this.localDefaultName, this.otherNames)
- },
- isUniqueName() {
- return this.localDefaultName === this.uniqueName
- },
+ /**
+ * Input label
+ */
+ label: {
+ type: String,
+ default: t('files', 'Folder name'),
},
- watch: {
- defaultName() {
- this.localDefaultName = this.defaultName || t('files', 'New folder')
- },
+})
- /**
- * Ensure the input is focussed even if the dialog is already mounted but not open
- */
- open() {
- this.$nextTick(() => this.focusInput())
- },
- },
- mounted() {
- // on mounted lets use the unique name
- this.localDefaultName = this.uniqueName
- this.$nextTick(() => this.focusInput())
- },
- methods: {
- t,
+const emit = defineEmits<{
+ (event: 'close', name: string | null): void
+}>()
- /**
- * Focus the filename input field
- */
- focusInput() {
- if (this.open) {
- this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.())
- }
- },
+const localDefaultName = ref<string>(props.defaultName)
+const nameInput = ref<ComponentPublicInstance>()
+const formElement = ref<HTMLFormElement>()
+const validity = ref('')
- onCreate() {
- this.$emit('close', this.localDefaultName)
- },
- onClose(state: boolean) {
- if (!state) {
- this.$emit('close', null)
- }
- },
- },
+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/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue
index a8a94fd4752..d86e5da9d20 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -21,8 +21,8 @@
</template>
<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
@@ -48,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 f73afd9abbe..7927948d3af 100644
--- a/apps/files/src/components/TemplatePreview.vue
+++ b/apps/files/src/components/TemplatePreview.vue
@@ -6,13 +6,14 @@
<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"
@@ -32,7 +33,7 @@
<script>
import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
-import { getToken, isPublic } from '../utils/davUtils.js'
+import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public'
// preview width generation
const previewWidth = 256
@@ -106,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=${encodePath(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`)
},
@@ -124,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>
@@ -192,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 7c2dbd7d8e1..3d668da8144 100644
--- a/apps/files/src/components/TransferOwnershipDialogue.vue
+++ b/apps/files/src/components/TransferOwnershipDialogue.vue
@@ -9,7 +9,7 @@
<form @submit.prevent="submit">
<p class="transfer-select-row">
<span>{{ readableDirectory }}</span>
- <NcButton v-if="directory === undefined"
+ <NcButton v-if="directory === undefined"
class="transfer-select-row__choose_button"
@click.prevent="start">
{{ t('files', 'Choose file or folder to transfer') }}
@@ -18,18 +18,16 @@
{{ t('files', 'Change') }}
</NcButton>
</p>
- <p class="new-owner-row">
+ <p class="new-owner">
<label for="targetUser">
<span>{{ t('files', 'New owner') }}</span>
</label>
- <NcSelect input-id="targetUser"
- v-model="selectedUser"
+ <NcSelect v-model="selectedUser"
+ input-id="targetUser"
:options="formatedUserSuggestions"
:multiple="false"
:loading="loadingUsers"
- label="displayName"
:user-select="true"
- class="middle-align"
@search="findUserDebounced" />
</p>
<p>
@@ -48,11 +46,11 @@ import axios from '@nextcloud/axios'
import debounce from 'debounce'
import { generateOcsUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import Vue from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
-import logger from '../logger.js'
+import logger from '../logger.ts'
const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer'))
.setMultiSelect(false)
@@ -90,6 +88,7 @@ export default {
user: user.uid,
displayName: user.displayName,
icon: 'icon-user',
+ subname: user.shareWithDisplayNameUnique,
}
})
},
@@ -156,6 +155,7 @@ export default {
Vue.set(this.userSuggestions, user.value.shareWith, {
uid: user.value.shareWith,
displayName: user.label,
+ shareWithDisplayNameUnique: user.shareWithDisplayNameUnique,
})
})
} catch (error) {
@@ -203,16 +203,15 @@ 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-wrap: wrap;
+ flex-direction: column;
+ max-width: 400px;
label {
display: flex;
@@ -220,18 +219,14 @@ p {
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 {
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 0ca2d869d17..4746fedf863 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -3,17 +3,35 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <div class="files-list" data-cy-files-list>
+ <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>
- <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }">
+ <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 }}
@@ -27,7 +45,6 @@
<!-- Body -->
<tbody :style="tbodyStyle"
class="files-list__tbody"
- :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="({key, item}, i) in renderedItems"
@@ -38,7 +55,7 @@
</tbody>
<!-- Footer -->
- <tfoot v-show="isReady"
+ <tfoot ref="footer"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@@ -51,21 +68,22 @@
import type { File, Folder, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
+import { defineComponent } from 'vue'
import debounce from 'debounce'
-import Vue from 'vue'
-import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import logger from '../logger.js'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import logger from '../logger.ts'
interface RecycledPoolItem {
key: string,
item: Node,
}
-export default Vue.extend({
- name: 'VirtualList',
+type DataSource = File | Folder
+type DataSourceKey = keyof DataSource
- mixins: [filesListWidthMixin],
+export default defineComponent({
+ name: 'VirtualList',
props: {
dataComponent: {
@@ -73,11 +91,11 @@ export default Vue.extend({
required: true,
},
dataKey: {
- type: String,
+ type: String as PropType<DataSourceKey>,
required: true,
},
dataSources: {
- type: Array as PropType<(File | Folder)[]>,
+ type: Array as PropType<DataSource[]>,
required: true,
},
extraProps: {
@@ -93,7 +111,7 @@ export default Vue.extend({
default: false,
},
/**
- * Visually hidden caption for the table accesibility
+ * Visually hidden caption for the table accessibility
*/
caption: {
type: String,
@@ -101,10 +119,19 @@ export default Vue.extend({
},
},
+ setup() {
+ const fileListWidth = useFileListWidth()
+
+ return {
+ fileListWidth,
+ }
+ },
+
data() {
return {
index: this.scrollToIndex,
beforeHeight: 0,
+ footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
@@ -120,37 +147,60 @@ export default Vue.extend({
// 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
- // 208px + 32px (name) + 16px (mtime) + 16px (padding) + 22px (grid gap)
- return this.gridMode ? (208 + 32 + 16 + 16 + 22) : 55
+ // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
},
+
// Grid mode only
itemWidth() {
- // 208px + 16px padding + 22px grid gap
- return 208 + 16 + 22
+ // 166px + 16px x 2 (padding left and right)
+ return 166 + 16 + 16
},
- rowCount() {
- return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
},
- columnCount() {
+
+ /**
+ * 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.filesListWidth / this.itemWidth)
+ 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() {
- return Math.max(0, this.index - this.bufferItems)
+ const firstColumnIndex = this.index - (this.index % this.columnCount)
+ return Math.max(0, firstColumnIndex - this.bufferItems)
},
/**
@@ -198,17 +248,19 @@ export default Vue.extend({
* The total number of rows that are available
*/
totalRowCount() {
- return Math.floor(this.dataSources.length / this.columnCount)
+ return Math.ceil(this.dataSources.length / this.columnCount)
},
tbodyStyle() {
- const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
- const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
- const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
+ // 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 {
- paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
- paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
- minHeight: `${this.totalRowCount * this.itemHeight + this.beforeHeight}px`,
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
+ minHeight: `${this.totalRowCount * this.itemHeight}px`,
}
},
},
@@ -219,15 +271,14 @@ export default Vue.extend({
totalRowCount() {
if (this.scrollToIndex) {
- this.$nextTick(() => this.scrollTo(this.scrollToIndex))
+ this.scrollTo(this.scrollToIndex)
}
},
columnCount(columnCount, oldColumnCount) {
if (oldColumnCount === 0) {
- // We're initializing, the scroll position
- // is handled on mounted
- console.debug('VirtualList: columnCount is 0, skipping scroll')
+ // 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,
@@ -237,30 +288,28 @@ export default Vue.extend({
},
mounted() {
- const before = this.$refs?.before as HTMLElement
- const root = this.$el as HTMLElement
- const thead = this.$refs?.thead as HTMLElement
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
this.resizeObserver = new ResizeObserver(debounce(() => {
- this.beforeHeight = before?.clientHeight ?? 0
- this.headerHeight = thead?.clientHeight ?? 0
- this.tableHeight = root?.clientHeight ?? 0
+ this.updateHeightVariables()
logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
- }, 100, false))
-
- this.resizeObserver.observe(before)
- this.resizeObserver.observe(root)
- this.resizeObserver.observe(thead)
-
- if (this.scrollToIndex) {
- this.scrollTo(this.scrollToIndex)
- }
-
- // Adding scroll listener AFTER the initial scroll to index
- this.$el.addEventListener('scroll', this.onScroll, { passive: true })
-
- this.$_recycledPool = {} as Record<string, any>
+ }, 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() {
@@ -271,28 +320,105 @@ export default Vue.extend({
methods: {
scrollTo(index: number) {
- const targetRow = Math.ceil(this.dataSources.length / this.columnCount)
- if (targetRow < this.rowCount) {
- logger.debug('VirtualList: Skip scrolling. nothing to scroll', { index, targetRow, rowCount: this.rowCount })
+ 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
- // Scroll to one row and a half before the index
- const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
- logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount })
- this.$el.scrollTop = scrollTop
+
+ // 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 topScroll = this.$el.scrollTop - this.beforeHeight
- const index = Math.floor(topScroll / this.itemHeight) * this.columnCount
+
+ const index = this.scrollPosToIndex(this.$el.scrollTop)
+ if (index === this.index) {
+ return
+ }
+
// Max 0 to prevent negative index
- this.index = Math.max(0, 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
index 360e12660f3..b9eb671a181 100644
--- a/apps/files/src/composables/useNavigation.spec.ts
+++ b/apps/files/src/composables/useNavigation.spec.ts
@@ -2,13 +2,14 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { beforeEach, describe, expect, it, jest } from '@jest/globals'
-import { Navigation, View } from '@nextcloud/files'
+import type { Navigation, View } from '@nextcloud/files'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
-import { defineComponent, nextTick } from 'vue'
-import { useNavigation } from './useNavigation'
+import { defineComponent } from 'vue'
-import nextcloudFiles from '@nextcloud/files'
+import { useNavigation } from './useNavigation'
+import * as nextcloudFiles from '@nextcloud/files'
// Just a wrapper so we can test the composable
const TestComponent = defineComponent({
@@ -23,12 +24,13 @@ const TestComponent = defineComponent({
})
describe('Composables: useNavigation', () => {
- const spy = jest.spyOn(nextcloudFiles, 'getNavigation')
+ const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
let navigation: Navigation
describe('currentView', () => {
beforeEach(() => {
- navigation = new Navigation()
+ // eslint-disable-next-line import/namespace
+ navigation = new nextcloudFiles.Navigation()
spy.mockImplementation(() => navigation)
})
@@ -38,7 +40,8 @@ describe('Composables: useNavigation', () => {
})
it('should return already active navigation', async () => {
- const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ // 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
@@ -47,7 +50,8 @@ describe('Composables: useNavigation', () => {
})
it('should be reactive on updating active navigation', async () => {
- const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ // 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)
@@ -62,7 +66,8 @@ describe('Composables: useNavigation', () => {
describe('views', () => {
beforeEach(() => {
- navigation = new Navigation()
+ // eslint-disable-next-line import/namespace
+ navigation = new nextcloudFiles.Navigation()
spy.mockImplementation(() => navigation)
})
@@ -72,7 +77,8 @@ describe('Composables: useNavigation', () => {
})
it('should return already registered views', () => {
- const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
+ // 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
@@ -81,8 +87,10 @@ describe('Composables: useNavigation', () => {
})
it('should be reactive on registering new views', () => {
- const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
- const view2 = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 })
+ // 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)
diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts
index f410aec895f..2a6f22a1232 100644
--- a/apps/files/src/composables/useNavigation.ts
+++ b/apps/files/src/composables/useNavigation.ts
@@ -3,24 +3,29 @@
* 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 { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue'
+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
*/
-export function useNavigation() {
+// 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<View | null> = shallowRef(navigation.active)
+ 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
+ currentView.value = event.detail as MaybeView
}
/**
@@ -28,11 +33,13 @@ export function useNavigation() {
*/
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)
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
index 8d57d82c034..ab8dbb63dfc 100644
--- a/apps/files/src/eventbus.d.ts
+++ b/apps/files/src/eventbus.d.ts
@@ -2,14 +2,34 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { Node } from '@nextcloud/files'
+
+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 }
}
}
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
index d5ecf8c65da..74eca0969b4 100644
--- a/apps/files/src/init.ts
+++ b/apps/files/src/init.ts
@@ -6,7 +6,7 @@ import { addNewFileMenuEntry, registerDavProperty, registerFileAction } from '@n
import { action as deleteAction } from './actions/deleteAction'
import { action as downloadAction } from './actions/downloadAction'
-import { action as editLocallyAction } from './actions/editLocallyAction'
+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'
@@ -14,20 +14,32 @@ 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'
+import { registerFavoritesView } from './views/favorites.ts'
import registerRecentView from './views/recent'
-import registerPersonalFilesView from './views/personal-files'
-import registerFilesView from './views/files'
-import registerPreviewServiceWorker from './services/ServiceWorker.js'
+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)
@@ -44,16 +56,28 @@ addNewFileMenuEntry(newFolderEntry)
addNewFileMenuEntry(newTemplatesFolder)
registerTemplateEntries()
-// Register files views
-registerFavoritesView()
-registerFilesView()
-registerRecentView()
-registerPersonalFilesView()
+// 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/logger.js b/apps/files/src/logger.ts
index 33f87b424e0..33f87b424e0 100644
--- a/apps/files/src/logger.js
+++ b/apps/files/src/logger.ts
diff --git a/apps/files/src/main-personal-settings.js b/apps/files/src/main-personal-settings.js
index c04eb604d3c..dce190f0160 100644
--- a/apps/files/src/main-personal-settings.js
+++ b/apps/files/src/main-personal-settings.js
@@ -4,16 +4,14 @@
*/
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
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.ts b/apps/files/src/main.ts
index 25fac9c1a94..463ecaf6239 100644
--- a/apps/files/src/main.ts
+++ b/apps/files/src/main.ts
@@ -2,26 +2,27 @@
* 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 { getNavigation } from '@nextcloud/files'
-import { getRequestToken } from '@nextcloud/auth'
import Vue from 'vue'
-import { pinia } from './store/index.ts'
+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'
-import FilesApp from './FilesApp.vue'
-// @ts-expect-error __webpack_nonce__ is injected by webpack
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
declare global {
interface Window {
- OC: any;
- OCA: any;
- OCP: any;
+ 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
}
}
@@ -30,17 +31,14 @@ window.OCA.Files = window.OCA.Files ?? {}
window.OCP.Files = window.OCP.Files ?? {}
// Expose router
-const Router = new RouterService(router)
-Object.assign(window.OCP.Files, { Router })
+if (!window.OCP.Files.Router) {
+ const Router = new RouterService(router)
+ Object.assign(window.OCP.Files, { Router })
+}
// Init Pinia store
Vue.use(PiniaVuePlugin)
-// Init Navigation Service
-// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a oberserver
-const Navigation = Vue.observable(getNavigation())
-Vue.prototype.$navigation = Navigation
-
// Init Files App Settings Service
const Settings = new SettingsService()
Object.assign(window.OCA.Files, { Settings })
@@ -48,6 +46,6 @@ Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel })
const FilesAppVue = Vue.extend(FilesApp)
new FilesAppVue({
- router,
- pinia,
+ 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/filesListWidth.ts b/apps/files/src/mixins/filesListWidth.ts
deleted file mode 100644
index 7d7ec598673..00000000000
--- a/apps/files/src/mixins/filesListWidth.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { defineComponent } from 'vue'
-
-export default defineComponent({
- data() {
- return {
- filesListWidth: 0,
- }
- },
-
- mounted() {
- const fileListEl = document.querySelector('#app-content-vue')
- this.filesListWidth = fileListEl?.clientWidth ?? 0
-
- // @ts-expect-error The resize observer is just now attached to the object
- this.$resizeObserver = new ResizeObserver((entries) => {
- if (entries.length > 0 && entries[0].target === fileListEl) {
- this.filesListWidth = entries[0].contentRect.width
- }
- })
- // @ts-expect-error The resize observer was attached right before to the this object
- this.$resizeObserver.observe(fileListEl as Element)
- },
-
- beforeDestroy() {
- // @ts-expect-error mounted must have been called before the destroy, so the resize
- this.$resizeObserver.disconnect()
- },
-})
diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts
index 53c747fa29b..12515db103f 100644
--- a/apps/files/src/mixins/filesSorting.ts
+++ b/apps/files/src/mixins/filesSorting.ts
@@ -6,16 +6,20 @@ import Vue from 'vue'
import { mapState } from 'pinia'
import { useViewConfigStore } from '../store/viewConfig'
-import { Navigation, View } from '@nextcloud/files'
+import { useNavigation } from '../composables/useNavigation'
export default Vue.extend({
+ setup() {
+ const { currentView } = useNavigation()
+
+ return {
+ currentView,
+ }
+ },
+
computed: {
...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']),
- currentView(): View {
- return (this.$navigation as Navigation).active as View
- },
-
/**
* Get the sorting mode for the current view
*/
diff --git a/apps/files/src/models/Setting.js b/apps/files/src/models/Setting.js
index 66173b7df93..1db1d818e69 100644
--- a/apps/files/src/models/Setting.js
+++ b/apps/files/src/models/Setting.js
@@ -9,6 +9,7 @@ export default class Setting {
_el
_name
_open
+ _order
/**
* Create a new files app setting
@@ -19,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 = () => {}
@@ -33,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() {
@@ -51,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 7b5ec721f1c..b67d51f277f 100644
--- a/apps/files/src/models/Tab.js
+++ b/apps/files/src/models/Tab.js
@@ -2,7 +2,7 @@
* 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 {
@@ -80,10 +80,7 @@ export default class Tab {
this._scrollBottomReached = scrollBottomReached
if (typeof iconSvg === 'string') {
- sanitizeSVG(iconSvg)
- .then(sanitizedSvg => {
- this._iconSvgSanitized = sanitizedSvg
- })
+ this._iconSvgSanitized = DOMPurify.sanitize(iconSvg)
}
}
diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts
index a570fa71c61..f0f854d2801 100644
--- a/apps/files/src/newMenu/newFolder.ts
+++ b/apps/files/src/newMenu/newFolder.ts
@@ -8,11 +8,11 @@ import { basename } from 'path'
import { emit } from '@nextcloud/event-bus'
import { getCurrentUser } from '@nextcloud/auth'
import { Permission, Folder } from '@nextcloud/files'
-import { showSuccess } from '@nextcloud/dialogs'
+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.svg?raw'
+import FolderPlusSvg from '@mdi/svg/svg/folder-plus-outline.svg?raw'
import { newNodeName } from '../utils/newNodeDialog'
import logger from '../logger'
@@ -42,19 +42,26 @@ const createNewFolder = async (root: Folder, name: string): Promise<createFolder
export const entry = {
id: 'newFolder',
displayName: t('files', 'New folder'),
- enabled: (context: Folder) => (context.permissions & Permission.CREATE) !== 0,
- iconSvgInline: FolderPlusSvg,
+ 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) {
- const { fileid, source } = await createNewFolder(context, name)
+ 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: getCurrentUser()?.uid || null,
+ owner: context.owner,
permissions: Permission.ALL,
root: context?.root || '/files/' + getCurrentUser()?.uid,
// Include mount-type from parent folder as this is inherited
@@ -65,14 +72,20 @@ export const entry = {
},
})
+ // Show success
+ emit('files:node:created', folder)
showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
logger.debug('Created new folder', { folder, source })
- emit('files:node:created', folder)
+
+ // Navigate to the new folder
window.OCP.Files.Router.goToRoute(
null, // use default route
- { view: 'files', fileid: folder.fileid },
+ { 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
index 9c8e405b911..356fc5e1611 100644
--- a/apps/files/src/newMenu/newFromTemplate.ts
+++ b/apps/files/src/newMenu/newFromTemplate.ts
@@ -69,7 +69,7 @@ export function registerTemplateEntries() {
if (name !== null) {
// Create the file
const picker = await templatePicker
- picker.open(name, provider)
+ picker.open(name.trim(), provider)
}
},
} as Entry)
diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts
index e2c27ce067f..bf6862bda08 100644
--- a/apps/files/src/newMenu/newTemplatesFolder.ts
+++ b/apps/files/src/newMenu/newTemplatesFolder.ts
@@ -15,9 +15,11 @@ import { newNodeName } from '../utils/newNodeDialog'
import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
import axios from '@nextcloud/axios'
-import logger from '../logger.js'
+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 })
/**
@@ -53,12 +55,12 @@ const initTemplatesFolder = async function(directory: Folder, name: string) {
export const entry = {
id: 'template-picker',
- displayName: t('files', 'Create new templates folder'),
+ displayName: t('files', 'Create templates folder'),
iconSvgInline: PlusSvg,
- order: 10,
+ order: 30,
enabled(context: Folder): boolean {
- // Templates folder already initialized
- if (templatesPath) {
+ // Templates disabled or templates folder already initialized
+ if (!templatesEnabled || templatesPath) {
return false
}
// Allow creation on your own folders only
diff --git a/apps/files/src/plugins/search/folderSearch.ts b/apps/files/src/plugins/search/folderSearch.ts
index 25049fc25b4..6aabefbfc9d 100644
--- a/apps/files/src/plugins/search/folderSearch.ts
+++ b/apps/files/src/plugins/search/folderSearch.ts
@@ -5,7 +5,7 @@
import type { Node } from '@nextcloud/files'
import { emit } from '@nextcloud/event-bus'
-import { getFilePickerBuilder } from '@nextcloud/dialogs';
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
import { imagePath } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import logger from '../../logger'
@@ -19,31 +19,41 @@ function init() {
return
}
- logger.info('Initializing unified search plugin: folder search from files app');
+ logger.info('Initializing unified search plugin: folder search from files app')
OCA.UnifiedSearch.registerFilterAction({
- id: 'files',
+ id: 'in-folder',
appId: 'files',
+ searchFrom: 'files',
label: t('files', 'In folder'),
icon: imagePath('files', 'app.svg'),
- callback: () => {
- 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]
- emit('nextcloud:unified-search:add-filter', {
- id: 'files',
- payload: folder,
- filterUpdateText: t('files', 'Search in folder: {folder}', { folder: folder.basename }),
- filterParams: { path: folder.path },
- })
- },
- })
- .build()
- filepicker.pick()
+ 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')
+ }
},
})
}
diff --git a/apps/files/src/reference-files.ts b/apps/files/src/reference-files.ts
index 828cbd3e584..3d089fe93c4 100644
--- a/apps/files/src/reference-files.ts
+++ b/apps/files/src/reference-files.ts
@@ -4,9 +4,9 @@
*/
import Vue from 'vue'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
-import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Components/NcRichText.js'
+import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/components/NcRichText'
import FileWidget from './views/ReferenceFileWidget.vue'
import FileReferencePickerElement from './views/FileReferencePickerElement.vue'
diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts
index de81755d234..fccb4a0a2b2 100644
--- a/apps/files/src/router/router.ts
+++ b/apps/files/src/router/router.ts
@@ -3,20 +3,47 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { RawLocation, Route } from 'vue-router'
-import type { ErrorHandler } from 'vue-router/types/router.d.ts'
import { generateUrl } from '@nextcloud/router'
+import { relative } from 'path'
import queryString from 'query-string'
-import Router from 'vue-router'
+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 as (to, onComplete?, onAbort?) => Promise<Route>
-Router.prototype.push = function push(to: RawLocation, onComplete?: ((route: Route) => void) | undefined, onAbort?: ErrorHandler | undefined): Promise<Route> {
- if (onComplete || onAbort) return originalPush.call(this, to, onComplete, onAbort)
- return originalPush.call(this, to).catch(err => err)
+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({
@@ -31,7 +58,7 @@ const router = new Router({
{
path: '/',
// Pretending we're using the default view
- redirect: { name: 'filelist', params: { view: 'files' } },
+ redirect: { name: 'filelist', params: { view: defaultView() } },
},
{
path: '/:view/:fileid(\\d+)?',
@@ -47,4 +74,72 @@ const router = new Router({
},
})
+// 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
index b947c612949..1013baeda6c 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -17,7 +17,7 @@ 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.js'
+import logger from '../logger.ts'
/**
* This function converts a list of DataTransferItems to a file tree.
@@ -178,8 +178,7 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co
for (const node of nodes) {
Vue.set(node, 'status', NodeStatus.LOADING)
- // TODO: resolve potential conflicts prior and force overwrite
- queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE))
+ queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
}
// Wait for all promises to settle
diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts
index 9f947531198..5f4370c7894 100644
--- a/apps/files/src/services/DropServiceUtils.spec.ts
+++ b/apps/files/src/services/DropServiceUtils.spec.ts
@@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { describe, it, expect } from '@jest/globals'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
import { join } from 'node:path'
@@ -88,20 +88,17 @@ describe('Filesystem API traverseTree', () => {
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
})
- afterAll(() => {
- // @ts-expect-error jsdom doesn't have DataTransferItem
- delete window.DataTransferItem
- })
-
it('Should return a RootDirectory with Filesystem API', async () => {
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
- jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
+ 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[])
@@ -121,8 +118,8 @@ describe('DropService dataTransferToFileTree', () => {
})
it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
- jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
- jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+ vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn())
const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts
index 27478dd956a..f10a09cfe27 100644
--- a/apps/files/src/services/DropServiceUtils.ts
+++ b/apps/files/src/services/DropServiceUtils.ts
@@ -10,7 +10,7 @@ import { openConflictPicker } from '@nextcloud/upload'
import { showError, showInfo } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
-import logger from '../logger.js'
+import logger from '../logger.ts'
/**
* This represents a Directory in the file tree
diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js
deleted file mode 100644
index 4e08cdb234d..00000000000
--- a/apps/files/src/services/FileInfo.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import axios from '@nextcloud/axios'
-import { davGetDefaultPropfind } from '@nextcloud/files'
-
-/**
- * @param {any} url -
- */
-export default async function(url) {
- const response = await axios({
- method: 'PROPFIND',
- url,
- data: davGetDefaultPropfind(),
- })
-
- // TODO: create new parser or use cdav-lib when available
- const file = OC.Files.getClient()._client.parseMultiStatus(response.data)
- // TODO: create new parser or use cdav-lib when available
- const fileInfo = OC.Files.getClient()._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
index dc83f16187b..080ce91e538 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -2,28 +2,59 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ContentsWithRoot } from '@nextcloud/files'
+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 { File, Folder, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
+import { join } from 'path'
import { client } from './WebdavClient.ts'
-import logger from '../logger.js'
-
+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 node The node returned by the webdav library
+ * @param stat The result returned by the webdav library
*/
-export const resultToNode = (node: FileStat): File | Folder => davResultToNode(node)
+export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
-export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
+/**
+ * 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 propfindPayload = davGetDefaultPropfind()
+ const searchStore = useSearchStore(getPinia())
- path = `${davRootPath}${path}`
+ 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,
@@ -55,3 +86,25 @@ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> =>
}
})
}
+
+/**
+ * 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
index aee89ac6c3d..10be42444e2 100644
--- a/apps/files/src/services/LivePhotos.ts
+++ b/apps/files/src/services/LivePhotos.ts
@@ -4,6 +4,9 @@
*/
import { Node, registerDavProperty } from '@nextcloud/files'
+/**
+ *
+ */
export function initLivePhotos(): void {
registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' })
}
diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts
index 44864b18c01..6dbb67f30b6 100644
--- a/apps/files/src/services/PreviewService.ts
+++ b/apps/files/src/services/PreviewService.ts
@@ -8,17 +8,14 @@ const SWCacheName = 'previews'
/**
* Check if the preview is already cached by the service worker
+ * @param previewUrl URL to check
*/
-export const isCachedPreview = function(previewUrl: string): Promise<boolean> {
+export async function isCachedPreview(previewUrl: string): Promise<boolean> {
if (!window?.caches?.open) {
- return Promise.resolve(false)
+ return false
}
- return window?.caches?.open(SWCacheName)
- .then(function(cache) {
- return cache.match(previewUrl)
- .then(function(response) {
- return !!response
- })
- })
+ 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
index c8cde136069..d0ca285b05c 100644
--- a/apps/files/src/services/Recent.ts
+++ b/apps/files/src/services/Recent.ts
@@ -3,19 +3,27 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ContentsWithRoot, Node } from '@nextcloud/files'
-import type { ResponseDataDetailed, SearchResult } from 'webdav'
+import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav'
import { getCurrentUser } from '@nextcloud/auth'
-import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL } from '@nextcloud/files'
+import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL, davResultToNode } from '@nextcloud/files'
import { CancelablePromise } from 'cancelable-promise'
import { useUserConfigStore } from '../store/userconfig.ts'
-import { pinia } from '../store/index.ts'
+import { getPinia } from '../store/index.ts'
import { client } from './WebdavClient.ts'
-import { resultToNode } from './Files.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.
@@ -24,7 +32,7 @@ const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 1
* @param path Path to search for recent changes
*/
export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
- const store = useUserConfigStore(pinia)
+ const store = useUserConfigStore(getPinia())
/**
* Filter function that returns only the visible nodes - or hidden if explicitly configured
diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts
index 84516465495..4e2999b1d29 100644
--- a/apps/files/src/services/RouterService.ts
+++ b/apps/files/src/services/RouterService.ts
@@ -2,28 +2,37 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { Route } from 'vue-router'
+import type { Route, Location } from 'vue-router'
import type VueRouter from 'vue-router'
-import type { Dictionary, Location } from 'vue-router/types/router'
export default class RouterService {
- private _router: VueRouter
+ // 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
+ this.router = router
}
get name(): string | null | undefined {
- return this._router.currentRoute.name
+ return this.router.currentRoute.name
}
- get query(): Dictionary<string | (string | null)[] | null | undefined> {
- return this._router.currentRoute.query || {}
+ get query(): Record<string, string | (string | null)[] | null | undefined> {
+ return this.router.currentRoute.query || {}
}
- get params(): Dictionary<string> {
- return this._router.currentRoute.params || {}
+ 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
}
/**
@@ -34,7 +43,7 @@ export default class RouterService {
* @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({
+ return this.router.push({
path,
replace,
})
@@ -51,11 +60,11 @@ export default class RouterService {
*/
goToRoute(
name?: string,
- params?: Dictionary<string>,
- query?: Dictionary<string | (string | null)[] | null | undefined>,
+ params?: Record<string, string>,
+ query?: Record<string, string | (string | null)[] | null | undefined>,
replace?: boolean,
): Promise<Route> {
- return this._router.push({
+ return this.router.push({
name,
query,
params,
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
index 477354d1c36..cc13db44009 100644
--- a/apps/files/src/services/ServiceWorker.js
+++ b/apps/files/src/services/ServiceWorker.js
@@ -2,8 +2,8 @@
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { generateUrl } from '@nextcloud/router'
-import logger from '../logger.js'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.ts'
export default () => {
if ('serviceWorker' in navigator) {
@@ -11,7 +11,15 @@ export default () => {
window.addEventListener('load', async () => {
try {
const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true })
- const registration = await navigator.serviceWorker.register(url, { scope: '/' })
+ 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 })
diff --git a/apps/files/src/services/SortingService.spec.ts b/apps/files/src/services/SortingService.spec.ts
deleted file mode 100644
index 5d20c43ed0a..00000000000
--- a/apps/files/src/services/SortingService.spec.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { describe, expect } from '@jest/globals'
-import { orderBy } from './SortingService'
-
-describe('SortingService', () => {
- test('By default the identify and ascending order is used', () => {
- const array = ['a', 'z', 'b']
- expect(orderBy(array)).toEqual(['a', 'b', 'z'])
- })
-
- test('Use identifiy but descending', () => {
- const array = ['a', 'z', 'b']
- expect(orderBy(array, undefined, ['desc'])).toEqual(['z', 'b', 'a'])
- })
-
- test('Can set identifier function', () => {
- const array = [
- { text: 'a', order: 2 },
- { text: 'z', order: 1 },
- { text: 'b', order: 3 },
- ] as const
- expect(orderBy(array, [(v) => v.order]).map((v) => v.text)).toEqual(['z', 'a', 'b'])
- })
-
- test('Can set multiple identifier functions', () => {
- const array = [
- { text: 'a', order: 2, secondOrder: 2 },
- { text: 'z', order: 1, secondOrder: 3 },
- { text: 'b', order: 2, secondOrder: 1 },
- ] as const
- expect(orderBy(array, [(v) => v.order, (v) => v.secondOrder]).map((v) => v.text)).toEqual(['z', 'b', 'a'])
- })
-
- test('Can set order partially', () => {
- const array = [
- { text: 'a', order: 2, secondOrder: 2 },
- { text: 'z', order: 1, secondOrder: 3 },
- { text: 'b', order: 2, secondOrder: 1 },
- ] as const
-
- expect(
- orderBy(
- array,
- [(v) => v.order, (v) => v.secondOrder],
- ['desc'],
- ).map((v) => v.text),
- ).toEqual(['b', 'a', 'z'])
- })
-
- test('Can set order array', () => {
- const array = [
- { text: 'a', order: 2, secondOrder: 2 },
- { text: 'z', order: 1, secondOrder: 3 },
- { text: 'b', order: 2, secondOrder: 1 },
- ] as const
-
- expect(
- orderBy(
- array,
- [(v) => v.order, (v) => v.secondOrder],
- ['desc', 'desc'],
- ).map((v) => v.text),
- ).toEqual(['a', 'b', 'z'])
- })
-
- test('Numbers are handled correctly', () => {
- const array = [
- { text: '2.3' },
- { text: '2.10' },
- { text: '2.0' },
- { text: '2.2' },
- ] as const
-
- expect(
- orderBy(
- array,
- [(v) => v.text],
- ).map((v) => v.text),
- ).toEqual(['2.0', '2.2', '2.3', '2.10'])
- })
-
- test('Numbers with suffixes are handled correctly', () => {
- const array = [
- { text: '2024-01-05' },
- { text: '2024-05-01' },
- { text: '2024-01-10' },
- { text: '2024-01-05 Foo' },
- ] as const
-
- expect(
- orderBy(
- array,
- [(v) => v.text],
- ).map((v) => v.text),
- ).toEqual(['2024-01-05', '2024-01-05 Foo', '2024-01-10', '2024-05-01'])
- })
-})
diff --git a/apps/files/src/services/SortingService.ts b/apps/files/src/services/SortingService.ts
deleted file mode 100644
index 392f35efc9f..00000000000
--- a/apps/files/src/services/SortingService.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
-
-type IdentifierFn<T> = (v: T) => unknown
-type SortingOrder = 'asc'|'desc'
-
-/**
- * Helper to create string representation
- * @param value Value to stringify
- */
-function stringify(value: unknown) {
- // The default representation of Date is not sortable because of the weekday names in front of it
- if (value instanceof Date) {
- return value.toISOString()
- }
- return String(value)
-}
-
-/**
- * Natural order a collection
- * You can define identifiers as callback functions, that get the element and return the value to sort.
- *
- * @param collection The collection to order
- * @param identifiers An array of identifiers to use, by default the identity of the element is used
- * @param orders Array of orders, by default all identifiers are sorted ascening
- */
-export function orderBy<T>(collection: readonly T[], identifiers?: IdentifierFn<T>[], orders?: SortingOrder[]): T[] {
- // If not identifiers are set we use the identity of the value
- identifiers = identifiers ?? [(value) => value]
- // By default sort the collection ascending
- orders = orders ?? []
- const sorting = identifiers.map((_, index) => (orders[index] ?? 'asc') === 'asc' ? 1 : -1)
-
- const collator = Intl.Collator(
- [getLanguage(), getCanonicalLocale()],
- {
- // handle 10 as ten and not as one-zero
- numeric: true,
- usage: 'sort',
- },
- )
-
- return [...collection].sort((a, b) => {
- for (const [index, identifier] of identifiers.entries()) {
- // Get the local compare of stringified value a and b
- const value = collator.compare(stringify(identifier(a)), stringify(identifier(b)))
- // If they do not match return the order
- if (value !== 0) {
- return value * sorting[index]
- }
- // If they match we need to continue with the next identifier
- }
- // If all are equal we need to return equality
- return 0
- })
-}
diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js
index 113e9d1488b..d7f25846ceb 100644
--- a/apps/files/src/services/Templates.js
+++ b/apps/files/src/services/Templates.js
@@ -11,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
index 5563508e2c7..2b92deba9b4 100644
--- a/apps/files/src/services/WebdavClient.ts
+++ b/apps/files/src/services/WebdavClient.ts
@@ -2,6 +2,18 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { davGetClient } from '@nextcloud/files'
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { Node } from '@nextcloud/files'
-export const client = davGetClient()
+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 61f8be419b6..35a379ad649 100644
--- a/apps/files/src/sidebar.js
+++ b/apps/files/src/sidebar.ts
@@ -23,6 +23,8 @@ 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
@@ -31,14 +33,20 @@ 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
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
index 74de1c4af16..810f662149c 100644
--- a/apps/files/src/store/dragging.ts
+++ b/apps/files/src/store/dragging.ts
@@ -2,9 +2,10 @@
* 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'
-import type { DragAndDropStore, FileSource } from '../types'
export const useDragAndDropStore = defineStore('dragging', {
state: () => ({
@@ -13,7 +14,8 @@ export const useDragAndDropStore = defineStore('dragging', {
actions: {
/**
- * Set the selection of fileIds
+ * Set the selection of files being dragged currently
+ * @param selection array of node sources
*/
set(selection = [] as FileSource[]) {
Vue.set(this, 'dragging', selection)
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index 0b541024018..0bcf4ce9350 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -4,25 +4,15 @@
*/
import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileSource } from '../types'
-import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { Folder, Node } from '@nextcloud/files'
-import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
import { defineStore } from 'pinia'
import { subscribe } from '@nextcloud/event-bus'
import logger from '../logger'
import Vue from 'vue'
-import { client } from '../services/WebdavClient.ts'
-
-const fetchNode = async (node: Node): Promise<Node> => {
- const propfindPayload = davGetDefaultPropfind()
- const result = await client.stat(`${davRootPath}${node.path}`, {
- details: true,
- data: propfindPayload,
- }) as ResponseDataDetailed<FileStat>
- return davResultToNode(result.data)
-}
+import { fetchNode } from '../services/WebdavClient.ts'
+import { usePathsStore } from './paths.ts'
export const useFilesStore = function(...args) {
const store = defineStore('files', {
@@ -34,12 +24,14 @@ export const useFilesStore = function(...args) {
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])
@@ -49,16 +41,58 @@ export const useFilesStore = function(...args) {
* 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) => {
@@ -94,6 +128,17 @@ export const useFilesStore = function(...args) {
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 })
@@ -103,19 +148,34 @@ export const useFilesStore = function(...args) {
// 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(fetchNode)).then(this.updateNodes)
+ 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 (node.source === nodes[0].source) {
+ 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).then(n => this.updateNodes([n]))
+ 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)
+ }
},
},
})
@@ -126,6 +186,10 @@ export const useFilesStore = function(...args) {
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
}
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
index 00676b3bc8e..3ba667ffd2f 100644
--- a/apps/files/src/store/index.ts
+++ b/apps/files/src/store/index.ts
@@ -5,4 +5,11 @@
import { createPinia } from 'pinia'
-export const pinia = createPinia()
+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
index 2b092c89ff8..f2654933895 100644
--- a/apps/files/src/store/keyboard.ts
+++ b/apps/files/src/store/keyboard.ts
@@ -9,6 +9,7 @@ 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', {
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
index 2993cc9d704..4a83cb51c83 100644
--- a/apps/files/src/store/paths.ts
+++ b/apps/files/src/store/paths.ts
@@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { FileSource, PathsStore, PathOptions, ServicesState } from '../types'
+import type { FileSource, PathsStore, PathOptions, ServicesState, Service } from '../types'
import { defineStore } from 'pinia'
-import { FileType, Folder, Node, getNavigation } from '@nextcloud/files'
+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'
@@ -41,6 +42,15 @@ export const usePathsStore = function(...args) {
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) {
@@ -59,46 +69,94 @@ export const usePathsStore = function(...args) {
// Update parent folder children if exists
// If the folder is the root, get it and update it
- if (node.dirname === '/') {
- const root = files.getRoot(service)
- if (!root._children) {
- Vue.set(root, '_children', [])
+ 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])
}
- root._children.push(node.source)
+
+ // 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
}
- // If the folder doesn't exists yet, it will be
- // fetched later and its children updated anyway.
- if (this.paths[service][node.dirname]) {
- const parentSource = this.paths[service][node.dirname]
- const parentFolder = files.getNode(parentSource) as Folder
- logger.debug('Path already exists, updating children', { parentFolder, node })
+ logger.debug('Parent path does not exists, skipping children update', { node })
+ },
- if (!parentFolder) {
- logger.error('Parent folder not found', { parentSource })
- return
- }
+ addNodeToParentChildren(node: Node) {
+ const service = getNavigation()?.active?.id || 'files'
- if (!parentFolder._children) {
- Vue.set(parentFolder, '_children', [])
- }
- parentFolder._children.push(node.source)
+ // 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) {
- // TODO: watch folders to update paths?
subscribe('files:node:created', pathsStore.onCreatedNode)
- // subscribe('files:node:deleted', pathsStore.onDeletedNode)
- // subscribe('files:node:moved', pathsStore.onMovedNode)
+ subscribe('files:node:deleted', pathsStore.onDeletedNode)
+ subscribe('files:node:moved', pathsStore.onMovedNode)
pathsStore._initialized = true
}
diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts
index 3782b75e3a4..fc61be3bd3b 100644
--- a/apps/files/src/store/renaming.ts
+++ b/apps/files/src/store/renaming.ts
@@ -2,29 +2,174 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { defineStore } from 'pinia'
-import { subscribe } from '@nextcloud/event-bus'
import type { Node } from '@nextcloud/files'
-import type { RenamingStore } from '../types'
-
-export const useRenamingStore = function(...args) {
- const store = defineStore('renaming', {
- state: () => ({
- renamingNode: undefined,
- newName: '',
- } as RenamingStore),
- })
- const renamingStore = store(...args)
+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
- if (!renamingStore._initialized) {
- subscribe('files:node:rename', function(node: Node) {
- renamingStore.renamingNode = node
- renamingStore.newName = node.basename
- })
- renamingStore._initialized = true
+ subscribe('files:node:rename', (node: Node) => {
+ renamingNode.value = node
+ newNodeName.value = node.basename
+ })
+
+ return {
+ $reset,
+
+ newNodeName,
+ rename,
+ renamingNode,
}
+})
- return renamingStore
+/**
+ * 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
index c8c5c6d7de3..fa35d953406 100644
--- a/apps/files/src/store/selection.ts
+++ b/apps/files/src/store/selection.ts
@@ -16,6 +16,7 @@ export const useSelectionStore = defineStore('selection', {
actions: {
/**
* Set the selection of fileIds
+ * @param selection
*/
set(selection = [] as FileSource[]) {
Vue.set(this, 'selected', [...new Set(selection)])
@@ -23,6 +24,7 @@ export const useSelectionStore = defineStore('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
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
index 4faa63a068a..48fe01d5134 100644
--- a/apps/files/src/store/userconfig.ts
+++ b/apps/files/src/store/userconfig.ts
@@ -2,58 +2,61 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { UserConfig, UserConfigStore } from '../types'
-import { defineStore } from 'pinia'
+import type { UserConfig } from '../types'
+import { getCurrentUser } from '@nextcloud/auth'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
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'
-import Vue from 'vue'
-const userConfig = loadState<UserConfig>('files', 'config', {
- show_hidden: false,
+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,
- grid_view: false,
-})
-export const useUserConfigStore = function(...args) {
- const store = defineStore('userconfig', {
- state: () => ({
- userConfig,
- } as UserConfigStore),
+ show_dialog_deletion: false,
+ show_dialog_file_extension: true,
+})
- actions: {
- /**
- * Update the user config local store
- */
- onUpdate(key: string, value: boolean) {
- Vue.set(this.userConfig, key, value)
- },
+export const useUserConfigStore = defineStore('userconfig', () => {
+ const userConfig = ref<UserConfig>({ ...initialUserConfig })
- /**
- * Update the user config local store AND on server side
- */
- async update(key: string, value: boolean) {
- await axios.put(generateUrl('/apps/files/api/v1/config/' + key), {
- value,
- })
+ /**
+ * 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)
+ }
- emit('files:config:updated', { 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 })
+ }
- const userConfigStore = store(...args)
+ // Register the event listener
+ subscribe('files:config:updated', ({ key, value }) => onUpdate(key, value))
- // Make sure we only register the listeners once
- if (!userConfigStore._initialized) {
- subscribe('files:config:updated', function({ key, value }: { key: string, value: boolean }) {
- userConfigStore.onUpdate(key, value)
- })
- userConfigStore._initialized = true
+ return {
+ userConfig,
+ update,
}
-
- return userConfigStore
-}
+})
diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts
index eed17cd1b17..a902cedb6fa 100644
--- a/apps/files/src/store/viewConfig.ts
+++ b/apps/files/src/store/viewConfig.ts
@@ -2,82 +2,95 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { defineStore } from 'pinia'
+import type { ViewConfigs, ViewId, ViewConfig } from '../types'
+
+import { getCurrentUser } from '@nextcloud/auth'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
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'
-import Vue from 'vue'
-import type { ViewConfigs, ViewConfigStore, ViewId, ViewConfig } from '../types'
+const initialViewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
+
+export const useViewConfigStore = defineStore('viewconfig', () => {
-const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs
+ const viewConfigs = ref({ ...initialViewConfig })
-export const useViewConfigStore = function(...args) {
- const store = defineStore('viewconfig', {
- state: () => ({
- viewConfig,
- } as ViewConfigStore),
+ /**
+ * 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] || {}
+ }
- getters: {
- getConfig: (state) => (view: ViewId): ViewConfig => state.viewConfig[view] || {},
- },
+ /**
+ * 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)
+ }
- actions: {
- /**
- * Update the view config local store
- */
- onUpdate(view: ViewId, key: string, value: string | number | boolean) {
- if (!this.viewConfig[view]) {
- Vue.set(this.viewConfig, view, {})
- }
- Vue.set(this.viewConfig[view], 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,
+ })
+ }
- /**
- * Update the view config local store AND on server side
- */
- async update(view: ViewId, key: string, value: string | number | boolean) {
- axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), {
- value,
- })
+ emit('files:view-config:updated', { view, key, value })
+ }
- emit('files:viewconfig: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')
+ }
- /**
- * 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
- */
- setSortingBy(key = 'basename', view = 'files') {
- // Save new config
- this.update(view, 'sorting_mode', key)
- this.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'
- /**
- * Toggle the sorting direction
- */
- toggleSortingDirection(view = 'files') {
- const config = this.getConfig(view) || { sorting_direction: 'asc' }
- const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc'
+ // Save new config
+ update(viewId, 'sorting_direction', newDirection)
+ }
- // Save new config
- this.update(view, 'sorting_direction', newDirection)
- },
- },
- })
+ // Initialize event listener
+ subscribe('files:view-config:updated', ({ view, key, value }) => onUpdate(view, key, value))
- const viewConfigStore = store(...args)
+ return {
+ viewConfigs,
- // Make sure we only register the listeners once
- if (!viewConfigStore._initialized) {
- subscribe('files:viewconfig:updated', function({ view, key, value }: { view: ViewId, key: string, value: boolean }) {
- viewConfigStore.onUpdate(view, key, value)
- })
- viewConfigStore._initialized = true
+ getConfig,
+ setSortingBy,
+ toggleSortingDirection,
+ update,
}
-
- return viewConfigStore
-}
+})
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index 9e1ba049697..0096ecc0fdb 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -2,7 +2,7 @@
* 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 { FileAction, Folder, Node, View } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
// Global definitions
@@ -50,8 +50,21 @@ export interface PathOptions {
// User config store
export interface UserConfig {
- [key: string]: boolean
+ [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
}
@@ -95,6 +108,19 @@ 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
@@ -105,3 +131,18 @@ export interface TemplateFile {
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 8c28c25c044..00000000000
--- a/apps/files/src/utils/davUtils.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { getCurrentUser } from '@nextcloud/auth'
-
-export const isPublic = function() {
- return !getCurrentUser()
-}
-
-export const getToken = function() {
- return document.getElementById('sharingToken') && document.getElementById('sharingToken').value
-}
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/fileUtils.ts b/apps/files/src/utils/fileUtils.ts
index 255c106740d..f0b974be21d 100644
--- a/apps/files/src/utils/fileUtils.ts
+++ b/apps/files/src/utils/fileUtils.ts
@@ -3,15 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { FileType, type Node } from '@nextcloud/files'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { n } from '@nextcloud/l10n'
/**
* Extract dir and name from file path
*
- * @param {string} path the full path
- * @return {string[]} [dirPath, fileName]
+ * @param path - The full path
+ * @return [dirPath, fileName]
*/
-export const extractFilePaths = function(path) {
+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('/')
@@ -20,26 +20,28 @@ export const extractFilePaths = function(path) {
/**
* Generate a translated summary of an array of nodes
- * @param {Node[]} nodes the nodes to summarize
- * @return {string}
+ *
+ * @param nodes - The nodes to summarize
+ * @param hidden - The number of hidden nodes
*/
-export const getSummaryFor = (nodes: Node[]): string => {
+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
- if (fileCount === 0) {
- return n('files', '{folderCount} folder', '{folderCount} folders', folderCount, { folderCount })
- } else if (folderCount === 0) {
- return n('files', '{fileCount} file', '{fileCount} files', fileCount, { fileCount })
+ const summary: string[] = []
+ if (fileCount > 0 || folderCount === 0) {
+ const fileSummary = n('files', '%n file', '%n files', fileCount)
+ summary.push(fileSummary)
}
-
- if (fileCount === 1) {
- return n('files', '1 file and {folderCount} folder', '1 file and {folderCount} folders', folderCount, { folderCount })
+ if (folderCount > 0) {
+ const folderSummary = n('files', '%n folder', '%n folders', folderCount)
+ summary.push(folderSummary)
}
-
- if (folderCount === 1) {
- return n('files', '{fileCount} file and 1 folder', '{fileCount} files and 1 folder', fileCount, { fileCount })
+ 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 t('files', '{fileCount} files and {folderCount} folders', { fileCount, folderCount })
+ 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/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts
index 53d4fe4d00f..a81fa9f4e17 100644
--- a/apps/files/src/utils/newNodeDialog.ts
+++ b/apps/files/src/utils/newNodeDialog.ts
@@ -23,7 +23,7 @@ interface ILabels {
* @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 successfull otherwise null if aborted
+ * @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)
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
index c2a502ee1a8..b4d4bc54f14 100644
--- a/apps/files/src/views/FileReferencePickerElement.vue
+++ b/apps/files/src/views/FileReferencePickerElement.vue
@@ -39,7 +39,7 @@ export default defineComponent({
},
filepickerOptions() {
return {
- allowPickDirectory: false,
+ allowPickDirectory: true,
buttons: this.buttonFactory,
container: `#${this.containerId}`,
multiselect: false,
@@ -53,18 +53,17 @@ export default defineComponent({
buttonFactory(selected: NcNode[]): IFilePickerButton[] {
const buttons = [] as IFilePickerButton[]
if (selected.length === 0) {
- buttons.push({
- label: t('files', 'Choose file'),
- type: 'tertiary' as never,
- callback: this.onClose,
- })
- } else {
- buttons.push({
- label: t('files', 'Choose {file}', { file: selected[0].basename }),
- type: 'primary',
- callback: this.onClose,
- })
+ 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
},
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index 98a817ac067..f9e517e92ee 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -4,12 +4,12 @@
-->
<template>
<NcAppContent :page-heading="pageHeading" data-cy-files-content>
- <div class="files-list__header">
+ <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }">
<!-- Current folder breadcrumbs -->
- <BreadCrumbs :path="dir" @reload="fetchContent">
+ <BreadCrumbs :path="directory" @reload="fetchContent">
<template #actions>
<!-- Sharing button -->
- <NcButton v-if="canShare && filesListWidth >= 512"
+ <NcButton v-if="canShare && fileListWidth >= 512"
:aria-label="shareButtonLabel"
:class="{ 'files-list__header-share-button--shared': shareButtonType }"
:title="shareButtonLabel"
@@ -17,26 +17,13 @@
type="tertiary"
@click="openSharingSidebar">
<template #icon>
- <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
+ <LinkIcon v-if="shareButtonType === ShareType.Link" />
<AccountPlusIcon v-else :size="20" />
</template>
</NcButton>
- <!-- Disabled upload button -->
- <NcButton v-if="!canUpload || isQuotaExceeded"
- :aria-label="cantUploadLabel"
- :title="cantUploadLabel"
- class="files-list__header-upload-button--disabled"
- :disabled="true"
- type="secondary">
- <template #icon>
- <PlusIcon :size="20" />
- </template>
- {{ t('files', 'New') }}
- </NcButton>
-
<!-- Uploader -->
- <UploadPicker v-else-if="currentFolder"
+ <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder"
allow-folders
class="files-list__header-upload-button"
:content="getContent"
@@ -48,7 +35,29 @@
</template>
</BreadCrumbs>
- <NcButton v-if="filesListWidth >= 512 && enableGridView"
+ <!-- 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"
@@ -59,103 +68,141 @@
<ViewGridIcon v-else />
</template>
</NcButton>
-
- <!-- Secondary loading indicator -->
- <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
</div>
<!-- Drag and drop notice -->
- <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" />
-
- <!-- Initial loading -->
- <NcLoadingIcon v-if="loading && !isRefreshing"
+ <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')" />
- <!-- Empty content placeholder -->
- <NcEmptyContent v-else-if="!loading && isEmptyDir"
- :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="dir !== '/'" #action>
- <!-- Uploader -->
- <UploadPicker v-if="currentFolder && 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
- :aria-label="t('files', 'Go to the previous folder')"
- :to="toPreviousDir"
- type="primary">
- {{ t('files', 'Go back') }}
- </NcButton>
- </template>
- <template #icon>
- <NcIconSvgWrapper :svg="currentView.icon" />
- </template>
- </NcEmptyContent>
-
- <!-- File list -->
+ <!-- File list - always mounted -->
<FilesListVirtual v-else
ref="filesListVirtual"
:current-folder="currentFolder"
:current-view="currentView"
- :nodes="dirContentsSorted" />
+ :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 } from '@nextcloud/files'
+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 } from '@nextcloud/files'
+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 } from 'path'
-import { showError, showWarning } from '@nextcloud/dialogs'
-import { Type } from '@nextcloud/sharing'
+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 NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import PlusIcon from 'vue-material-design-icons/Plus.vue'
-import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
-import ViewGridIcon from 'vue-material-design-icons/ViewGrid.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 { orderBy } from '../services/SortingService.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 filesListWidthMixin from '../mixins/filesListWidth.ts'
import filesSortingMixin from '../mixins/filesSorting.ts'
-import logger from '../logger.js'
-import DragAndDropNotice from '../components/DragAndDropNotice.vue'
-import debounce from 'debounce'
+import logger from '../logger.ts'
const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
@@ -169,37 +216,57 @@ export default defineComponent({
LinkIcon,
ListViewIcon,
NcAppContent,
+ NcActions,
+ NcActionButton,
NcButton,
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
- PlusIcon,
AccountPlusIcon,
UploadPicker,
ViewGridIcon,
+ IconAlertCircleOutline,
+ IconReload,
},
mixins: [
- filesListWidthMixin,
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 { currentView } = useNavigation()
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,
@@ -209,39 +276,36 @@ export default defineComponent({
// non reactive data
enableGridView,
forbiddenCharacters,
- Type,
+ ShareType,
}
},
data() {
return {
- filterText: '',
loading: true,
+ loadingAction: null as string | null,
+ error: null as string | null,
promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
- unsubscribeStoreCallback: () => {},
+ dirContentsFiltered: [] as INode[],
}
},
computed: {
/**
- * Handle search event from unified search.
- */
- onSearch() {
- return debounce((searchEvent: { query: string }) => {
- console.debug('Files app handling search event from unified search...', searchEvent)
- this.filterText = searchEvent.query
- }, 500)
- },
-
- /**
* Get a callback function for the uploader to fetch directory contents for conflict resolution
*/
getContent() {
- const view = this.currentView
+ 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
}
@@ -252,122 +316,81 @@ export default defineComponent({
},
pageHeading(): string {
- return this.currentView?.name ?? t('files', 'Files')
- },
-
- /**
- * The current directory query.
- */
- dir(): string {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
+ const title = this.currentView?.name ?? t('files', 'Files')
- /**
- * The current file id
- */
- fileId(): number | null {
- const number = Number.parseInt(this.$route?.params.fileid ?? '')
- return Number.isNaN(number) ? null : number
+ if (this.currentFolder === undefined || this.directory === '/') {
+ return title
+ }
+ return `${this.currentFolder.displayname} - ${title}`
},
/**
* The current folder.
*/
- currentFolder(): Folder | undefined {
- if (!this.currentView?.id) {
- return
- }
-
- if (this.dir === '/') {
- return this.filesStore.getRoot(this.currentView.id)
- }
+ 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,
+ })
- const source = this.pathsStore.getPath(this.currentView.id, this.dir)
- if (source === undefined) {
- return
+ if (!this.currentView?.id) {
+ return dummyFolder
}
- return this.filesStore.getNode(source) as Folder
+ return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
},
- /**
- * Directory content sorting parameters
- * Provided by an extra computed property for caching
- */
- sortingParameters() {
- const identifiers = [
- // 1: Sort favorites first if enabled
- ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
- // 2: Sort folders first if sorting by name
- ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []),
- // 3: Use sorting mode if NOT basename (to be able to use displayName too)
- ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
- // 4: Use displayname if available, fallback to name
- v => v.attributes?.displayname || v.basename,
- // 5: Finally, use basename if all previous sorting methods failed
- v => v.basename,
- ]
- const orders = [
- // (for 1): always sort favorites before normal files
- ...(this.userConfig.sort_favorites_first ? ['asc'] : []),
- // (for 2): always sort folders before files
- ...(this.userConfig.sort_folders_first ? ['asc'] : []),
- // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
- ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
- // (also for 3 so make sure not to conflict with 2 and 3)
- ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
- // for 4: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- // for 5: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- ] as ('asc'|'desc')[]
- return [identifiers, orders] as const
+ dirContents(): Node[] {
+ return (this.currentFolder?._children || [])
+ .map(this.filesStore.getNode)
+ .filter((node: Node) => !!node)
},
/**
* The current directory contents.
*/
- dirContentsSorted(): Node[] {
+ dirContentsSorted(): INode[] {
if (!this.currentView) {
return []
}
- let filteredDirContent = [...this.dirContents]
- // Filter based on the filterText obtained from nextcloud:unified-search.search event.
- if (this.filterText) {
- filteredDirContent = filteredDirContent.filter(node => {
- return node.basename.toLowerCase().includes(this.filterText.toLowerCase())
- })
- console.debug('Files view filtered', filteredDirContent)
- }
-
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.dirContents].sort(customColumn.sort)
+ const results = [...this.dirContentsFiltered].sort(customColumn.sort)
return this.isAscSorting ? results : results.reverse()
}
- return orderBy(
- filteredDirContent,
- ...this.sortingParameters,
- )
- },
-
- dirContents(): Node[] {
- const showHidden = this.userConfigStore?.userConfig.show_hidden
- return (this.currentFolder?._children || [])
- .map(this.getNode)
- .filter(file => {
- if (!showHidden) {
- return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.')
+ 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 !!file
+ return 1
})
+ }
+
+ return nodes
},
/**
@@ -392,37 +415,37 @@ export default defineComponent({
* Route to the previous directory.
*/
toPreviousDir(): Route {
- const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
+ const dir = this.directory.split('/').slice(0, -1).join('/') || '/'
return { ...this.$route, query: { dir } }
},
- shareAttributes(): number[] | undefined {
+ 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.shareAttributes) {
+ if (!this.shareTypesAttributes) {
return t('files', 'Share')
}
- if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
+ if (this.shareButtonType === ShareType.Link) {
return t('files', 'Shared by link')
}
return t('files', 'Shared')
},
- shareButtonType(): Type | null {
- if (!this.shareAttributes) {
+ shareButtonType(): ShareType | null {
+ if (!this.shareTypesAttributes) {
return null
}
// If all types are links, show the link icon
- if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
- return Type.SHARE_TYPE_LINK
+ if (this.shareTypesAttributes.some(type => type === ShareType.Link)) {
+ return ShareType.Link
}
- return Type.SHARE_TYPE_USER
+ return ShareType.User
},
gridViewButtonLabel() {
@@ -440,23 +463,72 @@ export default defineComponent({
isQuotaExceeded() {
return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
},
- cantUploadLabel() {
- if (this.isQuotaExceeded) {
- return t('files', 'Your have used your space quota and cannot upload files anymore')
- }
- return t('files', 'You don’t have permission to upload or create files here')
- },
/**
* Check if current folder has share permissions
*/
canShare() {
- return isSharingEnabled
+ 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
@@ -464,15 +536,16 @@ export default defineComponent({
logger.debug('View changed', { newView, oldView })
this.selectionStore.reset()
- this.triggerResetSearch()
this.fetchContent()
},
- dir(newDir, oldDir) {
+ directory(newDir, oldDir) {
logger.debug('Directory changed', { newDir, oldDir })
// TODO: preserve selection on browsing?
this.selectionStore.reset()
- this.triggerResetSearch()
+ if (window.OCA.Files.Sidebar?.close) {
+ window.OCA.Files.Sidebar.close()
+ }
this.fetchContent()
// Scroll to top, force virtual scroller to re-render
@@ -485,42 +558,73 @@ export default defineComponent({
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()
},
},
- mounted() {
- this.fetchContent()
-
+ async mounted() {
subscribe('files:node:deleted', this.onNodeDeleted)
subscribe('files:node:updated', this.onUpdatedNode)
- subscribe('nextcloud:unified-search:search', this.onSearch)
- subscribe('nextcloud:unified-search:reset', this.onResetSearch)
// reload on settings change
- this.unsubscribeStoreCallback = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
+ 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('nextcloud:unified-search:search', this.onSearch)
- unsubscribe('nextcloud:unified-search:reset', this.onResetSearch)
- this.unsubscribeStoreCallback()
+ unsubscribe('files:config:updated', this.fetchContent)
+ unsubscribe('files:filters:changed', this.filterDirContent)
+ unsubscribe('files:search:updated', this.onUpdateSearch)
},
methods: {
- t,
+ onUpdateSearch({ query, scope }) {
+ if (query && scope !== 'filter') {
+ this.debouncedFetchContent()
+ }
+ },
async fetchContent() {
this.loading = true
- const dir = this.dir
+ this.error = null
+ const dir = this.directory
const currentView = this.currentView
if (!currentView) {
- logger.debug('The current view doesn\'t exists or is not ready.', { 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()
@@ -561,6 +665,7 @@ export default defineComponent({
})
} catch (error) {
logger.error('Error while fetching content', { error })
+ this.error = humanizeWebDAVError(error)
} finally {
this.loading = false
}
@@ -568,16 +673,6 @@ export default defineComponent({
},
/**
- * Get a cached note from the store
- *
- * @param {number} fileId the file id to get
- * @return {Folder|File}
- */
- getNode(fileId) {
- return this.filesStore.getNode(fileId)
- },
-
- /**
* Handle the node deleted event to reset open file
* @param node The deleted node
*/
@@ -585,10 +680,10 @@ export default defineComponent({
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 neeed to keept the current view but move to the parent directory
+ // in this case we need to keep the current view but move to the parent directory
window.OCP.Files.Router.goToRoute(
null,
- { view: this.$route.params.view },
+ { view: this.currentView!.id },
{ dir: this.currentFolder?.dirname ?? '/' },
)
} else {
@@ -675,24 +770,6 @@ export default defineComponent({
}
},
- /**
- * Handle reset search query event
- */
- onResetSearch() {
- // Reset debounced calls to not set the query again
- this.onSearch.clear()
- // Reset filter query
- this.filterText = ''
- },
-
- /**
- * Trigger a reset of the local search (part of unified search)
- * This is usful to reset the search on directory / view change
- */
- triggerResetSearch() {
- emit('nextcloud:unified-search:reset')
- },
-
openSharingSidebar() {
if (!this.currentFolder) {
logger.debug('No current folder found for opening sharing sidebar')
@@ -704,14 +781,64 @@ export default defineComponent({
}
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;
@@ -732,6 +859,11 @@ export default defineComponent({
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
@@ -745,12 +877,29 @@ export default defineComponent({
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 44px;
- width: 44px;
- height: 44px;
+ flex: 0 0 var(--default-clickable-area);
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
}
&__loading-icon {
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index 46360a2357a..7357943ee28 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -10,8 +10,8 @@ import NavigationView from './Navigation.vue'
import { useViewConfigStore } from '../store/viewConfig'
import { Folder, View, getNavigation } from '@nextcloud/files'
-import Vue from 'vue'
-import router from '../router/router'
+import router from '../router/router.ts'
+import RouterService from '../services/RouterService'
const resetNavigation = () => {
const nav = getNavigation()
@@ -28,13 +28,18 @@ const createView = (id: string, name: string, parent?: string) => new View({
parent,
})
-describe('Navigation renders', () => {
- let Navigation: Navigation
+function mockWindow() {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router = new RouterService(router)
+}
- before(() => {
+describe('Navigation renders', () => {
+ before(async () => {
delete window._nc_navigation
- Navigation = getNavigation()
- Vue.prototype.$navigation = Navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
cy.mockInitialState('files', 'storageStats', {
used: 1000 * 1000 * 1000,
@@ -46,6 +51,7 @@ describe('Navigation renders', () => {
it('renders', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -65,8 +71,8 @@ describe('Navigation API', () => {
before(async () => {
delete window._nc_navigation
Navigation = getNavigation()
+ mockWindow()
- Vue.prototype.$navigation = Navigation
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
@@ -158,18 +164,18 @@ describe('Navigation API', () => {
})
describe('Quota rendering', () => {
- let Navigation: Navigation
-
- before(() => {
+ before(async () => {
delete window._nc_navigation
- Navigation = getNavigation()
- Vue.prototype.$navigation = Navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -184,9 +190,11 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: -1,
+ total: 50 * 1024 * 1024 * 1024,
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -203,10 +211,12 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: 5 * 1024 * 1024 * 1024,
+ total: 5 * 1024 * 1024 * 1024,
relative: 20, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -216,18 +226,21 @@ describe('Quota rendering', () => {
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.mockInitialState('files', 'storageStats', {
used: 5 * 1024 * 1024 * 1024,
quota: 1024 * 1024 * 1024,
+ total: 1024 * 1024 * 1024,
relative: 500, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -237,7 +250,8 @@ describe('Quota rendering', () => {
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 b69c6d5f7f2..0f3c3647c6e 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -4,34 +4,21 @@
-->
<template>
<NcAppNavigation data-cy-files-navigation
+ class="files-navigation"
:aria-label="t('files', 'Files')">
- <template #list>
- <NcAppNavigationItem v-for="view in parentViews"
- :key="view.id"
- :allow-collapse="true"
- :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)"
- @update:open="onToggleExpand(view)">
- <!-- Sanitized icon as svg if provided -->
- <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
-
- <!-- Child views if any -->
- <NcAppNavigationItem v-for="child in childViews[view.id]"
- :key="child.id"
- :data-cy-files-navigation-item="child.id"
- :exact-path="true"
- :icon="child.iconClass"
- :name="child.name"
- :to="generateToNavigation(child)">
- <!-- Sanitized icon as svg if provided -->
- <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" />
- </NcAppNavigationItem>
- </NcAppNavigationItem>
+ <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 -->
@@ -41,61 +28,73 @@
<NavigationQuota />
<!-- Files settings modal toggle-->
- <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')"
- :name="t('files', 'Files settings')"
+ <NcAppNavigationItem :name="t('files', 'Files settings')"
data-cy-files-navigation-settings-button
@click.prevent.stop="openSettings">
<IconCog slot="icon" :size="20" />
</NcAppNavigationItem>
</ul>
</template>
-
- <!-- Settings modal-->
- <SettingsModal :open="settingsOpened"
- data-cy-files-navigation-settings
- @close="onSettingsClose" />
</NcAppNavigation>
</template>
<script lang="ts">
import type { View } from '@nextcloud/files'
+import type { ViewConfig } from '../types.ts'
-import { emit } from '@nextcloud/event-bus'
-import { translate as t } from '@nextcloud/l10n'
+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/Cog.vue'
-import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+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.js'
+import logger from '../logger.ts'
+
+const collator = Intl.Collator(
+ [getLanguage(), getCanonicalLocale()],
+ {
+ numeric: true,
+ usage: 'sort',
+ },
+)
export default defineComponent({
name: 'Navigation',
components: {
IconCog,
+ FilesNavigationItem,
+ FilesNavigationSearch,
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
- NcIconSvgWrapper,
+ NcAppNavigationList,
SettingsModal,
},
setup() {
+ const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
return {
currentView,
+ t,
views,
+ filtersStore,
viewConfigStore,
}
},
@@ -114,28 +113,20 @@ export default defineComponent({
return this.$route?.params?.view || 'files'
},
- parentViews(): View[] {
- return this.views
- // filter child views
- .filter(view => !view.parent)
- // sort views by order
- .sort((a, b) => {
- return a.order - b.order
- })
- },
-
- childViews(): Record<string, View[]> {
+ /**
+ * 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[]>)
},
},
@@ -145,13 +136,18 @@ export default defineComponent({
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 the new view as active
+ // The new view as active
this.showView(view)
logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view })
}
},
},
+ created() {
+ subscribe('files:folder-tree:initialized', this.loadExpandedViews)
+ subscribe('files:folder-tree:expanded', this.loadExpandedViews)
+ },
+
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)!
@@ -160,16 +156,16 @@ export default defineComponent({
},
methods: {
- t,
-
- /**
- * 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.childViews[view.id]?.length > 0
+ 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)
+ }
},
/**
@@ -179,47 +175,11 @@ export default defineComponent({
showView(view: View) {
// Closing any opened sidebar
window.OCA?.Files?.Sidebar?.close?.()
- this.$navigation.setActive(view)
+ getNavigation().setActive(view)
emit('files:navigation:changed', view)
},
/**
- * Expand/collapse a a view with children and permanently
- * save this setting in the server.
- * @param view View to toggle
- */
- onToggleExpand(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)
- },
-
- /**
- * 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
- },
-
- /**
- * 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 } }
- },
-
- /**
* Open the settings modal
*/
openSettings() {
@@ -237,19 +197,15 @@ export default defineComponent({
</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::v-deep .app-navigation-entry.active .button-vue.icon-collapse:not(:hover) {
- color: var(--color-primary-element-text);
-}
-
-.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 {
@@ -259,4 +215,14 @@ export default defineComponent({
// 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
index 41b5fe73048..9db346ea35d 100644
--- a/apps/files/src/views/ReferenceFileWidget.vue
+++ b/apps/files/src/views/ReferenceFileWidget.vue
@@ -256,7 +256,7 @@ export default defineComponent({
min-width: 88px;
max-width: 88px;
padding: 12px;
- padding-right: 0;
+ padding-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
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 f71a5bc0f7c..bfac8e0b3d6 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -8,7 +8,27 @@
:name="t('files', 'Files settings')"
@update:open="onClose">
<!-- Settings API-->
- <NcAppSettingsSection id="settings" :name="t('files', 'Files settings')">
+ <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)">
@@ -19,22 +39,35 @@
@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 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>
- <NcCheckboxRadioSwitch v-if="enableGridView"
- data-cy-files-settings-setting="grid_view"
- :checked="userConfig.grid_view"
- @update:checked="setConfig('grid_view', $event)">
- {{ t('files', 'Enable the grid view') }}
- </NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<!-- Settings API-->
@@ -52,8 +85,9 @@
: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()"
@@ -67,33 +101,205 @@
:href="webdavDocs"
target="_blank"
rel="noreferrer noopener">
- {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗
+ {{ t('files', 'How to access files using WebDAV') }} ↗
</a>
</em>
<br>
- <em>
+ <em v-if="isTwoFactorEnabled">
<a class="setting-link" :href="appPasswordUrl">
- {{ t('files', 'If you have enabled 2FA, you must create and use a new app password by clicking here.') }} ↗
+ {{ 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.js'
-import Setting from '../components/Setting.vue'
-
-import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
+import { getCapabilities } from '@nextcloud/capabilities'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+
+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',
@@ -115,8 +321,11 @@ export default {
setup() {
const userConfigStore = useUserConfigStore()
+ const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true
return {
+ isSystemtagsEnabled,
userConfigStore,
+ t,
}
},
@@ -131,6 +340,7 @@ export default {
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)),
}
},
@@ -138,6 +348,24 @@ export default {
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() {
@@ -170,19 +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 1facff4642d..40a16d42b42 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -17,12 +17,19 @@
@closing="handleClosing"
@closed="handleClosed">
<template v-if="fileInfo" #subname>
- <NcIconSvgWrapper v-if="fileInfo.isFavourited"
- :path="mdiStar"
- :name="t('files', 'Favorite')"
- inline />
- {{ size }}
- <NcDateTime :timestamp="fileInfo.mtime" />
+ <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? -->
@@ -30,8 +37,8 @@
<div class="sidebar__description">
<SystemTags v-if="isSystemTagsEnabled && showTagsDefault"
v-show="showTags"
- :file-id="fileInfo.id"
- @has-tags="value => showTags = value" />
+ :disabled="!fileInfo?.canEdit()"
+ :file-id="fileInfo.id" />
<LegacyView v-for="view in views"
:key="view.cid"
:component="view"
@@ -85,32 +92,35 @@
</template>
</NcAppSidebar>
</template>
-<script>
-import { getCurrentUser } from '@nextcloud/auth'
-import { getCapabilities } from '@nextcloud/capabilities'
-import { showError } from '@nextcloud/dialogs'
+<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 { File, Folder, formatFileSize } from '@nextcloud/files'
import { encodePath } from '@nextcloud/paths'
-import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
-import { Type as ShareTypes } from '@nextcloud/sharing'
+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 axios from '@nextcloud/axios'
+import { ShareType } from '@nextcloud/sharing'
+import { showError } from '@nextcloud/dialogs'
import $ from 'jquery'
+import axios from '@nextcloud/axios'
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+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.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.js'
+import logger from '../logger.ts'
-export default {
+export default defineComponent({
name: 'Sidebar',
components: {
@@ -122,6 +132,7 @@ export default {
NcIconSvgWrapper,
SidebarTab,
SystemTags,
+ NcUserBubble,
},
setup() {
@@ -145,6 +156,7 @@ export default {
error: null,
loading: true,
fileInfo: null,
+ node: null,
isFullScreen: false,
hasLowHeight: false,
}
@@ -186,8 +198,7 @@ export default {
* @return {string}
*/
davPath() {
- const user = this.currentUser.uid
- return generateRemoteUrl(`dav/files/${user}${encodePath(this.file)}`)
+ return `${davRemoteURL}${davRootPath}${encodePath(this.file)}`
},
/**
@@ -234,8 +245,8 @@ export default {
},
compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen,
loading: this.loading,
- name: this.fileInfo.name,
- title: this.fileInfo.name,
+ name: this.node?.displayname ?? this.fileInfo.name,
+ title: this.node?.displayname ?? this.fileInfo.name,
}
} else if (this.error) {
return {
@@ -287,6 +298,25 @@ export default {
isSystemTagsEnabled() {
return getCapabilities()?.systemtags?.enabled === true
},
+ ownerId() {
+ return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid
+ },
+ currentUserIsOwner() {
+ return this.ownerId === this.currentUser.uid
+ },
+ nodeOwnerLabel() {
+ let ownerDisplayName = this.node?.attributes?.['owner-display-name']
+ if (this.currentUserIsOwner) {
+ ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})`
+ }
+ return ownerDisplayName
+ },
+ sharedMultipleTimes() {
+ if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
+ return t('files', 'Shared multiple times with different people')
+ }
+ return null
+ },
},
created() {
subscribe('files:node:deleted', this.onNodeDeleted)
@@ -345,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) {
@@ -374,10 +404,10 @@ export default {
},
/**
- * 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 {
@@ -400,17 +430,21 @@ export default {
*/
const isDir = this.fileInfo.type === 'dir'
const Node = isDir ? Folder : File
- emit(state ? 'files:favorites:added' : 'files:favorites:removed', new Node({
+ const node = new Node({
fileid: this.fileInfo.id,
- source: this.davPath,
- root: `/files/${getCurrentUser().uid}`,
+ 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) {
- showError(t('files', 'Unable to change the favourite state of the file'))
- logger.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 })
}
},
@@ -430,7 +464,10 @@ export default {
* Toggle the tags selector
*/
toggleTags() {
- this.showTagsDefault = this.showTags = !this.showTags
+ // toggle
+ this.showTags = !this.showTags
+ // save the new state
+ this.setShowTagsDefault(this.showTags)
},
/**
@@ -457,7 +494,8 @@ export default {
this.loading = true
try {
- this.fileInfo = await FileInfo(this.davPath)
+ 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('/')
@@ -475,7 +513,7 @@ export default {
await this.$nextTick()
- if (focusTabAfterLoad) {
+ if (focusTabAfterLoad && this.$refs.sidebar) {
this.$refs.sidebar.focusActiveTabContent()
}
} catch (error) {
@@ -550,7 +588,7 @@ export default {
this.hasLowHeight = document.documentElement.clientHeight < 1024
},
},
-}
+})
</script>
<style lang="scss" scoped>
.app-sidebar {
@@ -581,7 +619,7 @@ export default {
}
.svg-icon {
- ::v-deep svg {
+ :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
@@ -589,10 +627,25 @@ export default {
}
}
-.sidebar__description {
- display: flex;
- flex-direction: column;
- width: 100%;
- gap: 8px 0;
+.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 f2e2e29e4b5..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -17,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"
@@ -25,6 +27,7 @@
v-bind="template"
:checked="checked === template.fileid"
:ratio="provider.ratio"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
</ul>
@@ -47,19 +50,20 @@
import type { TemplateFile } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
-import { showError } from '@nextcloud/dialogs'
+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 } from '../services/Templates.js'
+import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcModal from '@nextcloud/vue/components/NcModal'
import TemplatePreview from '../components/TemplatePreview.vue'
-import logger from '../logger.js'
+import TemplateFiller from '../components/TemplateFiller.vue'
+import logger from '../logger.ts'
const border = 2
const margin = 8
@@ -178,6 +182,11 @@ export default defineComponent({
// Else, open the picker
this.opened = true
+
+ // Set initial focus to the empty template preview
+ this.$nextTick(() => {
+ this.$refs.emptyTemplatePreview?.focus()
+ })
},
/**
@@ -200,8 +209,13 @@ export default defineComponent({
this.checked = fileid
},
- async onSubmit() {
- this.loading = true
+ 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
@@ -215,6 +229,7 @@ export default defineComponent({
normalize(`${currentDirectory}/${this.name}`),
this.selectedTemplate?.filename as string ?? '',
this.selectedTemplate?.templateType as string ?? '',
+ templateFields,
)
logger.debug('Created new file', fileInfo)
@@ -257,6 +272,27 @@ export default defineComponent({
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>
@@ -294,7 +330,7 @@ export default defineComponent({
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;
@@ -302,14 +338,14 @@ export default defineComponent({
}
// 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
index dfced698cf1..f793eb9f54c 100644
--- a/apps/files/src/views/favorites.spec.ts
+++ b/apps/files/src/views/favorites.spec.ts
@@ -3,22 +3,27 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { basename } from 'path'
-import { expect } from '@jest/globals'
-import { Folder, Navigation, getNavigation } from '@nextcloud/files'
+
+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 eventBus, { emit } from '@nextcloud/event-bus'
-import * as initialState from '@nextcloud/initial-state'
+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'
+import { registerFavoritesView } from './favorites'
-jest.mock('webdav/dist/node/request.js', () => ({
- request: jest.fn(),
-}))
+// eslint-disable-next-line import/namespace
+const { Folder, getNavigation } = filesUtils
-global.window.OC = {
+vi.mock('@nextcloud/axios')
+
+window.OC = {
+ ...window.OC,
TAG_FAVORITE: '_$!<Favorite>!$_',
}
@@ -31,19 +36,19 @@ declare global {
describe('Favorites view definition', () => {
let Navigation
beforeEach(() => {
- Navigation = getNavigation()
- expect(window._nc_navigation).toBeDefined()
- })
+ vi.resetAllMocks()
- afterEach(() => {
delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
})
- test('Default empty favorite view', () => {
- jest.spyOn(eventBus, 'subscribe')
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ 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: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
const favoritesView = Navigation.views.find(view => view.id === 'favorites')
const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
@@ -60,40 +65,64 @@ describe('Favorites view definition', () => {
expect(favoritesView?.id).toBe('favorites')
expect(favoritesView?.name).toBe('Favorites')
expect(favoritesView?.caption).toBeDefined()
- expect(favoritesView?.icon).toBe('<svg>SvgMock</svg>')
+ expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/)
expect(favoritesView?.order).toBe(15)
expect(favoritesView?.columns).toStrictEqual([])
expect(favoritesView?.getContents).toBeDefined()
})
- test('Default with favorites', () => {
+ test('Default with favorites', async () => {
const favoriteFolders = [
- { fileid: 1, path: '/foo' },
- { fileid: 2, path: '/bar' },
- { fileid: 3, path: '/foo/bar' },
+ 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',
+ }),
]
- jest.spyOn(initialState, 'loadState').mockReturnValue(favoriteFolders)
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
- registerFavoritesView()
+ 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(4)
+ expect(Navigation.views.length).toBe(5)
expect(favoritesView).toBeDefined()
- expect(favoriteFoldersViews.length).toBe(3)
+ 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).toBe('<svg>SvgMock</svg>')
- expect(favoriteView?.order).toBe(index)
+ expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoriteView?.order).toBe(expectedOrder[index])
expect(favoriteView?.params).toStrictEqual({
dir: folder.path,
- fileid: folder.fileid.toString(),
+ fileid: String(folder.fileid),
view: 'favorites',
})
expect(favoriteView?.parent).toBe('favorites')
@@ -103,22 +132,21 @@ describe('Favorites view definition', () => {
})
})
-describe('Dynamic update of favourite folders', () => {
+describe('Dynamic update of favorite folders', () => {
let Navigation
beforeEach(() => {
- Navigation = getNavigation()
- })
+ vi.restoreAllMocks()
- afterEach(() => {
delete window._nc_navigation
+ Navigation = getNavigation()
})
test('Add a favorite folder creates a new entry in the navigation', async () => {
- jest.spyOn(eventBus, 'emit')
- jest.spyOn(initialState, 'loadState').mockReturnValue([])
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
const favoritesView = Navigation.views.find(view => view.id === 'favorites')
const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
@@ -130,7 +158,7 @@ describe('Dynamic update of favourite folders', () => {
// Create new folder to favorite
const folder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
})
@@ -142,12 +170,18 @@ describe('Dynamic update of favourite folders', () => {
})
test('Remove a favorite folder remove the entry from the navigation column', async () => {
- jest.spyOn(eventBus, 'emit')
- jest.spyOn(eventBus, 'subscribe')
- jest.spyOn(initialState, 'loadState').mockReturnValue([{ fileid: 42, path: '/Foo/Bar' }])
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
-
- registerFavoritesView()
+ 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')
@@ -159,7 +193,7 @@ describe('Dynamic update of favourite folders', () => {
// Create new folder to favorite
const folder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
attributes: {
@@ -167,11 +201,15 @@ describe('Dynamic update of favourite folders', () => {
},
})
+ 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')
@@ -183,11 +221,11 @@ describe('Dynamic update of favourite folders', () => {
})
test('Renaming a favorite folder updates the navigation', async () => {
- jest.spyOn(eventBus, 'emit')
- jest.spyOn(initialState, 'loadState').mockReturnValue([])
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
const favoritesView = Navigation.views.find(view => view.id === 'favorites')
const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
@@ -201,7 +239,7 @@ describe('Dynamic update of favourite folders', () => {
// Create new folder to favorite
const folder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
})
@@ -212,12 +250,12 @@ describe('Dynamic update of favourite folders', () => {
// Create a folder with the same id but renamed
const renamedFolder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar.renamed',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed',
owner: 'admin',
})
// Exec the rename action
- emit('files:node:renamed', renamedFolder)
+ 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
index e09587a0d5d..cac776507ef 100644
--- a/apps/files/src/views/favorites.ts
+++ b/apps/files/src/views/favorites.ts
@@ -4,34 +4,30 @@
*/
import type { Folder, Node } from '@nextcloud/files'
-import { subscribe } from '@nextcloud/event-bus'
import { FileType, View, getNavigation } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
-import { getLanguage, translate as t } from '@nextcloud/l10n'
-import { basename } from 'path'
+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.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'
-// The return type of the initial state
-interface IFavoriteFolder {
- fileid: number
- path: string
-}
-
-export const generateFavoriteFolderView = function(folder: IFavoriteFolder, index = 0): View {
+const generateFavoriteFolderView = function(folder: Folder, index = 0): View {
return new View({
id: generateIdFromPath(folder.path),
- name: basename(folder.path),
+ name: folder.displayname,
icon: FolderSvg,
order: index,
+
params: {
dir: folder.path,
- fileid: folder.fileid.toString(),
+ fileid: String(folder.fileid),
view: 'favorites',
},
@@ -43,21 +39,16 @@ export const generateFavoriteFolderView = function(folder: IFavoriteFolder, inde
})
}
-export const generateIdFromPath = function(path: string): string {
+const generateIdFromPath = function(path: string): string {
return `favorite-${hashCode(path)}`
}
-export default () => {
- // Load state in function for mock testing purposes
- const favoriteFolders = loadState<IFavoriteFolder[]>('files', 'favoriteFolders', [])
- const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
- logger.debug('Generating favorites view', { favoriteFolders })
-
+export const registerFavoritesView = async () => {
const Navigation = getNavigation()
Navigation.register(new View({
id: 'favorites',
name: t('files', 'Favorites'),
- caption: t('files', 'List of favorites files and folders.'),
+ 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'),
@@ -70,10 +61,13 @@ export default () => {
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 favourites navigation when a new folder is added
+ * Update favorites navigation when a new folder is added
*/
subscribe('files:favorites:added', (node: Node) => {
if (node.type !== FileType.Folder) {
@@ -90,7 +84,7 @@ export default () => {
})
/**
- * Remove favourites navigation when a folder is removed
+ * Remove favorites navigation when a folder is removed
*/
subscribe('files:favorites:removed', (node: Node) => {
if (node.type !== FileType.Folder) {
@@ -107,7 +101,7 @@ export default () => {
})
/**
- * Update favourites navigation when a folder is renamed
+ * Update favorites navigation when a folder is renamed
*/
subscribe('files:node:renamed', (node: Node) => {
if (node.type !== FileType.Folder) {
@@ -126,7 +120,7 @@ export default () => {
* update the order property of the existing views
*/
const updateAndSortViews = function() {
- favoriteFolders.sort((a, b) => a.path.localeCompare(b.path, getLanguage(), { ignorePunctuation: true }))
+ 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) {
@@ -137,8 +131,7 @@ export default () => {
// Add a folder to the favorites paths array and update the views
const addToFavorites = function(node: Folder) {
- const newFavoriteFolder: IFavoriteFolder = { path: node.path, fileid: node.fileid! }
- const view = generateFavoriteFolderView(newFavoriteFolder)
+ const view = generateFavoriteFolderView(node)
// Skip if already exists
if (favoriteFolders.find((folder) => folder.path === node.path)) {
@@ -146,7 +139,7 @@ export default () => {
}
// Update arrays
- favoriteFolders.push(newFavoriteFolder)
+ favoriteFolders.push(node)
favoriteFoldersViews.push(view)
// Update and sort views
@@ -185,4 +178,6 @@ export default () => {
removePathFromFavorites(favoriteFolder.path)
addToFavorites(node)
}
+
+ updateAndSortViews()
}
diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts
index a49a13f91e1..a94aab0f14b 100644
--- a/apps/files/src/views/files.ts
+++ b/apps/files/src/views/files.ts
@@ -2,22 +2,64 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
-import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
-import { getContents } from '../services/Files'
+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 = ''
-export default () => {
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'files',
+ id: VIEW_ID,
name: t('files', 'All files'),
caption: t('files', 'List of your files and folders.'),
icon: FolderSvg,
- order: 0,
+ // 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
index ce175d7c5ca..241582057d1 100644
--- a/apps/files/src/views/personal-files.ts
+++ b/apps/files/src/views/personal-files.ts
@@ -2,24 +2,36 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
+
+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'
-import { getContents } from '../services/PersonalFiles'
-import AccountIcon from '@mdi/svg/svg/account.svg?raw'
+/**
+ * Register the personal files view if allowed
+ */
+export function registerPersonalFilesView(): void {
+ if (!hasPersonalFilesView()) {
+ return
+ }
-export default () => {
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'personal',
- name: t('files', 'Personal Files'),
+ 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,
- order: 5,
+ // 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/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,
+ }))
+}
diff --git a/apps/files/src/vue.d.ts b/apps/files/src/vue.d.ts
deleted file mode 100644
index fc8714d418b..00000000000
--- a/apps/files/src/vue.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import type { Navigation } from '@nextcloud/files'
-
-declare module 'vue/types/vue' {
- interface Vue {
- $navigation: Navigation
- }
-}