diff options
Diffstat (limited to 'apps/files/src')
152 files changed, 9384 insertions, 2644 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 86e1812a9d1..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 eventBus from '@nextcloud/event-bus' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + import axios from '@nextcloud/axios' +import * as capabilities from '@nextcloud/capabilities' +import * as eventBus from '@nextcloud/event-bus' +import { action } from './deleteAction' import logger from '../logger' +import { shouldAskForConfirmation } from './deleteUtils' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') +vi.mock('@nextcloud/capabilities') const view = { id: 'files', @@ -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 23106a5d15f..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 logger from '../logger.js' -import PQueue from 'p-queue' +import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw' -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') - } +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' - return t('files', 'Delete') -} +const queue = new PQueue({ concurrency: 5 }) -const queue = new PQueue({ concurrency: 1 }) +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 - // 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) + // Trick to detect if the action was called from a keyboard event + // we need to make sure the method calling have its named containing 'keydown' + // here we use `onKeydown` method from the FileEntryActions component + const callStack = new Error().stack || '' + const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown') + + if (shouldAskForConfirmation() || isCalledFromEventListener) { + confirm = await askConfirmation([node], view) + } + + // If the user cancels the deletion, we don't want to do anything + if (confirm === false) { + return null + } + + await deleteNode(node) return true } catch (error) { @@ -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 39b82ff82d6..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 eventBus from '@nextcloud/event-bus' -import * as favoriteAction from './favoriteAction' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { action } from './favoriteAction' import axios from '@nextcloud/axios' +import * as eventBus from '@nextcloud/event-bus' +import * as favoriteAction from './favoriteAction' import logger from '../logger' +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') + const view = { id: 'files', name: 'Files', @@ -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 dc24ee708e5..06e32c98090 100644 --- a/apps/files/src/actions/moveOrCopyAction.ts +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -4,26 +4,24 @@ */ 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' -// eslint-disable-next-line n/no-extraneous-import -import { AxiosError } from 'axios' -import { basename, join } from 'path' +import { isAxiosError } from '@nextcloud/axios' +import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs' -import { Permission, FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind } from '@nextcloud/files' +import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { openConflictPicker, hasConflict } from '@nextcloud/upload' +import { basename, join } from 'path' import Vue from 'vue' -import CopyIconSvg from '@mdi/svg/svg/folder-multiple.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' import logger from '../logger' -import { getUniqueName } from '../utils/fileUtils' /** * Return the action that is possible for the given nodes @@ -43,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 @@ -82,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 () => { @@ -125,38 +146,47 @@ 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) } } catch (error) { - if (error instanceof AxiosError) { - if (error?.response?.status === 412) { + if (isAxiosError(error)) { + if (error.response?.status === 412) { throw new Error(t('files', 'A file or folder with that name already exists in this folder')) - } else if (error?.response?.status === 423) { + } else if (error.response?.status === 423) { throw new Error(t('files', 'The files are locked')) - } else if (error?.response?.status === 404) { + } else if (error.response?.status === 404) { throw new Error(t('files', 'The file does not exist anymore')) } else if (error.message) { throw new Error(error.message) @@ -165,34 +195,36 @@ 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) .setFilter((n: Node) => { - // We only want to show folders that we can create nodes in - return (n.permissions & Permission.CREATE) !== 0 - // We don't want to show the current nodes in the file picker - && !fileIDs.includes(n.fileid) + // We don't want to show the current nodes in the file picker + return !fileIDs.includes(n.fileid) }) .setMimeTypeFilter([]) .setMultiSelect(false) .startAt(dir) - - return new Promise((resolve, reject) => { - filePicker.setButtonFactory((_selection, path: string) => { + .setButtonFactory((selection: Node[], path: string) => { const buttons: IFilePickerButton[] = [] const target = basename(path) @@ -204,6 +236,7 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes: label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'), type: 'primary', icon: CopyIconSvg, + disabled: selection.some((node) => (node.permissions & Permission.CREATE) === 0), async callback(destination: Node[]) { resolve({ destination: destination[0] as Folder, @@ -224,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'), @@ -240,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: @@ -266,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 @@ -283,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 @@ -299,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 511fd32d134..0372e8f4bc7 100644 --- a/apps/files/src/actions/moveOrCopyActionUtils.ts +++ b/apps/files/src/actions/moveOrCopyActionUtils.ts @@ -4,28 +4,31 @@ */ import type { Folder, Node } from '@nextcloud/files' +import type { ShareAttribute } from '../../../files_sharing/src/sharing' + import { Permission } from '@nextcloud/files' +import { isPublicShare } from '@nextcloud/sharing/public' import PQueue from 'p-queue' +import { loadState } from '@nextcloud/initial-state' + +const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE) // This is the processing queue. We only want to allow 3 concurrent requests let queue: PQueue +// Maximum number of concurrent operations +const MAX_CONCURRENCY = 5 + /** * Get the processing queue */ export const getQueue = () => { if (!queue) { - queue = new PQueue({ concurrency: 3 }) + queue = new PQueue({ concurrency: MAX_CONCURRENCY }) } return queue } -type ShareAttribute = { - enabled: boolean - key: string - scope: string -} - export enum MoveCopyAction { MOVE = 'Move', COPY = 'Copy', @@ -39,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 f6de2fdf6b2..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 88f592a4cdf..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)" @@ -35,26 +35,27 @@ <script lang="ts"> import type { Node } from '@nextcloud/files' +import type { FileSource } from '../types.ts' import { basename } from 'path' import { defineComponent } from 'vue' import { Permission } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import HomeSvg from '@mdi/svg/svg/home.svg?raw' -import NcBreadcrumb from '@nextcloud/vue/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 { 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' -import type { FileSource } from '../types.ts' export default defineComponent({ name: 'BreadCrumbs', @@ -65,10 +66,6 @@ export default defineComponent({ NcIconSvgWrapper, }, - mixins: [ - filesListWidthMixin, - ], - props: { path: { type: String, @@ -82,6 +79,8 @@ export default defineComponent({ const pathsStore = usePathsStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() + const fileListWidth = useFileListWidth() + const { currentView, views } = useNavigation() return { draggingStore, @@ -89,14 +88,14 @@ export default defineComponent({ pathsStore, selectionStore, uploaderStore, + + currentView, + fileListWidth, + views, } }, computed: { - currentView() { - return this.$navigation.active - }, - dirs(): string[] { const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`) // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc @@ -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 @@ -150,17 +148,40 @@ export default defineComponent({ getNodeFromSource(source: FileSource): Node | undefined { return this.filesStore.getNode(source) }, - getFileSourceFromPath(path: string): FileSource | undefined { - return this.pathsStore.getPath(this.currentView?.id, path) + getFileSourceFromPath(path: string): FileSource | null { + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null }, getDirDisplayName(path: string): string { if (path === '/') { - return this.$navigation?.active?.name || t('files', 'Home') + return this.currentView?.name || t('files', 'Home') } - const source: FileSource | undefined = 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) { @@ -170,6 +191,10 @@ export default defineComponent({ }, onDragOver(event: DragEvent, path: string) { + if (!event.dataTransfer) { + return + } + // Cannot drop on the current directory if (path === this.dirs[this.dirs.length - 1]) { event.dataTransfer.dropEffect = 'none' @@ -269,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 2f013927640..c7684d5c205 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -26,16 +26,21 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue' -import { Folder, Permission } from '@nextcloud/files' +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 logger from '../logger.js' +import { useNavigation } from '../composables/useNavigation' import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import logger from '../logger.ts' +import type { RawLocation } from 'vue-router' export default defineComponent({ name: 'DragAndDropNotice', @@ -46,11 +51,19 @@ export default defineComponent({ props: { currentFolder: { - type: Folder, + type: Object as PropType<Folder>, required: true, }, }, + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + data() { return { dragover: false, @@ -58,10 +71,6 @@ export default defineComponent({ }, computed: { - currentView() { - return this.$navigation.active - }, - /** * Check if the current folder has create permissions */ @@ -76,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) @@ -106,6 +126,7 @@ export default defineComponent({ if (isForeignFile) { // Only handle uploading of outside files (not Nextcloud files) this.dragover = true + this.resetDragOver() } }, @@ -120,6 +141,7 @@ export default defineComponent({ if (this.dragover) { this.dragover = false + this.resetDragOver.clear() } }, @@ -128,6 +150,7 @@ export default defineComponent({ event.preventDefault() if (this.dragover) { this.dragover = false + this.resetDragOver.clear() } }, @@ -180,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, @@ -204,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); @@ -212,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 0ef6bed9abc..72fd98d43fb 100644 --- a/apps/files/src/components/DragAndDropPreview.vue +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -62,7 +62,7 @@ export default Vue.extend({ summary(): string { if (this.isSingleNode) { const node = this.nodes[0] - return node.attributes?.displayName || node.basename + return node.attributes?.displayname || node.basename } return getSummaryFor(this.nodes) @@ -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 0ee04739282..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -16,7 +16,7 @@ class="files-list__row" v-on="rowListeners"> <!-- Failed indicator --> - <span v-if="source.attributes.failed" class="files-list__row--failed" /> + <span v-if="isFailedSource" class="files-list__row--failed" /> <!-- Checkbox --> <FileEntryCheckbox :fileid="fileid" @@ -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,21 +94,25 @@ </template> <script lang="ts"> +import { FileType, formatFileSize } from '@nextcloud/files' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' -import { Permission, 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.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' @@ -120,7 +133,7 @@ export default defineComponent({ ], props: { - isMtimeAvailable: { + isMimeAvailable: { type: Boolean, default: false, }, @@ -128,10 +141,6 @@ export default defineComponent({ type: Boolean, default: false, }, - compact: { - type: Boolean, - default: false, - }, }, setup() { @@ -140,12 +149,25 @@ export default defineComponent({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + const filesListWidth = useFileListWidth() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, + + currentDir, + currentFileId, + currentView, + filesListWidth, } }, @@ -175,56 +197,80 @@ 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 = parseInt(this.source.size, 10) - if (typeof size !== 'number' || isNaN(size) || size < 0) { + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { return this.t('files', 'Pending') } return formatFileSize(size, true) }, + sizeOpacity() { const maxOpacitySize = 10 * 1024 * 1024 - const size = parseInt(this.source.size, 10) - if (!size || isNaN(size) || size < 0) { + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { return {} } - const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2))) + const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2))) return { color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, } }, - mtimeOpacity() { - const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days - - const mtime = this.source.mtime?.getTime?.() - if (!mtime) { - return {} - } + }, - // 1 = today, 0 = 31 days ago - const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime)) - if (ratio < 0) { - return {} - } - return { - 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 21d5cd9e796..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) }} @@ -77,24 +114,28 @@ <script lang="ts"> import type { PropType } from 'vue' +import type { FileAction, Node } from '@nextcloud/files' -import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' +import { DefaultType, NodeStatus } from '@nextcloud/files' +import { defineComponent, inject } from 'vue' +import { t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -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 ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' -import Vue, { defineComponent } from 'vue' - import CustomElementRender from '../CustomElementRender.vue' -import logger from '../../logger.js' - -// The registered actions list -const actions = getFileActions() +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { executeAction } from '../../utils/actionUtils.ts' +import { useActiveStore } from '../../store/active.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import actionsMixins from '../../mixins/actionsMixin.ts' +import logger from '../../logger.ts' export default defineComponent({ name: 'FileEntryActions', @@ -109,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, @@ -132,41 +167,46 @@ export default defineComponent({ }, }, - data() { + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory: currentDir } = useRouteParameters() + + const activeStore = useActiveStore() + const filesListWidth = useFileListWidth() + const enabledFileActions = inject<FileAction[]>('enabledFileActions', []) return { - 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') - }, - currentView(): View { - return this.$navigation.active as View + 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.attributes.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 @@ -174,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 @@ -194,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) @@ -207,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: { @@ -236,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) - Vue.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', '') - Vue.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> @@ -348,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); } - .button-vue__icon { - color: var(--color-primary-element); + + // Some icons exceed the default size so we need to enforce a max width and height + .files-list__row-action-icon :deep(svg) { + max-height: var(--max-icon-size) !important; + max-width: var(--max-icon-size) !important; + } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } } } + </style> diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue 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 3e671c0c141..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, View } 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 from '@nextcloud/axios' -import { isAxiosError } from 'axios' -import Vue, { 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 { 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,17 +91,27 @@ export default defineComponent({ }, setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') + return { + currentView, + defaultFileAction, + directory, + filesListWidth, + renamingStore, + userConfigStore, } }, computed: { - currentView(): View { - return this.$navigation.active as View - }, - isRenaming() { return this.renamingStore.renamingNode === this.source }, @@ -109,24 +119,24 @@ 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] }, linkTo() { - if (this.source.attributes.failed) { + if (this.source.status === NodeStatus.FAILED) { return { is: 'span', params: { @@ -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 - Vue.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 { - Vue.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 8238c1a53d1..1bd0572f53b 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -17,7 +17,7 @@ @dragend="onDragEnd" @drop="onDrop"> <!-- Failed indicator --> - <span v-if="source.attributes.failed" class="files-list__row--failed" /> + <span v-if="isFailedSource" class="files-list__row--failed" /> <!-- Checkbox --> <FileEntryCheckbox :fileid="fileid" @@ -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" @@ -46,12 +45,21 @@ @click.native="execDefaultAction" /> </td> + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + </td> + <!-- Actions --> <FileEntryActions ref="actions" :class="`files-list__row-actions-${uniqueId}`" - :files-list-width="filesListWidth" :grid-mode="true" - :loading.sync="loading" :opened.sync="openedMenu" :source="source" /> </tr> @@ -60,6 +68,10 @@ <script lang="ts"> import { defineComponent } from 'vue' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -79,6 +91,7 @@ export default defineComponent({ FileEntryCheckbox, FileEntryName, FileEntryPreview, + NcDateTime, }, mixins: [ @@ -93,12 +106,23 @@ export default defineComponent({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, + + currentDir, + currentFileId, + currentView, } }, diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index c56c10de75a..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: { @@ -37,52 +40,69 @@ export default defineComponent({ type: Number, default: 0, }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + compact: { + type: Boolean, + default: false, + }, + }, + + provide() { + return { + defaultFileAction: computed(() => this.defaultFileAction), + enabledFileActions: computed(() => this.enabledFileActions), + } }, data() { return { - loading: '', dragover: false, gridMode: false, } }, computed: { - currentView(): View { - return this.$navigation.active as View - }, - - 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 + 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() { @@ -106,11 +126,23 @@ export default defineComponent({ return String(this.fileid) === String(this.currentFileId) }, - canDrag() { + /** + * Check if the source is in a failed state after an API request + */ + isFailedSource() { + return this.source.status === NodeStatus.FAILED + }, + + canDrag(): boolean { if (this.isRenaming) { return false } + // Ignore if the node is not available + if (this.isFailedSource) { + return false + } + const canDrag = (node: Node): boolean => { return (node?.permissions & Permission.UPDATE) !== 0 } @@ -123,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 @@ -141,21 +178,112 @@ 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 }, }, + + mtime() { + // If the mtime is not a valid date, return it as is + if (this.source.mtime && !isNaN(this.source.mtime.getDate())) { + return this.source.mtime + } + + if (this.source.crtime && !isNaN(this.source.crtime.getDate())) { + return this.source.crtime + } + + return null + }, + + mtimeOpacity() { + if (!this.mtime) { + return {} + } + + // The time when we start reducing the opacity + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + // everything older than the maxOpacityTime will have the same value + const timeDiff = Date.now() - this.mtime.getTime() + if (timeDiff < 0) { + // this means we have an invalid mtime which is in the future! + return {} + } + + // inversed time difference from 0 to maxOpacityTime (which would mean today) + const opacityTime = Math.max(0, maxOpacityTime - timeDiff) + // 100 = today, 0 = 31 days ago or older + const percentage = Math.round(opacityTime * 100 / maxOpacityTime) + return { + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, + } + }, + + /** + * Sorted actions that are enabled for this node + */ + enabledFileActions() { + if (this.source.status === NodeStatus.FAILED) { + return [] + } + + return actions + .filter(action => { + if (!action.enabled) { + return true + } + + // In case something goes wrong, since we don't want to break + // the entire list, we filter out actions that throw an error. + try { + return action.enabled([this.source], this.currentView) + } catch (error) { + logger.error('Error while checking action', { action, error }) + return false + } + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + defaultFileAction() { + return this.enabledFileActions.find((action) => action.default !== undefined) + }, }, watch: { /** * When the source changes, reset the preview * and fetch the new one. + * @param newSource The new value of the source prop + * @param oldSource The previous value */ - source(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() { @@ -164,9 +292,6 @@ export default defineComponent({ methods: { resetState() { - // Reset loading state - this.loading = '' - // Reset the preview state this.$refs?.preview?.reset?.() @@ -181,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) { @@ -189,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 { @@ -207,16 +338,47 @@ export default defineComponent({ event.stopPropagation() }, - execDefaultAction(event) { - // if ctrl+click or middle mouse button, open in new tab - if (event.ctrlKey || event.metaKey || event.button === 1) { + execDefaultAction(event: MouseEvent) { + // Ignore click if we are renaming + if (this.isRenaming) { + return + } + + // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4) + if (Boolean(event.button & 2) || event.button > 4) { + return + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return + } + + // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab + // also if there is no default action use this as a fallback + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 + if (metaKeyPressed || !this.defaultFileAction) { + // If no download permission, then we can not allow to download (direct link) the files + if (isPublicShare() && !isDownloadable(this.source)) { + return + } + + const url = isPublicShare() + ? this.source.encodedSource + : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() - 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) { @@ -325,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 7134c957fb8..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" @@ -54,17 +62,21 @@ </template> <script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + import { translate as t } from '@nextcloud/l10n' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import { defineComponent, type PropType } from 'vue' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import { useFilesStore } from '../store/files.ts' +import { useNavigation } from '../composables/useNavigation' import { useSelectionStore } from '../store/selection.ts' import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import filesSortingMixin from '../mixins/filesSorting.ts' -import logger from '../logger.js' -import type { Node } from '@nextcloud/files' -import type { FileSource } from '../types.ts' +import logger from '../logger.ts' export default defineComponent({ name: 'FilesListTableHeader', @@ -79,6 +91,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, @@ -100,17 +116,17 @@ export default defineComponent({ setup() { const filesStore = useFilesStore() const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + return { filesStore, selectionStore, + + currentView, } }, computed: { - currentView() { - return this.$navigation.active - }, - columns() { // Hide columns if the list is too small if (this.filesListWidth < 512) { @@ -151,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' } @@ -168,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 }) @@ -181,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 12f4642f227..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,90 +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) - - // handle initially opening a given file - const { id } = loadState<{ id?: number }>('files', 'openFileInfo', {}) - 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 (fileId === null || this.openFileId === fileId) { - 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, + ) + }, + + // 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) { @@ -301,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: 44px; - --icon-preview-size: 32px; + --clickable-area: var(--default-clickable-area); + --icon-preview-size: 24px; + --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; + &:has(.file-list-filters__active) { + --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small)); + } + & :deep() { // Table head, body and footer tbody { @@ -348,7 +528,7 @@ export default defineComponent({ } .files-list__selected { - padding-right: 12px; + padding-inline-end: 12px; white-space: nowrap; } @@ -357,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; @@ -375,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, @@ -385,7 +586,6 @@ export default defineComponent({ flex-direction: column; width: 100%; background-color: var(--color-main-background); - } // Table header @@ -393,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 { @@ -406,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); @@ -417,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; @@ -439,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; @@ -504,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 @@ -531,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); @@ -550,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 { @@ -578,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; @@ -605,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; } @@ -625,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; @@ -656,44 +878,56 @@ 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); - --row-width: 160px; - // We use half of the clickable area as visual balance margin - --row-height: calc(var(--row-width) - var(--half-clickable-area)); - --icon-preview-size: calc(var(--row-width) - var(--clickable-area)); +.files-list--grid tbody.files-list__tbody { + --item-padding: 16px; + --icon-preview-size: 166px; + --name-height: var(--default-clickable-area); + --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline)); + --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2); + --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2); --checkbox-padding: 0px; - display: grid; grid-template-columns: repeat(auto-fill, var(--row-width)); - grid-gap: 15px; - row-gap: 15px; align-content: center; align-items: center; @@ -701,29 +935,44 @@ tbody.files-list__tbody.files-list__tbody--grid { justify-items: center; tr { + display: flex; + flex-direction: column; width: var(--row-width); - height: calc(var(--row-height) + var(--clickable-area)); + height: var(--row-height); border: none; - border-radius: var(--border-radius); + border-radius: var(--border-radius-large); + padding: var(--item-padding); } // Checkbox in the top left .files-list__row-checkbox { position: absolute; z-index: 9; - top: 0; - left: 0; + top: calc(var(--item-padding) / 2); + inset-inline-start: calc(var(--item-padding) / 2); overflow: hidden; - width: var(--clickable-area); - height: var(--clickable-area); - border-radius: var(--half-clickable-area); + --checkbox-container-size: 44px; + width: var(--checkbox-container-size); + height: var(--checkbox-container-size); + + // Add a background to the checkbox so we do not see the image through it. + .checkbox-radio-switch__content::after { + content: ''; + width: 16px; + height: 16px; + position: absolute; + inset-inline-start: 50%; + margin-inline-start: -8px; + z-index: -1; + background: var(--color-main-background); + } } // Star icon in the top right .files-list__row-icon-favorite { position: absolute; top: 0; - right: 0; + inset-inline-end: 0; display: flex; align-items: center; justify-content: center; @@ -732,38 +981,55 @@ tbody.files-list__tbody.files-list__tbody--grid { } .files-list__row-name { - display: grid; - justify-content: stretch; - width: 100%; - height: 100%; - grid-auto-rows: var(--row-height) var(--clickable-area); + display: flex; + flex-direction: column; + width: var(--icon-preview-size); + height: calc(var(--icon-preview-size) + var(--name-height)); + // Ensure that the name outline is visible. + overflow: visible; span.files-list__row-icon { - width: 100%; - height: 100%; - // Visual balance, we use half of the clickable area - // as a margin around the preview - padding-top: var(--half-clickable-area); - } - - a.files-list__row-name-link { - // Minus action menu - width: calc(100% - var(--clickable-area)); - height: var(--clickable-area); + width: var(--icon-preview-size); + height: var(--icon-preview-size); } .files-list__row-name-text { margin: 0; - padding-right: 0; + // Ensure that the outline is not too close to the text. + margin-inline-start: -4px; + padding: 0px 4px; } } + .files-list__row-mtime { + width: var(--icon-preview-size); + height: var(--mtime-height); + font-size: var(--font-size-small); + } + .files-list__row-actions { position: absolute; - right: 0; - bottom: 0; + 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 a4477408faf..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' -import { getUniqueName } from '../utils/fileUtils' +<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 22b54e560bf..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 { debounce } from 'debounce' -import Vue from 'vue' +import { defineComponent } from 'vue' +import debounce from 'debounce' -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 - // 138px + 44px (name) + 15px (grid gap) - return this.gridMode ? (138 + 44 + 15) : 55 + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, + // Grid mode only itemWidth() { - // 160px + 15px grid gap - return 160 + 15 + // 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 new file mode 100644 index 00000000000..b9eb671a181 --- /dev/null +++ b/apps/files/src/composables/useNavigation.spec.ts @@ -0,0 +1,106 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Navigation, View } from '@nextcloud/files' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +import { useNavigation } from './useNavigation' +import * as nextcloudFiles from '@nextcloud/files' + +// Just a wrapper so we can test the composable +const TestComponent = defineComponent({ + template: '<div></div>', + setup() { + const { currentView, views } = useNavigation() + return { + currentView, + views, + } + }, +}) + +describe('Composables: useNavigation', () => { + const spy = vi.spyOn(nextcloudFiles, 'getNavigation') + let navigation: Navigation + + describe('currentView', () => { + beforeEach(() => { + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() + spy.mockImplementation(() => navigation) + }) + + it('should return null without active navigation', () => { + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null) + }) + + it('should return already active navigation', async () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + navigation.register(view) + navigation.setActive(view) + // Now the navigation is already set it should take the active navigation + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view) + }) + + it('should be reactive on updating active navigation', async () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + navigation.register(view) + const wrapper = mount(TestComponent) + + // no active navigation + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null) + + navigation.setActive(view) + // Now the navigation is set it should take the active navigation + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view) + }) + }) + + describe('views', () => { + beforeEach(() => { + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() + spy.mockImplementation(() => navigation) + }) + + it('should return empty array without registered views', () => { + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([]) + }) + + it('should return already registered views', () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view]) + }) + + it('should be reactive on registering new views', () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 }) + + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view]) + + // now register view 2 and check it is reactivly added + navigation.register(view2) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view, view2]) + }) + }) +}) diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts new file mode 100644 index 00000000000..2a6f22a1232 --- /dev/null +++ b/apps/files/src/composables/useNavigation.ts @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { View } from '@nextcloud/files' +import type { ShallowRef } from 'vue' + +import { getNavigation } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue' + +/** + * Composable to get the currently active files view from the files navigation + * @param _loaded If set enforce a current view is loaded + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useNavigation<T extends boolean>(_loaded?: T) { + type MaybeView = T extends true ? View : (View | null); + const navigation = getNavigation() + const views: ShallowRef<View[]> = shallowRef(navigation.views) + const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView) + + /** + * Event listener to update the `currentView` + * @param event The update event + */ + function onUpdateActive(event: CustomEvent<View|null>) { + currentView.value = event.detail as MaybeView + } + + /** + * Event listener to update all registered views + */ + function onUpdateViews() { + views.value = navigation.views + triggerRef(views) + } + + onMounted(() => { + navigation.addEventListener('update', onUpdateViews) + navigation.addEventListener('updateActive', onUpdateActive) + subscribe('files:navigation:updated', onUpdateViews) + }) + onUnmounted(() => { + navigation.removeEventListener('update', onUpdateViews) + navigation.removeEventListener('updateActive', onUpdateActive) + }) + + return { + currentView, + views, + } +} diff --git a/apps/files/src/composables/useRouteParameters.ts b/apps/files/src/composables/useRouteParameters.ts new file mode 100644 index 00000000000..dbb8ca7f081 --- /dev/null +++ b/apps/files/src/composables/useRouteParameters.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { computed } from 'vue' +import { useRoute } from 'vue-router/composables' + +/** + * Get information about the current route + */ +export function useRouteParameters() { + + const route = useRoute() + + /** + * Get the path of the current active directory + */ + const directory = computed<string>( + () => String(route.query.dir || '/') + // Remove any trailing slash but leave root slash + .replace(/^(.+)\/$/, '$1'), + ) + + /** + * Get the current fileId used on the route + */ + const fileId = computed<number | null>(() => { + const fileId = Number.parseInt(route.params.fileid ?? '0') || null + return Number.isNaN(fileId) ? null : fileId + }) + + /** + * State of `openFile` route param + */ + const openFile = computed<boolean>( + // if `openfile` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'), + ) + + const openDetails = computed<boolean>( + // if `opendetails` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'opendetails' in route.query && (typeof route.query.opendetails !== 'string' || route.query.opendetails.toLocaleLowerCase() !== 'false'), + ) + + return { + /** Path of currently open directory */ + directory, + + /** Current active fileId */ + fileId, + + /** Should the active node should be opened (`openFile` route param) */ + openFile, + + /** Should the details sidebar be shown (`openDetails` route param) */ + openDetails, + } +} diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index 63dbe4f3158..ab8dbb63dfc 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -2,13 +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 a4c9ccac755..00000000000 --- a/apps/files/src/utils/davUtils.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' - -export const getRootPath = function() { - if (getCurrentUser()) { - return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) - } else { - return generateRemoteUrl('webdav').replace('/remote.php', '/public.php') - } -} - -export const isPublic = function() { - return !getCurrentUser() -} - -export const getToken = function() { - return document.getElementById('sharingToken') && document.getElementById('sharingToken').value -} 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 2427e330352..f0b974be21d 100644 --- a/apps/files/src/utils/fileUtils.ts +++ b/apps/files/src/utils/fileUtils.ts @@ -2,51 +2,16 @@ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { basename, extname } from 'path' import { FileType, type Node } from '@nextcloud/files' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' - -// TODO: move to @nextcloud/files -/** - * Create an unique file name - * @param name The initial name to use - * @param otherNames Other names that are already used - * @param options Optional parameters for tuning the behavior - * @param options.suffix A function that takes an index and returns a suffix to add to the file name, defaults to '(index)' - * @param options.ignoreFileExtension Set to true to ignore the file extension when adding the suffix (when getting a unique directory name) - * @return Either the initial name, if unique, or the name with the suffix so that the name is unique - */ -export const getUniqueName = ( - name: string, - otherNames: string[], - options: { - suffix?: (i: number) => string, - ignoreFileExtension?: boolean, - } = {}, -): string => { - const opts = { - suffix: (n: number) => `(${n})`, - ignoreFileExtension: false, - ...options, - } - - let newName = name - let i = 1 - while (otherNames.includes(newName)) { - const ext = opts.ignoreFileExtension ? '' : extname(name) - const base = basename(name, ext) - newName = `${base} ${opts.suffix(i++)}${ext}` - } - return newName -} +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('/') @@ -55,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/hashUtils.ts b/apps/files/src/utils/hashUtils.ts index 607064947a8..2e1fadff067 100644 --- a/apps/files/src/utils/hashUtils.ts +++ b/apps/files/src/utils/hashUtils.ts @@ -3,6 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +/** + * Simple non-secure hashing function similar to Java's `hashCode` + * @param str The string to hash + * @return {number} a non secure hash of the string + */ export const hashCode = function(str: string): number { let hash = 0 for (let i = 0; i < str.length; i++) { 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 ae20c58ea32..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,36 +17,47 @@ 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" - :content="dirContents" - :destination="currentFolder" - :multiple="true" + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple @failed="onUploadFail" @uploaded="onUpload" /> </template> </BreadCrumbs> - <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" @@ -57,100 +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" - :content="dirContents" - :destination="currentFolder" - class="files-list__header-upload-button" - 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 { View, 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 } from 'path' -import { showError } from '@nextcloud/dialogs' -import { Type } from '@nextcloud/sharing' -import { UploadPicker } from '@nextcloud/upload' +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 @@ -164,23 +216,38 @@ 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() @@ -188,167 +255,142 @@ export default defineComponent({ const viewConfigStore = useViewConfigStore() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, filesStore, + filtersStore, pathsStore, selectionStore, uploaderStore, userConfigStore, viewConfigStore, - enableGridView, // non reactive data - Type, + enableGridView, + forbiddenCharacters, + 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. + * Get a callback function for the uploader to fetch directory contents for conflict resolution */ - onSearch() { - return debounce((searchEvent: { query: string }) => { - console.debug('Files app handling search event from unified search...', searchEvent) - this.filterText = searchEvent.query - }, 500) + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } }, userConfig(): UserConfig { return this.userConfigStore.userConfig }, - currentView(): View { - return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files'))! - }, - pageHeading(): string { - return this.currentView?.name ?? t('files', 'Files') - }, + const title = 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') - }, - - /** - * 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 }, /** @@ -373,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() { @@ -421,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 @@ -445,15 +536,16 @@ export default defineComponent({ logger.debug('View changed', { newView, oldView }) this.selectionStore.reset() - this.resetSearch() this.fetchContent() }, - dir(newDir, oldDir) { + directory(newDir, oldDir) { logger.debug('Directory changed', { newDir, oldDir }) // TODO: preserve selection on browsing? this.selectionStore.reset() - this.resetSearch() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } this.fetchContent() // Scroll to top, force virtual scroller to re-render @@ -466,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.onSearch) // 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.onSearch) - 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() @@ -542,6 +665,7 @@ export default defineComponent({ }) } catch (error) { logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) } finally { this.loading = false } @@ -549,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 */ @@ -566,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 { @@ -590,8 +704,7 @@ export default defineComponent({ onUpload(upload: Upload) { // Let's only refresh the current Folder // Navigating to a different folder will refresh it anyway - const destinationSource = dirname(upload.source) - const needsRefresh = destinationSource === this.currentFolder?.source + const needsRefresh = dirname(upload.source) === this.currentFolder!.source // TODO: fetch uploaded files data only // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid @@ -604,6 +717,11 @@ export default defineComponent({ async onUploadFail(upload: Upload) { const status = upload.response?.status || 0 + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + // Check known status codes if (status === 507) { showError(t('files', 'Not enough free space')) @@ -652,13 +770,6 @@ export default defineComponent({ } }, - /** - * Reset the search query - */ - resetSearch() { - this.filterText = '' - }, - openSharingSidebar() { if (!this.currentFolder) { logger.debug('No current folder found for opening sharing sidebar') @@ -668,16 +779,66 @@ export default defineComponent({ if (window?.OCA?.Files?.Sidebar?.setActiveTab) { window.OCA.Files.Sidebar.setActiveTab('sharing') } - sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path) + 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; @@ -698,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 @@ -711,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 a555a04a910..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -2,23 +2,44 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import FolderSvg from '@mdi/svg/svg/folder.svg' -import ShareSvg from '@mdi/svg/svg/share-variant.svg' +import type { Navigation } from '@nextcloud/files' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' import { createTestingPinia } from '@pinia/testing' import NavigationView from './Navigation.vue' -import router from '../router/router' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' -import Vue from 'vue' +import router from '../router/router.ts' +import RouterService from '../services/RouterService' + +const resetNavigation = () => { + const nav = getNavigation() + ;[...nav.views].forEach(({ id }) => nav.remove(id)) + nav.setActive(null) +} + +const createView = (id: string, name: string, parent?: string) => new View({ + id, + name, + getContents: async () => ({ folder: {} as Folder, contents: [] }), + icon: FolderSvg, + order: 1, + parent, +}) -describe('Navigation renders', () => { - delete window._nc_navigation - const Navigation = getNavigation() +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} - before(() => { - Vue.prototype.$navigation = Navigation +describe('Navigation renders', () => { + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) cy.mockInitialState('files', 'storageStats', { used: 1000 * 1000 * 1000, @@ -30,6 +51,7 @@ describe('Navigation renders', () => { it('renders', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -44,29 +66,31 @@ describe('Navigation renders', () => { }) describe('Navigation API', () => { - delete window._nc_navigation - const Navigation = getNavigation() + let Navigation: Navigation + + before(async () => { + delete window._nc_navigation + Navigation = getNavigation() + mockWindow() - before(() => { - Vue.prototype.$navigation = Navigation + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) + beforeEach(() => resetNavigation()) + it('Check API entries rendering', () => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + console.warn(Navigation.views) cy.mount(NavigationView, { + router, global: { - plugins: [createTestingPinia({ - createSpy: cy.spy, - })], + plugins: [ + createTestingPinia({ + createSpy: cy.spy, + }), + ], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -76,21 +100,16 @@ describe('Navigation API', () => { }) it('Adds a new entry and render', () => { - Navigation.register(new View({ - id: 'sharing', - name: 'Sharing', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: ShareSvg, - order: 2, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -100,22 +119,17 @@ describe('Navigation API', () => { }) it('Adds a new children, render and open menu', () => { - Navigation.register(new View({ - id: 'sharingin', - name: 'Shared with me', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - parent: 'sharing', - icon: ShareSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) + Navigation.register(createView('sharingin', 'Shared with me', 'sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.wrap(useViewConfigStore()).as('viewConfigStore') @@ -143,30 +157,25 @@ describe('Navigation API', () => { }) it('Throws when adding a duplicate entry', () => { - expect(() => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) - }).to.throw('View id files is already registered') + Navigation.register(createView('files', 'Files')) + expect(() => Navigation.register(createView('files', 'Files'))) + .to.throw('View id files is already registered') }) }) describe('Quota rendering', () => { - delete window._nc_navigation - const Navigation = getNavigation() - - before(() => { - Vue.prototype.$navigation = Navigation + before(async () => { + delete window._nc_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, @@ -181,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, @@ -200,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, @@ -213,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, @@ -234,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 71e9bf38068..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,52 +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"> - <Cog slot="icon" :size="20" /> + <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> - - <!-- Settings modal--> - <SettingsModal :open="settingsOpened" - data-cy-files-navigation-settings - @close="onSettingsClose" /> </NcAppNavigation> </template> <script lang="ts"> import type { View } from '@nextcloud/files' +import type { ViewConfig } from '../types.ts' -import { emit } from '@nextcloud/event-bus' -import { translate } from '@nextcloud/l10n' -import Cog from 'vue-material-design-icons/Cog.vue' -import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import { emit, subscribe } from '@nextcloud/event-bus' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' -import { useViewConfigStore } from '../store/viewConfig.ts' -import logger from '../logger.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.ts' + +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) -export default { +export default defineComponent({ name: 'Navigation', components: { - Cog, + IconCog, + FilesNavigationItem, + FilesNavigationSearch, + NavigationQuota, NcAppNavigation, NcAppNavigationItem, - NcIconSvgWrapper, + NcAppNavigationList, SettingsModal, }, setup() { + const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() + const { currentView, views } = useNavigation() + return { + currentView, + t, + views, + + filtersStore, viewConfigStore, } }, @@ -98,117 +106,80 @@ export default { }, computed: { + /** + * The current view ID from the route params + */ currentViewId() { return this.$route?.params?.view || 'files' }, - currentView(): View { - return this.views.find(view => view.id === this.currentViewId)! - }, - - views(): View[] { - return this.$navigation.views - }, - - 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[]>) }, }, watch: { - currentView(view, oldView) { - if (view.id !== oldView?.id) { - this.$navigation.setActive(view) - logger.debug(`Navigation changed from ${oldView.id} to ${view.id}`, { from: oldView, to: view }) - + currentViewId(newView, oldView) { + if (this.currentViewId !== this.currentView?.id) { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + // The new view as active this.showView(view) + logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view }) } }, }, + created() { + subscribe('files:folder-tree:initialized', this.loadExpandedViews) + subscribe('files:folder-tree:expanded', this.loadExpandedViews) + }, + beforeMount() { - if (this.currentView) { - logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) - this.showView(this.currentView) - } + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + this.showView(view) + logger.debug('Navigation mounted. Showing requested view', { view }) }, methods: { - /** - * 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) + } }, + /** + * Set the view as active on the navigation and handle internal state + * @param view View to set active + */ showView(view: View) { // Closing any opened sidebar - window?.OCA?.Files?.Sidebar?.close?.() - this.$navigation.setActive(view) + window.OCA?.Files?.Sidebar?.close?.() + 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() { @@ -221,26 +192,20 @@ export default { onSettingsClose() { this.settingsOpened = false }, - - t: translate, }, -} +}) </script> <style scoped lang="scss"> -// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in -.app-navigation::v-deep .app-navigation-entry-icon { - background-repeat: no-repeat; - background-position: center; -} - -.app-navigation::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 { @@ -250,4 +215,14 @@ export default { // Prevent shrinking or growing flex: 0 0 auto; } + +.files-navigation { + &__list { + height: 100%; // Fill all available space for sticky views + } + + :deep(.app-navigation__content > ul.app-navigation__list) { + will-change: scroll-position; + } +} </style> diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue 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 7dbb0dbc551..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 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' + +// eslint-disable-next-line import/namespace +const { Folder, getNavigation } = filesUtils -jest.mock('webdav/dist/node/request.js', () => ({ - request: jest.fn(), -})) +vi.mock('@nextcloud/axios') -global.window.OC = { +window.OC = { + ...window.OC, TAG_FAVORITE: '_$!<Favorite>!$_', } @@ -31,25 +36,26 @@ 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') - expect(eventBus.subscribe).toHaveBeenCalledTimes(2) + expect(eventBus.subscribe).toHaveBeenCalledTimes(3) expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything()) expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything()) // one main view and no children expect(Navigation.views.length).toBe(1) @@ -59,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') @@ -102,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') @@ -129,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', }) @@ -141,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: [] })) + 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: [] })) - registerFavoritesView() + await registerFavoritesView() let favoritesView = Navigation.views.find(view => view.id === 'favorites') let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') @@ -158,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: { @@ -166,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') @@ -180,4 +219,43 @@ describe('Dynamic update of favourite folders', () => { expect(favoritesView).toBeDefined() expect(favoriteFoldersViews.length).toBe(0) }) + + test('Renaming a favorite folder updates the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // expect(eventBus.emit).toHaveBeenCalledTimes(2) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder) + + // Create a folder with the same id but renamed + const renamedFolder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed', + owner: 'admin', + }) + + // Exec the rename action + eventBus.emit('files:node:renamed', renamedFolder) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder) + }) }) diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts index b246eb59793..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,11 +101,26 @@ export default () => { }) /** + * Update favorites navigation when a folder is renamed + */ + subscribe('files:node:renamed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + if (node.attributes.favorite !== 1) { + return + } + + updateNodeFromFavorites(node as Folder) + }) + + /** * Sort the favorites paths array and * update the order property of the existing views */ const updateAndSortViews = function() { - favoriteFolders.sort((a, b) => a.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) { @@ -122,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)) { @@ -131,7 +139,7 @@ export default () => { } // Update arrays - favoriteFolders.push(newFavoriteFolder) + favoriteFolders.push(node) favoriteFoldersViews.push(view) // Update and sort views @@ -157,4 +165,19 @@ export default () => { Navigation.remove(id) updateAndSortViews() } + + // Update a folder from the favorites paths array and update the views + const updateNodeFromFavorites = function(node: Folder) { + const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid) + + // Skip if it does not exists + if (favoriteFolder === undefined) { + return + } + + removePathFromFavorites(favoriteFolder.path) + addToFavorites(node) + } + + updateAndSortViews() } diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts 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 - } -} |