diff options
Diffstat (limited to 'apps/files/src')
167 files changed, 18791 insertions, 1672 deletions
diff --git a/apps/files/src/FilesApp.vue b/apps/files/src/FilesApp.vue new file mode 100644 index 00000000000..6fc02113162 --- /dev/null +++ b/apps/files/src/FilesApp.vue @@ -0,0 +1,40 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcContent app-name="files"> + <Navigation v-if="!isPublic" /> + <FilesList :is-public="isPublic" /> + </NcContent> +</template> + +<script lang="ts"> +import { isPublicShare } from '@nextcloud/sharing/public' +import { defineComponent } from 'vue' +import NcContent from '@nextcloud/vue/components/NcContent' +import Navigation from './views/Navigation.vue' +import FilesList from './views/FilesList.vue' +import { useHotKeys } from './composables/useHotKeys' + +export default defineComponent({ + name: 'FilesApp', + + components: { + NcContent, + FilesList, + Navigation, + }, + + setup() { + // Register global hotkeys + useHotKeys() + + const isPublic = isPublicShare() + + return { + isPublic, + } + }, +}) +</script> diff --git a/apps/files/src/actions/convertAction.ts b/apps/files/src/actions/convertAction.ts new file mode 100644 index 00000000000..4992dea312b --- /dev/null +++ b/apps/files/src/actions/convertAction.ts @@ -0,0 +1,81 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { FileAction, registerFileAction } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { t } from '@nextcloud/l10n' + +import AutoRenewSvg from '@mdi/svg/svg/autorenew.svg?raw' + +import { convertFile, convertFiles } from './convertUtils' + +type ConversionsProvider = { + from: string, + to: string, + displayName: string, +} + +export const ACTION_CONVERT = 'convert' +export const registerConvertActions = () => { + // Generate sub actions + const convertProviders = getCapabilities()?.files?.file_conversions as ConversionsProvider[] ?? [] + const actions = convertProviders.map(({ to, from, displayName }) => { + return new FileAction({ + id: `convert-${from}-${to}`, + displayName: () => t('files', 'Save as {displayName}', { displayName }), + iconSvgInline: () => generateIconSvg(to), + enabled: (nodes: Node[]) => { + // Check that all nodes have the same mime type + return nodes.every(node => from === node.mime) + }, + + async exec(node: Node) { + // If we're here, we know that the node has a fileid + convertFile(node.fileid as number, to) + + // Silently terminate, we'll handle the UI in the background + return null + }, + + async execBatch(nodes: Node[]) { + const fileIds = nodes.map(node => node.fileid).filter(Boolean) as number[] + convertFiles(fileIds, to) + + // Silently terminate, we'll handle the UI in the background + return Array(nodes.length).fill(null) + }, + + parent: ACTION_CONVERT, + }) + }) + + // Register main action + registerFileAction(new FileAction({ + id: ACTION_CONVERT, + displayName: () => t('files', 'Save as …'), + iconSvgInline: () => AutoRenewSvg, + enabled: (nodes: Node[], view: View) => { + return actions.some(action => action.enabled!(nodes, view)) + }, + async exec() { + return null + }, + order: 25, + })) + + // Register sub actions + actions.forEach(registerFileAction) +} + +export const generateIconSvg = (mime: string) => { + // Generate icon based on mime type + const url = generateUrl('/core/mimeicon?mime=' + encodeURIComponent(mime)) + return `<svg width="32" height="32" viewBox="0 0 32 32" + xmlns="http://www.w3.org/2000/svg"> + <image href="${url}" height="32" width="32" /> + </svg>` +} diff --git a/apps/files/src/actions/convertUtils.ts b/apps/files/src/actions/convertUtils.ts new file mode 100644 index 00000000000..0ace3747d9c --- /dev/null +++ b/apps/files/src/actions/convertUtils.ts @@ -0,0 +1,139 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { AxiosResponse, AxiosError } from '@nextcloud/axios' +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { showError, showLoading, showSuccess } from '@nextcloud/dialogs' +import { n, t } from '@nextcloud/l10n' +import axios, { isAxiosError } from '@nextcloud/axios' +import PQueue from 'p-queue' + +import { fetchNode } from '../services/WebdavClient.ts' +import logger from '../logger' + +type ConversionResponse = { + path: string + fileId: number +} + +interface PromiseRejectedResult<T> { + status: 'rejected' + reason: T +} + +type PromiseSettledResult<T, E> = PromiseFulfilledResult<T> | PromiseRejectedResult<E>; +type ConversionSuccess = AxiosResponse<OCSResponse<ConversionResponse>> +type ConversionError = AxiosError<OCSResponse<ConversionResponse>> + +const queue = new PQueue({ concurrency: 5 }) +const requestConversion = function(fileId: number, targetMimeType: string): Promise<AxiosResponse> { + return axios.post(generateOcsUrl('/apps/files/api/v1/convert'), { + fileId, + targetMimeType, + }) +} + +export const convertFiles = async function(fileIds: number[], targetMimeType: string) { + const conversions = fileIds.map(fileId => queue.add(() => requestConversion(fileId, targetMimeType))) + + // Start conversion + const toast = showLoading(t('files', 'Converting files …')) + + // Handle results + try { + const results = await Promise.allSettled(conversions) as PromiseSettledResult<ConversionSuccess, ConversionError>[] + const failed = results.filter(result => result.status === 'rejected') as PromiseRejectedResult<ConversionError>[] + if (failed.length > 0) { + const messages = failed.map(result => result.reason?.response?.data?.ocs?.meta?.message) + logger.error('Failed to convert files', { fileIds, targetMimeType, messages }) + + // If all failed files have the same error message, show it + if (new Set(messages).size === 1 && typeof messages[0] === 'string') { + showError(t('files', 'Failed to convert files: {message}', { message: messages[0] })) + return + } + + if (failed.length === fileIds.length) { + showError(t('files', 'All files failed to be converted')) + return + } + + // A single file failed and if we have a message for the failed file, show it + if (failed.length === 1 && messages[0]) { + showError(t('files', 'One file could not be converted: {message}', { message: messages[0] })) + return + } + + // We already check above when all files failed + // if we're here, we have a mix of failed and successful files + showError(n('files', 'One file could not be converted', '%n files could not be converted', failed.length)) + showSuccess(n('files', 'One file successfully converted', '%n files successfully converted', fileIds.length - failed.length)) + return + } + + // All files converted + showSuccess(t('files', 'Files successfully converted')) + + // Extract files that are within the current directory + // in batch mode, you might have files from different directories + // ⚠️, let's get the actual current dir, as the one from the action + // might have changed as the user navigated away + const currentDir = window.OCP.Files.Router.query.dir as string + const newPaths = results + .filter(result => result.status === 'fulfilled') + .map(result => result.value.data.ocs.data.path) + .filter(path => path.startsWith(currentDir)) + + // Fetch the new files + logger.debug('Files to fetch', { newPaths }) + const newFiles = await Promise.all(newPaths.map(path => fetchNode(path))) + + // Inform the file list about the new files + newFiles.forEach(file => emit('files:node:created', file)) + + // Switch to the new files + const firstSuccess = results[0] as PromiseFulfilledResult<ConversionSuccess> + const newFileId = firstSuccess.value.data.ocs.data.fileId + window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId.toString() }, window.OCP.Files.Router.query) + } catch (error) { + // Should not happen as we use allSettled and handle errors above + showError(t('files', 'Failed to convert files')) + logger.error('Failed to convert files', { fileIds, targetMimeType, error }) + } finally { + // Hide loading toast + toast.hideToast() + } +} + +export const convertFile = async function(fileId: number, targetMimeType: string) { + const toast = showLoading(t('files', 'Converting file …')) + + try { + const result = await queue.add(() => requestConversion(fileId, targetMimeType)) as AxiosResponse<OCSResponse<ConversionResponse>> + showSuccess(t('files', 'File successfully converted')) + + // Inform the file list about the new file + const newFile = await fetchNode(result.data.ocs.data.path) + emit('files:node:created', newFile) + + // Switch to the new file + const newFileId = result.data.ocs.data.fileId + window.OCP.Files.Router.goToRoute(null, { ...window.OCP.Files.Router.params, fileid: newFileId.toString() }, window.OCP.Files.Router.query) + } catch (error) { + // If the server returned an error message, show it + if (isAxiosError(error) && error.response?.data?.ocs?.meta?.message) { + showError(t('files', 'Failed to convert file: {message}', { message: error.response.data.ocs.meta.message })) + return + } + + logger.error('Failed to convert file', { fileId, targetMimeType, error }) + showError(t('files', 'Failed to convert file')) + } finally { + // Hide loading toast + toast.hideToast() + } +} diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts new file mode 100644 index 00000000000..845d29962a7 --- /dev/null +++ b/apps/files/src/actions/deleteAction.spec.ts @@ -0,0 +1,449 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import axios from '@nextcloud/axios' +import * as capabilities from '@nextcloud/capabilities' +import * as eventBus from '@nextcloud/event-bus' + +import { action } from './deleteAction' +import logger from '../logger' +import { shouldAskForConfirmation } from './deleteUtils' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') +vi.mock('@nextcloud/capabilities') + +const view = { + id: 'files', + name: 'Files', +} as View + +const trashbinView = { + id: 'trashbin', + name: 'Trashbin', +} as View + +describe('Delete action conditions tests', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'shared', + }, + }) + + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const folder2 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'shared', + }, + }) + + const folder3 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'is-mount-root': true, + 'mount-type': 'external', + }, + }) + + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('delete') + expect(action.displayName([file], view)).toBe('Delete file') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(100) + }) + + test('Default folder displayName', () => { + expect(action.displayName([folder], view)).toBe('Delete folder') + }) + + test('Default trashbin view displayName', () => { + expect(action.displayName([file], trashbinView)).toBe('Delete permanently') + }) + + test('Trashbin disabled displayName', () => { + vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => { + return { + files: {}, + } + }) + expect(action.displayName([file], view)).toBe('Delete permanently') + expect(capabilities.getCapabilities).toBeCalledTimes(1) + }) + + test('Shared root node displayName', () => { + expect(action.displayName([file2], view)).toBe('Leave this share') + expect(action.displayName([folder2], view)).toBe('Leave this share') + expect(action.displayName([file2, folder2], view)).toBe('Leave these shares') + }) + + test('External storage root node displayName', () => { + expect(action.displayName([folder3], view)).toBe('Disconnect storage') + expect(action.displayName([folder3, folder3], view)).toBe('Disconnect storages') + }) + + test('Shared and owned nodes displayName', () => { + expect(action.displayName([file, file2], view)).toBe('Delete and unshare') + }) +}) + +describe('Delete action enabled tests', () => { + let initialState: HTMLInputElement + + afterEach(() => { + document.body.removeChild(initialState) + }) + + beforeEach(() => { + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) + }) + + test('Enabled with DELETE permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled without DELETE permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled if not all nodes can be deleted', () => { + const folder1 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/Foo/', + owner: 'test', + permissions: Permission.DELETE, + }) + const folder2 = new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/test/Bar/', + owner: 'test', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder1], view)).toBe(true) + expect(action.enabled!([folder2], view)).toBe(false) + expect(action.enabled!([folder1, folder2], view)).toBe(false) + }) + + test('Disabled if not allowed', () => { + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: false, + }))) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) +}) + +describe('Delete action execute tests', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + test('Delete action', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('https://cloud.domain.com/remote.php/dav/files/test/foobar.txt') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Delete action batch', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + const confirmMock = vi.fn() + window.OC = { dialogs: { confirmDestructive: confirmMock } } + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.execBatch!([file1, file2], view, '/') + + // Not enough nodes to trigger a confirmation dialog + expect(confirmMock).toBeCalledTimes(0) + + expect(exec).toStrictEqual([true, true]) + expect(axios.delete).toBeCalledTimes(2) + expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Delete action batch large set', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + // Emulate the confirmation dialog to always confirm + const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true)) + window.OC = { dialogs: { confirmDestructive: confirmMock } } + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file3 = new File({ + id: 3, + source: 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file4 = new File({ + id: 4, + source: 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file5 = new File({ + id: 5, + source: 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.execBatch!([file1, file2, file3, file4, file5], view, '/') + + // Enough nodes to trigger a confirmation dialog + expect(confirmMock).toBeCalledTimes(1) + + expect(exec).toStrictEqual([true, true, true, true, true]) + expect(axios.delete).toBeCalledTimes(5) + expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt') + expect(axios.delete).toHaveBeenNthCalledWith(3, 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt') + expect(axios.delete).toHaveBeenNthCalledWith(4, 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt') + expect(axios.delete).toHaveBeenNthCalledWith(5, 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt') + + expect(eventBus.emit).toBeCalledTimes(5) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + expect(eventBus.emit).toHaveBeenNthCalledWith(3, 'files:node:deleted', file3) + expect(eventBus.emit).toHaveBeenNthCalledWith(4, 'files:node:deleted', file4) + expect(eventBus.emit).toHaveBeenNthCalledWith(5, 'files:node:deleted', file5) + }) + + test('Delete action batch dialog enabled', async () => { + // Enable the confirmation dialog + eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true }) + expect(shouldAskForConfirmation()).toBe(true) + + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => { + return { + files: {}, + } + }) + + // Emulate the confirmation dialog to always confirm + const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(true)) + window.OC = { dialogs: { confirmDestructive: confirmMock } } + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.execBatch!([file1, file2], view, '/') + + // Will trigger a confirmation dialog because trashbin app is disabled + expect(confirmMock).toBeCalledTimes(1) + + expect(exec).toStrictEqual([true, true]) + expect(axios.delete).toBeCalledTimes(2) + expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + + eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false }) + }) + + test('Delete fails', async () => { + vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') }) + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foobar.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(false) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('https://cloud.domain.com/remote.php/dav/files/test/foobar.txt') + + expect(eventBus.emit).toBeCalledTimes(0) + expect(logger.error).toBeCalledTimes(1) + }) + + test('Delete is cancelled with dialog enabled', async () => { + // Enable the confirmation dialog + eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: true }) + + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + vi.spyOn(capabilities, 'getCapabilities').mockImplementation(() => { + return { + files: {}, + } + }) + + // Emulate the confirmation dialog to always confirm + const confirmMock = vi.fn().mockImplementation((a, b, c, resolve) => resolve(false)) + window.OC = { dialogs: { confirmDestructive: confirmMock } } + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt', + owner: 'test', + mime: 'text/plain', + permissions: Permission.READ | Permission.UPDATE | Permission.DELETE, + }) + + const exec = await action.execBatch!([file1], view, '/') + + expect(confirmMock).toBeCalledTimes(1) + + expect(exec).toStrictEqual([null]) + expect(axios.delete).toBeCalledTimes(0) + + expect(eventBus.emit).toBeCalledTimes(0) + + eventBus.emit('files:config:updated', { key: 'show_dialog_deletion', value: false }) + }) +}) diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts new file mode 100644 index 00000000000..fa4fdfe8cdc --- /dev/null +++ b/apps/files/src/actions/deleteAction.ts @@ -0,0 +1,113 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { Permission, Node, View, FileAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import PQueue from 'p-queue' + +import CloseSvg from '@mdi/svg/svg/close.svg?raw' +import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw' +import TrashCanSvg from '@mdi/svg/svg/trash-can-outline.svg?raw' + +import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts' +import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts' +import logger from '../logger.ts' + +const queue = new PQueue({ concurrency: 5 }) + +export const ACTION_DELETE = 'delete' + +export const action = new FileAction({ + id: ACTION_DELETE, + displayName, + iconSvgInline: (nodes: Node[]) => { + if (canUnshareOnly(nodes)) { + return CloseSvg + } + + if (canDisconnectOnly(nodes)) { + return NetworkOffSvg + } + + return TrashCanSvg + }, + + enabled(nodes: Node[], view: View): boolean { + if (view.id === TRASHBIN_VIEW_ID) { + const config = loadState('files_trashbin', 'config', { allow_delete: true }) + if (config.allow_delete === false) { + return false + } + } + + return nodes.length > 0 && nodes + .map(node => node.permissions) + .every(permission => (permission & Permission.DELETE) !== 0) + }, + + async exec(node: Node, view: View) { + try { + let confirm = true + + // Trick to detect if the action was called from a keyboard event + // we need to make sure the method calling have its named containing 'keydown' + // here we use `onKeydown` method from the FileEntryActions component + const callStack = new Error().stack || '' + const isCalledFromEventListener = callStack.toLocaleLowerCase().includes('keydown') + + if (shouldAskForConfirmation() || isCalledFromEventListener) { + confirm = await askConfirmation([node], view) + } + + // If the user cancels the deletion, we don't want to do anything + if (confirm === false) { + return null + } + + await deleteNode(node) + + return true + } catch (error) { + logger.error('Error while deleting a file', { error, source: node.source, node }) + return false + } + }, + + async execBatch(nodes: Node[], view: View): Promise<(boolean | null)[]> { + let confirm = true + + if (shouldAskForConfirmation()) { + confirm = await askConfirmation(nodes, view) + } else if (nodes.length >= 5 && !canUnshareOnly(nodes) && !canDisconnectOnly(nodes)) { + confirm = await askConfirmation(nodes, view) + } + + // If the user cancels the deletion, we don't want to do anything + if (confirm === false) { + return Promise.all(nodes.map(() => null)) + } + + // Map each node to a promise that resolves with the result of exec(node) + const promises = nodes.map(node => { + // Create a promise that resolves with the result of exec(node) + const promise = new Promise<boolean>(resolve => { + queue.add(async () => { + try { + await deleteNode(node) + resolve(true) + } catch (error) { + logger.error('Error while deleting a file', { error, source: node.source, node }) + resolve(false) + } + }) + }) + return promise + }) + + return Promise.all(promises) + }, + + destructive: true, + order: 100, +}) diff --git a/apps/files/src/actions/deleteUtils.ts b/apps/files/src/actions/deleteUtils.ts new file mode 100644 index 00000000000..1ca7859b6c5 --- /dev/null +++ b/apps/files/src/actions/deleteUtils.ts @@ -0,0 +1,141 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Capabilities } from '../types' +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { FileType } from '@nextcloud/files' +import { getCapabilities } from '@nextcloud/capabilities' +import { n, t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import { useUserConfigStore } from '../store/userconfig' +import { getPinia } from '../store' + +export const isTrashbinEnabled = () => (getCapabilities() as Capabilities)?.files?.undelete === true + +export const canUnshareOnly = (nodes: Node[]) => { + return nodes.every(node => node.attributes['is-mount-root'] === true + && node.attributes['mount-type'] === 'shared') +} + +export const canDisconnectOnly = (nodes: Node[]) => { + return nodes.every(node => node.attributes['is-mount-root'] === true + && node.attributes['mount-type'] === 'external') +} + +export const isMixedUnshareAndDelete = (nodes: Node[]) => { + if (nodes.length === 1) { + return false + } + + const hasSharedItems = nodes.some(node => canUnshareOnly([node])) + const hasDeleteItems = nodes.some(node => !canUnshareOnly([node])) + return hasSharedItems && hasDeleteItems +} + +export const isAllFiles = (nodes: Node[]) => { + return !nodes.some(node => node.type !== FileType.File) +} + +export const isAllFolders = (nodes: Node[]) => { + return !nodes.some(node => node.type !== FileType.Folder) +} + +export const displayName = (nodes: Node[], view: View) => { + /** + * If those nodes are all the root node of a + * share, we can only unshare them. + */ + if (canUnshareOnly(nodes)) { + if (nodes.length === 1) { + return t('files', 'Leave this share') + } + return t('files', 'Leave these shares') + } + + /** + * If those nodes are all the root node of an + * external storage, we can only disconnect it. + */ + if (canDisconnectOnly(nodes)) { + if (nodes.length === 1) { + return t('files', 'Disconnect storage') + } + return t('files', 'Disconnect storages') + } + + /** + * If we're in the trashbin, we can only delete permanently + */ + if (view.id === 'trashbin' || !isTrashbinEnabled()) { + return t('files', 'Delete permanently') + } + + /** + * If we're in the sharing view, we can only unshare + */ + if (isMixedUnshareAndDelete(nodes)) { + return t('files', 'Delete and unshare') + } + + /** + * If we're only selecting files, use proper wording + */ + if (isAllFiles(nodes)) { + if (nodes.length === 1) { + return t('files', 'Delete file') + } + return t('files', 'Delete files') + } + + /** + * If we're only selecting folders, use proper wording + */ + if (isAllFolders(nodes)) { + if (nodes.length === 1) { + return t('files', 'Delete folder') + } + return t('files', 'Delete folders') + } + + return t('files', 'Delete') +} + +export const shouldAskForConfirmation = () => { + const userConfig = useUserConfigStore(getPinia()) + return userConfig.userConfig.show_dialog_deletion !== false +} + +export const askConfirmation = async (nodes: Node[], view: View) => { + const message = view.id === 'trashbin' || !isTrashbinEnabled() + ? n('files', 'You are about to permanently delete {count} item', 'You are about to permanently delete {count} items', nodes.length, { count: nodes.length }) + : n('files', 'You are about to delete {count} item', 'You are about to delete {count} items', nodes.length, { count: nodes.length }) + + return new Promise<boolean>(resolve => { + // TODO: Use the new dialog API + window.OC.dialogs.confirmDestructive( + message, + t('files', 'Confirm deletion'), + { + type: window.OC.dialogs.YES_NO_BUTTONS, + confirm: displayName(nodes, view), + confirmClasses: 'error', + cancel: t('files', 'Cancel'), + }, + (decision: boolean) => { + resolve(decision) + }, + ) + }) +} + +export const deleteNode = async (node: Node) => { + await axios.delete(node.encodedSource) + + // Let's delete even if it's moved to the trashbin + // since it has been removed from the current view + // and changing the view will trigger a reload anyway. + emit('files:node:deleted', node) +} diff --git a/apps/files/src/actions/downloadAction.spec.ts b/apps/files/src/actions/downloadAction.spec.ts new file mode 100644 index 00000000000..8d5612d982b --- /dev/null +++ b/apps/files/src/actions/downloadAction.spec.ts @@ -0,0 +1,191 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Permission, View, FileAction, DefaultType } from '@nextcloud/files' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { action } from './downloadAction' + +const view = { + id: 'files', + name: 'Files', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Download action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('download') + expect(action.displayName([], view)).toBe('Download') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBe(DefaultType.DEFAULT) + expect(action.order).toBe(30) + }) +}) + +describe('Download action enabled tests', () => { + test('Enabled with READ permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled without READ permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled if not all nodes have READ permissions', () => { + const folder1 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + permissions: Permission.READ, + }) + const folder2 = new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/', + owner: 'admin', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder1], view)).toBe(true) + expect(action.enabled!([folder2], view)).toBe(false) + expect(action.enabled!([folder1, folder2], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) +}) + +describe('Download action execute tests', () => { + const link = { + click: vi.fn(), + } as unknown as HTMLAnchorElement + + beforeEach(() => { + vi.resetAllMocks() + vi.spyOn(document, 'createElement').mockImplementation(() => link) + }) + + test('Download single file', async () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + const exec = await action.exec(file, view, '/') + + // Silent action + expect(exec).toBe(null) + expect(link.download).toBe('foobar.txt') + expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt') + expect(link.click).toHaveBeenCalledTimes(1) + }) + + test('Download single file with batch', async () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + const exec = await action.execBatch!([file], view, '/') + + // Silent action + expect(exec).toStrictEqual([null]) + expect(link.download).toEqual('foobar.txt') + expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt') + expect(link.click).toHaveBeenCalledTimes(1) + }) + + test('Download single file with displayname set', async () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + displayname: 'baz.txt', + permissions: Permission.READ, + }) + + const exec = await action.execBatch!([file], view, '/') + + // Silent action + expect(exec).toStrictEqual([null]) + expect(link.download).toEqual('baz.txt') + expect(link.href).toEqual('https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt') + expect(link.click).toHaveBeenCalledTimes(1) + }) + + test('Download single folder', async () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + const exec = await action.exec(folder, view, '/') + + // Silent action + expect(exec).toBe(null) + expect(link.download).toEqual('') + expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/FooBar/?accept=zip') + expect(link.click).toHaveBeenCalledTimes(1) + }) + + test('Download multiple nodes', async () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Dir/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Dir/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + const exec = await action.execBatch!([file1, file2], view, '/Dir') + + // Silent action + expect(exec).toStrictEqual([null, null]) + expect(link.download).toEqual('') + expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/Dir/?accept=zip&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D') + expect(link.click).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts new file mode 100644 index 00000000000..8abd87972ee --- /dev/null +++ b/apps/files/src/actions/downloadAction.ts @@ -0,0 +1,114 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' +import { FileAction, FileType, DefaultType } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { isDownloadable } from '../utils/permissions' + +import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw' + +/** + * Trigger downloading a file. + * + * @param url The url of the asset to download + * @param name Optionally the recommended name of the download (browsers might ignore it) + */ +function triggerDownload(url: string, name?: string) { + const hiddenElement = document.createElement('a') + hiddenElement.download = name ?? '' + hiddenElement.href = url + hiddenElement.click() +} + +/** + * Find the longest common path prefix of both input paths + * @param first The first path + * @param second The second path + */ +function longestCommonPath(first: string, second: string): string { + const firstSegments = first.split('/').filter(Boolean) + const secondSegments = second.split('/').filter(Boolean) + let base = '' + for (const [index, segment] of firstSegments.entries()) { + if (index >= second.length) { + break + } + if (segment !== secondSegments[index]) { + break + } + const sep = base === '' ? '' : '/' + base = `${base}${sep}${segment}` + } + return base +} + +const downloadNodes = function(nodes: Node[]) { + let url: URL + + if (nodes.length === 1) { + if (nodes[0].type === FileType.File) { + return triggerDownload(nodes[0].encodedSource, nodes[0].displayname) + } else { + url = new URL(nodes[0].encodedSource) + url.searchParams.append('accept', 'zip') + } + } else { + url = new URL(nodes[0].encodedSource) + let base = url.pathname + for (const node of nodes.slice(1)) { + base = longestCommonPath(base, (new URL(node.encodedSource).pathname)) + } + url.pathname = base + + // The URL contains the path encoded so we need to decode as the query.append will re-encode it + const filenames = nodes.map((node) => decodeURIComponent(node.encodedSource.slice(url.href.length + 1))) + url.searchParams.append('accept', 'zip') + url.searchParams.append('files', JSON.stringify(filenames)) + } + + if (url.pathname.at(-1) !== '/') { + url.pathname = `${url.pathname}/` + } + + return triggerDownload(url.href) +} + +export const action = new FileAction({ + id: 'download', + default: DefaultType.DEFAULT, + + displayName: () => t('files', 'Download'), + iconSvgInline: () => ArrowDownSvg, + + enabled(nodes: Node[], view: View) { + if (nodes.length === 0) { + return false + } + + // We can only download dav files and folders. + if (nodes.some(node => !node.isDavResource)) { + return false + } + + // Trashbin does not allow batch download + if (nodes.length > 1 && view.id === 'trashbin') { + return false + } + + return nodes.every(isDownloadable) + }, + + async exec(node: Node) { + downloadNodes([node]) + return null + }, + + async execBatch(nodes: Node[]) { + downloadNodes(nodes) + return new Array(nodes.length).fill(null) + }, + + order: 30, +}) diff --git a/apps/files/src/actions/favoriteAction.spec.ts b/apps/files/src/actions/favoriteAction.spec.ts new file mode 100644 index 00000000000..96768c4887a --- /dev/null +++ b/apps/files/src/actions/favoriteAction.spec.ts @@ -0,0 +1,381 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { action } from './favoriteAction' +import axios from '@nextcloud/axios' +import * as eventBus from '@nextcloud/event-bus' +import * as favoriteAction from './favoriteAction' +import logger from '../logger' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const favoriteView = { + id: 'favorites', + name: 'Favorites', +} as View + +// Mock webroot variable +beforeAll(() => { + window.OC = { + ...window.OC, + TAG_FAVORITE: '_$!<Favorite>!$_', + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Favorite action conditions tests', () => { + test('Default values', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('favorite') + expect(action.displayName([file], view)).toBe('Add to favorites') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(-50) + }) + + test('Display name is Remove from favorites if already in favorites', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 1, + }, + }) + + expect(action.displayName([file], view)).toBe('Remove from favorites') + }) + + test('Display name for multiple state files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 1, + }, + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 0, + }, + }) + const file3 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 1, + }, + }) + + expect(action.displayName([file1, file2, file3], view)).toBe('Add to favorites') + expect(action.displayName([file1, file2], view)).toBe('Add to favorites') + expect(action.displayName([file2, file3], view)).toBe('Add to favorites') + expect(action.displayName([file1, file3], view)).toBe('Remove from favorites') + }) +}) + +describe('Favorite action enabled tests', () => { + test('Enabled for dav file', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled for non-dav ressources', () => { + const file = new File({ + id: 1, + source: 'https://domain.com/data/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) +}) + +describe('Favorite action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Favorite triggers tag addition', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(true) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] }) + + // Check node change propagation + expect(file.attributes.favorite).toBe(1) + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:favorites:added', file) + }) + + test('Favorite triggers tag removal', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 1, + }, + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(true) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] }) + + // Check node change propagation + expect(file.attributes.favorite).toBe(0) + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file) + }) + + test('Favorite triggers node removal if favorite view and root dir', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 1, + }, + }) + + const exec = await action.exec(file, favoriteView, '/') + + expect(exec).toBe(true) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] }) + + // Check node change propagation + expect(file.attributes.favorite).toBe(0) + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:favorites:removed', file) + }) + + test('Favorite does NOT triggers node removal if favorite view but NOT root dir', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar/foobar.txt', + root: '/files/admin', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 1, + }, + }) + + const exec = await action.exec(file, favoriteView, '/') + + expect(exec).toBe(true) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/Foo/Bar/foobar.txt', { tags: [] }) + + // Check node change propagation + expect(file.attributes.favorite).toBe(0) + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:favorites:removed', file) + }) + + test('Favorite fails and show error', async () => { + const error = new Error('Mock error') + vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') }) + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 0, + }, + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(false) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: ['_$!<Favorite>!$_'] }) + + // Check node change propagation + expect(logger.error).toBeCalledTimes(1) + expect(logger.error).toBeCalledWith('Error while adding a file to favourites', { error, source: file.source, node: file }) + expect(file.attributes.favorite).toBe(0) + expect(eventBus.emit).toBeCalledTimes(0) + }) + + test('Removing from favorites fails and show error', async () => { + const error = new Error('Mock error') + vi.spyOn(axios, 'post').mockImplementation(() => { throw error }) + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + attributes: { + favorite: 1, + }, + }) + + const exec = await action.exec(file, view, '/') + + expect(exec).toBe(false) + + // Check POST request + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('/index.php/apps/files/api/v1/files/foobar.txt', { tags: [] }) + + // Check node change propagation + expect(logger.error).toBeCalledTimes(1) + expect(logger.error).toBeCalledWith('Error while removing a file from favourites', { error, source: file.source, node: file }) + expect(file.attributes.favorite).toBe(1) + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) + +describe('Favorite action batch execute tests', () => { + beforeEach(() => { vi.restoreAllMocks() }) + + test('Favorite action batch execute with mixed files', async () => { + vi.spyOn(favoriteAction, 'favoriteNode') + vi.spyOn(axios, 'post') + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 1, + }, + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 0, + }, + }) + + // Mixed states triggers favorite action + const exec = await action.execBatch!([file1, file2], view, '/') + expect(exec).toStrictEqual([true, true]) + expect([file1, file2].every(file => file.attributes.favorite === 1)).toBe(true) + + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: ['_$!<Favorite>!$_'] }) + expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: ['_$!<Favorite>!$_'] }) + }) + + test('Remove from favorite action batch execute with favorites only files', async () => { + vi.spyOn(favoriteAction, 'favoriteNode') + vi.spyOn(axios, 'post') + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 1, + }, + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + favorite: 1, + }, + }) + + // Mixed states triggers favorite action + const exec = await action.execBatch!([file1, file2], view, '/') + expect(exec).toStrictEqual([true, true]) + expect([file1, file2].every(file => file.attributes.favorite === 0)).toBe(true) + + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, '/index.php/apps/files/api/v1/files/foo.txt', { tags: [] }) + expect(axios.post).toHaveBeenNthCalledWith(2, '/index.php/apps/files/api/v1/files/bar.txt', { tags: [] }) + }) +}) diff --git a/apps/files/src/actions/favoriteAction.ts b/apps/files/src/actions/favoriteAction.ts new file mode 100644 index 00000000000..b0e1e3a0817 --- /dev/null +++ b/apps/files/src/actions/favoriteAction.ts @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { Permission, FileAction } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { encodePath } from '@nextcloud/paths' +import { generateUrl } from '@nextcloud/router' +import { isPublicShare } from '@nextcloud/sharing/public' +import axios from '@nextcloud/axios' +import PQueue from 'p-queue' +import Vue from 'vue' + +import StarOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw' +import StarSvg from '@mdi/svg/svg/star.svg?raw' + +import logger from '../logger.ts' + +export const ACTION_FAVORITE = 'favorite' + +const queue = new PQueue({ concurrency: 5 }) + +// If any of the nodes is not favorited, we display the favorite action. +const shouldFavorite = (nodes: Node[]): boolean => { + return nodes.some(node => node.attributes.favorite !== 1) +} + +export const favoriteNode = async (node: Node, view: View, willFavorite: boolean): Promise<boolean> => { + try { + // TODO: migrate to webdav tags plugin + const url = generateUrl('/apps/files/api/v1/files') + encodePath(node.path) + await axios.post(url, { + tags: willFavorite + ? [window.OC.TAG_FAVORITE] + : [], + }) + + // Let's delete if we are in the favourites view + // AND if it is removed from the user favorites + // AND it's in the root of the favorites view + if (view.id === 'favorites' && !willFavorite && node.dirname === '/') { + emit('files:node:deleted', node) + } + + // Update the node webdav attribute + Vue.set(node.attributes, 'favorite', willFavorite ? 1 : 0) + + // Dispatch event to whoever is interested + if (willFavorite) { + emit('files:favorites:added', node) + } else { + emit('files:favorites:removed', node) + } + + return true + } catch (error) { + const action = willFavorite ? 'adding a file to favourites' : 'removing a file from favourites' + logger.error('Error while ' + action, { error, source: node.source, node }) + return false + } +} + +export const action = new FileAction({ + id: ACTION_FAVORITE, + displayName(nodes: Node[]) { + return shouldFavorite(nodes) + ? t('files', 'Add to favorites') + : t('files', 'Remove from favorites') + }, + iconSvgInline: (nodes: Node[]) => { + return shouldFavorite(nodes) + ? StarOutlineSvg + : StarSvg + }, + + enabled(nodes: Node[]) { + // Not enabled for public shares + if (isPublicShare()) { + return false + } + + // We can only favorite nodes if they are located in files + return nodes.every(node => node.root?.startsWith?.('/files')) + // and we have permissions + && nodes.every(node => node.permissions !== Permission.NONE) + }, + + async exec(node: Node, view: View) { + const willFavorite = shouldFavorite([node]) + return await favoriteNode(node, view, willFavorite) + }, + async execBatch(nodes: Node[], view: View) { + const willFavorite = shouldFavorite(nodes) + + // Map each node to a promise that resolves with the result of exec(node) + const promises = nodes.map(node => { + // Create a promise that resolves with the result of exec(node) + const promise = new Promise<boolean>(resolve => { + queue.add(async () => { + try { + await favoriteNode(node, view, willFavorite) + resolve(true) + } catch (error) { + logger.error('Error while adding file to favorite', { error, source: node.source, node }) + resolve(false) + } + }) + }) + return promise + }) + + return Promise.all(promises) + }, + + order: -50, +}) diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts new file mode 100644 index 00000000000..06e32c98090 --- /dev/null +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -0,0 +1,373 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Folder, Node, View } from '@nextcloud/files' +import type { IFilePickerButton } from '@nextcloud/dialogs' +import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav' +import type { MoveCopyResult } from './moveOrCopyActionUtils' + +import { isAxiosError } from '@nextcloud/axios' +import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { openConflictPicker, hasConflict } from '@nextcloud/upload' +import { basename, join } from 'path' +import Vue from 'vue' + +import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' +import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' + +import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils' +import { getContents } from '../services/Files' +import logger from '../logger' + +/** + * Return the action that is possible for the given nodes + * @param {Node[]} nodes The nodes to check against + * @return {MoveCopyAction} The action that is possible for the given nodes + */ +const getActionForNodes = (nodes: Node[]): MoveCopyAction => { + if (canMove(nodes)) { + if (canCopy(nodes)) { + return MoveCopyAction.MOVE_OR_COPY + } + return MoveCopyAction.MOVE + } + + // Assuming we can copy as the enabled checks for copy permissions + return MoveCopyAction.COPY +} + +/** + * Create a loading notification toast + * @param mode The move or copy mode + * @param source Name of the node that is copied / moved + * @param destination Destination path + * @return {() => void} Function to hide the notification + */ +function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void { + const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination }) + + let toast: ReturnType<typeof showInfo>|undefined + toast = showInfo( + `<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`, + { + isHTML: true, + timeout: TOAST_PERMANENT_TIMEOUT, + onRemove: () => { toast?.hideToast(); toast = undefined }, + }, + ) + return () => toast && toast.hideToast() +} + +/** + * Handle the copy/move of a node to a destination + * This can be imported and used by other scripts/components on server + * @param {Node} node The node to copy/move + * @param {Folder} destination The destination to copy/move the node to + * @param {MoveCopyAction} method The method to use for the copy/move + * @param {boolean} overwrite Whether to overwrite the destination if it exists + * @return {Promise<void>} A promise that resolves when the copy/move is done + */ +export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => { + if (!destination) { + return + } + + if (destination.type !== FileType.Folder) { + throw new Error(t('files', 'Destination is not a folder')) + } + + // Do not allow to MOVE a node to the same folder it is already located + if (method === MoveCopyAction.MOVE && node.dirname === destination.path) { + throw new Error(t('files', 'This file/folder is already in that directory')) + } + + /** + * Example: + * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo + * Allow move of /foo does not start with /foo/bar/file.txt so allow + * - node: /foo , destination: /foo/bar + * Do not allow as it would copy foo within itself + * - node: /foo/bar.txt, destination: /foo + * Allow copy a file to the same directory + * - node: "/foo/bar", destination: "/foo/bar 1" + * Allow to move or copy but we need to check with trailing / otherwise it would report false positive + */ + if (`${destination.path}/`.startsWith(`${node.path}/`)) { + throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself')) + } + + // Set loading state + Vue.set(node, 'status', NodeStatus.LOADING) + const actionFinished = createLoadingNotification(method, node.basename, destination.path) + + const queue = getQueue() + return await queue.add(async () => { + const copySuffix = (index: number) => { + if (index === 1) { + return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file + } + return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file + } + + try { + const client = davGetClient() + const currentPath = join(davRootPath, node.path) + const destinationPath = join(davRootPath, destination.path) + + if (method === MoveCopyAction.COPY) { + let target = node.basename + // If we do not allow overwriting then find an unique name + if (!overwrite) { + const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[] + target = getUniqueName( + node.basename, + otherNodes.map((n) => n.basename), + { + suffix: copySuffix, + ignoreFileExtension: node.type === FileType.Folder, + }, + ) + } + await client.copyFile(currentPath, join(destinationPath, target)) + // If the node is copied into current directory the view needs to be updated + if (node.dirname === destination.path) { + const { data } = await client.stat( + join(destinationPath, target), + { + details: true, + data: davGetDefaultPropfind(), + }, + ) as ResponseDataDetailed<FileStat> + emit('files:node:created', davResultToNode(data)) + } + } else { + // show conflict file popup if we do not allow overwriting + if (!overwrite) { + const otherNodes = await getContents(destination.path) + if (hasConflict([node], otherNodes.contents)) { + try { + // Let the user choose what to do with the conflicting files + const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents) + // two empty arrays: either only old files or conflict skipped -> no action required + if (!selected.length && !renamed.length) { + return + } + } catch (error) { + // User cancelled + return + } + } + } + // getting here means either no conflict, file was renamed to keep both files + // in a conflict, or the selected file was chosen to be kept during the conflict + try { + await client.moveFile(currentPath, join(destinationPath, node.basename)) + } catch (error) { + const parser = new DOMParser() + const text = await (error as WebDAVClientError).response?.text() + const message = parser.parseFromString(text ?? '', 'text/xml') + .querySelector('message')?.textContent + if (message) { + showError(message) + } + throw error + } + // Delete the node as it will be fetched again + // when navigating to the destination folder + emit('files:node:deleted', node) + } + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 412) { + throw new Error(t('files', 'A file or folder with that name already exists in this folder')) + } else if (error.response?.status === 423) { + throw new Error(t('files', 'The files are locked')) + } else if (error.response?.status === 404) { + throw new Error(t('files', 'The file does not exist anymore')) + } else if (error.message) { + throw new Error(error.message) + } + } + logger.debug(error as Error) + throw new Error() + } finally { + Vue.set(node, 'status', '') + actionFinished() + } + }) +} + +/** + * Open a file picker for the given action + * @param action The action to open the file picker for + * @param dir The directory to start the file picker in + * @param nodes The nodes to move/copy + * @return The picked destination or false if cancelled by user + */ +async function openFilePickerForAction( + action: MoveCopyAction, + dir = '/', + nodes: Node[], +): Promise<MoveCopyResult | false> { + const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>() + const fileIDs = nodes.map(node => node.fileid).filter(Boolean) + const filePicker = getFilePickerBuilder(t('files', 'Choose destination')) + .allowDirectories(true) + .setFilter((n: Node) => { + // We don't want to show the current nodes in the file picker + return !fileIDs.includes(n.fileid) + }) + .setMimeTypeFilter([]) + .setMultiSelect(false) + .startAt(dir) + .setButtonFactory((selection: Node[], path: string) => { + const buttons: IFilePickerButton[] = [] + const target = basename(path) + + const dirnames = nodes.map(node => node.dirname) + const paths = nodes.map(node => node.path) + + if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'), + type: 'primary', + icon: CopyIconSvg, + disabled: selection.some((node) => (node.permissions & Permission.CREATE) === 0), + async callback(destination: Node[]) { + resolve({ + destination: destination[0] as Folder, + action: MoveCopyAction.COPY, + } as MoveCopyResult) + }, + }) + } + + // Invalid MOVE targets (but valid copy targets) + if (dirnames.includes(path)) { + // This file/folder is already in that directory + return buttons + } + + if (paths.includes(path)) { + // You cannot move a file/folder onto itself + return buttons + } + + if (selection.some((node) => (node.permissions & Permission.CREATE) === 0)) { + // Missing 'CREATE' permissions for selected destination + return buttons + } + + if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'), + type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary', + icon: FolderMoveSvg, + async callback(destination: Node[]) { + resolve({ + destination: destination[0] as Folder, + action: MoveCopyAction.MOVE, + } as MoveCopyResult) + }, + }) + } + + return buttons + }) + .build() + + filePicker.pick() + .catch((error: Error) => { + logger.debug(error as Error) + if (error instanceof FilePickerClosed) { + resolve(false) + } else { + reject(new Error(t('files', 'Move or copy operation failed'))) + } + }) + + return promise +} + +export const ACTION_COPY_MOVE = 'move-copy' +export const action = new FileAction({ + id: ACTION_COPY_MOVE, + displayName(nodes: Node[]) { + switch (getActionForNodes(nodes)) { + case MoveCopyAction.MOVE: + return t('files', 'Move') + case MoveCopyAction.COPY: + return t('files', 'Copy') + case MoveCopyAction.MOVE_OR_COPY: + return t('files', 'Move or copy') + } + }, + iconSvgInline: () => FolderMoveSvg, + enabled(nodes: Node[], view: View) { + // We can not copy or move in single file shares + if (view.id === 'public-file-share') { + return false + } + // We only support moving/copying files within the user folder + if (!nodes.every(node => node.root?.startsWith('/files/'))) { + return false + } + return nodes.length > 0 && (canMove(nodes) || canCopy(nodes)) + }, + + async exec(node: Node, view: View, dir: string) { + const action = getActionForNodes([node]) + let result + try { + result = await openFilePickerForAction(action, dir, [node]) + } catch (e) { + logger.error(e as Error) + return false + } + if (result === false) { + return null + } + + try { + await handleCopyMoveNodeTo(node, result.destination, result.action) + return true + } catch (error) { + if (error instanceof Error && !!error.message) { + showError(error.message) + // Silent action as we handle the toast + return null + } + return false + } + }, + + async execBatch(nodes: Node[], view: View, dir: string) { + const action = getActionForNodes(nodes) + const result = await openFilePickerForAction(action, dir, nodes) + // Handle cancellation silently + if (result === false) { + return nodes.map(() => null) + } + + const promises = nodes.map(async node => { + try { + await handleCopyMoveNodeTo(node, result.destination, result.action) + return true + } catch (error) { + logger.error(`Failed to ${result.action} node`, { node, error }) + return false + } + }) + + // We need to keep the selection on error! + // So we do not return null, and for batch action + // we let the front handle the error. + return await Promise.all(promises) + }, + + order: 15, +}) diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts new file mode 100644 index 00000000000..0372e8f4bc7 --- /dev/null +++ b/apps/files/src/actions/moveOrCopyActionUtils.ts @@ -0,0 +1,71 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Folder, Node } from '@nextcloud/files' +import type { ShareAttribute } from '../../../files_sharing/src/sharing' + +import { Permission } from '@nextcloud/files' +import { isPublicShare } from '@nextcloud/sharing/public' +import PQueue from 'p-queue' +import { loadState } from '@nextcloud/initial-state' + +const sharePermissions = loadState<number>('files_sharing', 'sharePermissions', Permission.NONE) + +// This is the processing queue. We only want to allow 3 concurrent requests +let queue: PQueue + +// Maximum number of concurrent operations +const MAX_CONCURRENCY = 5 + +/** + * Get the processing queue + */ +export const getQueue = () => { + if (!queue) { + queue = new PQueue({ concurrency: MAX_CONCURRENCY }) + } + return queue +} + +export enum MoveCopyAction { + MOVE = 'Move', + COPY = 'Copy', + MOVE_OR_COPY = 'move-or-copy', +} + +export type MoveCopyResult = { + destination: Folder + action: MoveCopyAction.COPY | MoveCopyAction.MOVE +} + +export const canMove = (nodes: Node[]) => { + const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL) + return Boolean(minPermission & Permission.DELETE) +} + +export const canDownload = (nodes: Node[]) => { + return nodes.every(node => { + const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute> + return !shareAttributes.some(attribute => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download') + + }) +} + +export const canCopy = (nodes: Node[]) => { + // a shared file cannot be copied if the download is disabled + if (!canDownload(nodes)) { + return false + } + // it cannot be copied if the user has only view permissions + if (nodes.some((node) => node.permissions === Permission.NONE)) { + return false + } + // on public shares all files have the same permission so copy is only possible if write permission is granted + if (isPublicShare()) { + return Boolean(sharePermissions & Permission.CREATE) + } + // otherwise permission is granted + return true +} diff --git a/apps/files/src/actions/openFolderAction.spec.ts b/apps/files/src/actions/openFolderAction.spec.ts new file mode 100644 index 00000000000..066ad5d86d8 --- /dev/null +++ b/apps/files/src/actions/openFolderAction.spec.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Node, Permission, View, DefaultType, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' + +import { action } from './openFolderAction' + +const view = { + id: 'files', + name: 'Files', +} as View + +describe('Open folder action conditions tests', () => { + test('Default values', () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('open-folder') + expect(action.displayName([folder], view)).toBe('Open folder FooBar') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBe(DefaultType.HIDDEN) + expect(action.order).toBe(-100) + }) +}) + +describe('Open folder action enabled tests', () => { + test('Enabled for folders', () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder], view)).toBe(true) + }) + + test('Disabled for non-dav ressources', () => { + const folder = new Folder({ + id: 1, + source: 'https://domain.com/data/FooBar/', + owner: 'admin', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder], view)).toBe(false) + }) + + test('Disabled if more than one node', () => { + const folder1 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + permissions: Permission.READ, + }) + const folder2 = new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder1, folder2], view)).toBe(false) + }) + + test('Disabled for files', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled without READ permissions', () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder], view)).toBe(false) + }) +}) + +describe('Open folder action execute tests', () => { + test('Open folder', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + const exec = await action.exec(folder, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/FooBar' }) + }) + + test('Open folder fails without node', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const exec = await action.exec(null as unknown as Node, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) + + test('Open folder fails without Folder', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) +}) diff --git a/apps/files/src/actions/openFolderAction.ts b/apps/files/src/actions/openFolderAction.ts new file mode 100644 index 00000000000..8719f7a93fb --- /dev/null +++ b/apps/files/src/actions/openFolderAction.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { Permission, Node, FileType, View, FileAction, DefaultType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' + +export const action = new FileAction({ + id: 'open-folder', + displayName(files: Node[]) { + // Only works on single node + const displayName = files[0].displayname + return t('files', 'Open folder {displayName}', { displayName }) + }, + iconSvgInline: () => FolderSvg, + + enabled(nodes: Node[]) { + // Only works on single node + if (nodes.length !== 1) { + return false + } + + const node = nodes[0] + + if (!node.isDavRessource) { + return false + } + + return node.type === FileType.Folder + && (node.permissions & Permission.READ) !== 0 + }, + + async exec(node: Node, view: View) { + if (!node || node.type !== FileType.Folder) { + return false + } + + window.OCP.Files.Router.goToRoute( + null, + { view: view.id, fileid: String(node.fileid) }, + { dir: node.path }, + ) + return null + }, + + // Main action if enabled, meaning folders only + default: DefaultType.HIDDEN, + order: -100, +}) diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts new file mode 100644 index 00000000000..3ccd15fa2d2 --- /dev/null +++ b/apps/files/src/actions/openInFilesAction.spec.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { action } from './openInFilesAction' +import { describe, expect, test, vi } from 'vitest' +import { File, Folder, Permission, View, DefaultType, FileAction } from '@nextcloud/files' + +const view = { + id: 'files', + name: 'Files', +} as View + +const recentView = { + id: 'recent', + name: 'Recent', +} as View + +describe('Open in files action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('open-in-files') + expect(action.displayName([], recentView)).toBe('Open in Files') + expect(action.iconSvgInline([], recentView)).toBe('') + expect(action.default).toBe(DefaultType.HIDDEN) + expect(action.order).toBe(-1000) + expect(action.inline).toBeUndefined() + }) +}) + +describe('Open in files action enabled tests', () => { + test('Enabled with on valid view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], recentView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) +}) + +describe('Open in files action execute tests', () => { + test('Open in files', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt', + owner: 'admin', + mime: 'text/plain', + root: '/files/admin', + permissions: Permission.ALL, + }) + + const exec = await action.exec(file, view, '/') + + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' }) + }) + + test('Open in files with folder', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + root: '/files/admin', + permissions: Permission.ALL, + }) + + const exec = await action.exec(file, view, '/') + + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo/Bar', openfile: 'true' }) + }) +}) diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts new file mode 100644 index 00000000000..9e10b1ac74e --- /dev/null +++ b/apps/files/src/actions/openInFilesAction.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' + +import { t } from '@nextcloud/l10n' +import { FileType, FileAction, DefaultType } from '@nextcloud/files' +import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search' + +export const action = new FileAction({ + id: 'open-in-files', + displayName: () => t('files', 'Open in Files'), + iconSvgInline: () => '', + + enabled(nodes, view) { + return view.id === 'recent' || view.id === SEARCH_VIEW_ID + }, + + async exec(node: Node) { + let dir = node.dirname + if (node.type === FileType.Folder) { + dir = dir + '/' + node.basename + } + + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: String(node.fileid) }, + { dir, openfile: 'true' }, + ) + return null + }, + + // Before openFolderAction + order: -1000, + default: DefaultType.HIDDEN, +}) diff --git a/apps/files/src/actions/openLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts new file mode 100644 index 00000000000..860bd6233f4 --- /dev/null +++ b/apps/files/src/actions/openLocallyAction.spec.ts @@ -0,0 +1,170 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import axios from '@nextcloud/axios' +import * as nextcloudDialogs from '@nextcloud/dialogs' +import { action } from './openLocallyAction' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +// Mock web root variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).OCA = { Viewer: { open: vi.fn() } } +}) + +describe('Open locally action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('edit-locally') + expect(action.displayName([], view)).toBe('Open locally') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(25) + }) +}) + +describe('Open locally action enabled tests', () => { + test('Enabled for file with UPDATE permission', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled for non-dav resources', () => { + const file = new File({ + id: 1, + source: 'https://domain.com/data/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled if more than one node', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file1, file2], view)).toBe(false) + }) + + test('Disabled for files', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled without UPDATE permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) +}) + +describe('Open locally action execute tests', () => { + let spyShowDialog + beforeEach(() => { + vi.resetAllMocks() + spyShowDialog = vi.spyOn(nextcloudDialogs.Dialog.prototype, 'show') + .mockImplementation(() => Promise.resolve()) + }) + + test('Open locally opens proper URL', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({ + data: { ocs: { data: { token: 'foobar' } } }, + })) + const showError = vi.spyOn(nextcloudDialogs, 'showError') + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + const file = new File({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.UPDATE, + }) + + const exec = await action.exec(file, view, '/') + + expect(spyShowDialog).toBeCalled() + + // Silent action + expect(exec).toBe(null) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' }) + expect(showError).toBeCalledTimes(0) + expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self') + }) + + test('Open locally fails and shows error', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({})) + const showError = vi.spyOn(nextcloudDialogs, 'showError') + + const file = new File({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.UPDATE, + }) + + const exec = await action.exec(file, view, '/') + + expect(spyShowDialog).toBeCalled() + + // Silent action + expect(exec).toBe(null) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' }) + expect(showError).toBeCalledTimes(1) + expect(showError).toBeCalledWith('Failed to redirect to client') + expect(window.location.href).toBe('http://nextcloud.local/') + }) +}) diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts new file mode 100644 index 00000000000..986b304210c --- /dev/null +++ b/apps/files/src/actions/openLocallyAction.ts @@ -0,0 +1,114 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { encodePath } from '@nextcloud/paths' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { FileAction, Permission, type Node } from '@nextcloud/files' +import { showError, DialogBuilder } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' +import IconWeb from '@mdi/svg/svg/web.svg?raw' +import { isPublicShare } from '@nextcloud/sharing/public' + +export const action = new FileAction({ + id: 'edit-locally', + displayName: () => t('files', 'Open locally'), + iconSvgInline: () => LaptopSvg, + + // Only works on single files + enabled(nodes: Node[]) { + // Only works on single node + if (nodes.length !== 1) { + return false + } + + // does not work with shares + if (isPublicShare()) { + return false + } + + return (nodes[0].permissions & Permission.UPDATE) !== 0 + }, + + async exec(node: Node) { + await attemptOpenLocalClient(node.path) + return null + }, + + order: 25, +}) + +/** + * Try to open the path in the Nextcloud client. + * + * If this fails a dialog is shown with 3 options: + * 1. Retry: If it fails no further dialog is shown. + * 2. Open online: The viewer is used to open the file. + * 3. Close the dialog and nothing happens (abort). + * + * @param path - The path to open + */ +async function attemptOpenLocalClient(path: string) { + await openLocalClient(path) + const result = await confirmLocalEditDialog() + if (result === 'local') { + await openLocalClient(path) + } else if (result === 'online') { + window.OCA.Viewer.open({ path }) + } +} + +/** + * Try to open a file in the Nextcloud client. + * There is no way to get notified if this action was successfull. + * + * @param path - Path to open + */ +async function openLocalClient(path: string): Promise<void> { + const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' + + try { + const result = await axios.post(link, { path }) + const uid = getCurrentUser()?.uid + let url = `nc://open/${uid}@` + window.location.host + encodePath(path) + url += '?token=' + result.data.ocs.data.token + + window.open(url, '_self') + } catch (error) { + showError(t('files', 'Failed to redirect to client')) + } +} + +/** + * Open the confirmation dialog. + */ +async function confirmLocalEditDialog(): Promise<'online'|'local'|false> { + let result: 'online'|'local'|false = false + const dialog = (new DialogBuilder()) + .setName(t('files', 'Open file locally')) + .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) + .setButtons([ + { + label: t('files', 'Retry and close'), + type: 'secondary', + callback: () => { + result = 'local' + }, + }, + { + label: t('files', 'Open online'), + icon: IconWeb, + type: 'primary', + callback: () => { + result = 'online' + }, + }, + ]) + .build() + + await dialog.show() + return result +} diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts new file mode 100644 index 00000000000..1f9c9209d41 --- /dev/null +++ b/apps/files/src/actions/renameAction.spec.ts @@ -0,0 +1,100 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { action } from './renameAction' +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' +import * as eventBus from '@nextcloud/event-bus' +import { describe, expect, test, vi, beforeEach } from 'vitest' +import { useFilesStore } from '../store/files' +import { getPinia } from '../store/index.ts' + +const view = { + id: 'files', + name: 'Files', +} as View + +beforeEach(() => { + const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE }) + const files = useFilesStore(getPinia()) + files.setRoot({ service: 'files', root }) +}) + +describe('Rename action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('rename') + expect(action.displayName([], view)).toBe('Rename') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(10) + }) +}) + +describe('Rename action enabled tests', () => { + test('Enabled for node with UPDATE permission', () => { + const file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.UPDATE | Permission.DELETE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled for node without DELETE permission', () => { + const file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled if more than one node', () => { + window.OCA = { Files: { Sidebar: {} } } + + const file1 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file1, file2], view)).toBe(false) + }) +}) + +describe('Rename action exec tests', () => { + test('Rename', async () => { + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + + // Silent action + expect(exec).toBe(null) + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:node:rename', file) + }) +}) diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts new file mode 100644 index 00000000000..715ecb7563e --- /dev/null +++ b/apps/files/src/actions/renameAction.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { emit } from '@nextcloud/event-bus' +import { Permission, type Node, FileAction, View } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw' +import { getPinia } from '../store' +import { useFilesStore } from '../store/files' +import { dirname } from 'path' + +export const ACTION_RENAME = 'rename' + +export const action = new FileAction({ + id: ACTION_RENAME, + displayName: () => t('files', 'Rename'), + iconSvgInline: () => PencilSvg, + + enabled: (nodes: Node[], view: View) => { + if (nodes.length === 0) { + return false + } + + // Disable for single file shares + if (view.id === 'public-file-share') { + return false + } + + const node = nodes[0] + const filesStore = useFilesStore(getPinia()) + const parentNode = node.dirname === '/' + ? filesStore.getRoot(view.id) + : filesStore.getNode(dirname(node.source)) + const parentPermissions = parentNode?.permissions || Permission.NONE + + // Only enable if the node have the delete permission + // and if the parent folder allows creating files + return Boolean(node.permissions & Permission.DELETE) + && Boolean(parentPermissions & Permission.CREATE) + }, + + async exec(node: Node) { + // Renaming is a built-in feature of the files app + emit('files:node:rename', node) + return null + }, + + order: 10, +}) diff --git a/apps/files/src/actions/sidebarAction.spec.ts b/apps/files/src/actions/sidebarAction.spec.ts new file mode 100644 index 00000000000..9085bf595ad --- /dev/null +++ b/apps/files/src/actions/sidebarAction.spec.ts @@ -0,0 +1,185 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction, Folder } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' + +import { action } from './sidebarAction' +import logger from '../logger' + +const view = { + id: 'files', + name: 'Files', +} as View + +describe('Open sidebar action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('details') + expect(action.displayName([], view)).toBe('Details') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(-50) + }) +}) + +describe('Open sidebar action enabled tests', () => { + test('Enabled for ressources within user root folder', () => { + window.OCA = { Files: { Sidebar: {} } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled without permissions', () => { + window.OCA = { Files: { Sidebar: {} } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + + }) + + test('Disabled if more than one node', () => { + window.OCA = { Files: { Sidebar: {} } } + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file1, file2], view)).toBe(false) + }) + + test('Disabled if no Sidebar', () => { + window.OCA = {} + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled for non-dav ressources', () => { + window.OCA = { Files: { Sidebar: {} } } + + const file = new File({ + id: 1, + source: 'https://domain.com/documents/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) +}) + +describe('Open sidebar action exec tests', () => { + test('Open sidebar', async () => { + const openMock = vi.fn() + const defaultTabMock = vi.fn() + window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } } + + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(openMock).toBeCalledWith('/foobar.txt') + expect(defaultTabMock).toBeCalledWith('sharing') + expect(goToRouteMock).toBeCalledWith( + null, + { view: view.id, fileid: '1' }, + { dir: '/', opendetails: 'true' }, + true, + ) + }) + + test('Open sidebar for folder', async () => { + const openMock = vi.fn() + const defaultTabMock = vi.fn() + window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } } + + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar', + owner: 'admin', + mime: 'httpd/unix-directory', + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(openMock).toBeCalledWith('/foobar') + expect(defaultTabMock).toBeCalledWith('sharing') + expect(goToRouteMock).toBeCalledWith( + null, + { view: view.id, fileid: '1' }, + { dir: '/', opendetails: 'true' }, + true, + ) + }) + + test('Open sidebar fails', async () => { + const openMock = vi.fn(() => { throw new Error('Mock error') }) + const defaultTabMock = vi.fn() + window.OCA = { Files: { Sidebar: { open: openMock, setActiveTab: defaultTabMock } } } + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + const exec = await action.exec(file, view, '/') + expect(exec).toBe(false) + expect(openMock).toBeCalledTimes(1) + expect(logger.error).toBeCalledTimes(1) + }) +}) diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts new file mode 100644 index 00000000000..8f020b4ee8d --- /dev/null +++ b/apps/files/src/actions/sidebarAction.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { Permission, FileAction } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { isPublicShare } from '@nextcloud/sharing/public' + +import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw' + +import logger from '../logger.ts' + +export const ACTION_DETAILS = 'details' + +export const action = new FileAction({ + id: ACTION_DETAILS, + displayName: () => t('files', 'Details'), + iconSvgInline: () => InformationSvg, + + // Sidebar currently supports user folder only, /files/USER + enabled: (nodes: Node[]) => { + if (isPublicShare()) { + return false + } + + // Only works on single node + if (nodes.length !== 1) { + return false + } + + if (!nodes[0]) { + return false + } + + // Only work if the sidebar is available + if (!window?.OCA?.Files?.Sidebar) { + return false + } + + return (nodes[0].root?.startsWith('/files/') && nodes[0].permissions !== Permission.NONE) ?? false + }, + + async exec(node: Node, view: View, dir: string) { + try { + // If the sidebar is already open for the current file, do nothing + if (window.OCA.Files.Sidebar.file === node.path) { + logger.debug('Sidebar already open for this file', { node }) + return null + } + // Open sidebar and set active tab to sharing by default + window.OCA.Files.Sidebar.setActiveTab('sharing') + + // TODO: migrate Sidebar to use a Node instead + await window.OCA.Files.Sidebar.open(node.path) + + // Silently update current fileid + window.OCP?.Files?.Router?.goToRoute( + null, + { view: view.id, fileid: String(node.fileid) }, + { ...window.OCP.Files.Router.query, dir, opendetails: 'true' }, + true, + ) + + return null + } catch (error) { + logger.error('Error while opening sidebar', { error }) + return false + } + }, + + order: -50, +}) diff --git a/apps/files/src/actions/viewInFolderAction.spec.ts b/apps/files/src/actions/viewInFolderAction.spec.ts new file mode 100644 index 00000000000..bd618c8a89f --- /dev/null +++ b/apps/files/src/actions/viewInFolderAction.spec.ts @@ -0,0 +1,193 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Node, Permission, View, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' +import { action } from './viewInFolderAction' + +const view = { + id: 'trashbin', + name: 'Trashbin', +} as View + +const viewFiles = { + id: 'files', + name: 'Files', +} as View + +describe('View in folder action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('view-in-folder') + expect(action.displayName([], view)).toBe('View in folder') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(80) + expect(action.enabled).toBeDefined() + }) +}) + +describe('View in folder action enabled tests', () => { + test('Enabled for trashbin', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Disabled for files', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], viewFiles)).toBe(false) + }) + + test('Disabled without permissions', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.NONE, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled for non-dav ressources', () => { + const file = new File({ + id: 1, + source: 'https://domain.com/foobar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Disabled if more than one node', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + }) + const file2 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file1, file2], view)).toBe(false) + }) + + test('Disabled for folders', () => { + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/FooBar/', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder], view)).toBe(false) + }) + + test('Disabled for files outside the user root folder', () => { + const file = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/trashbin/admin/trash/image.jpg.d1731053878', + owner: 'admin', + permissions: Permission.READ, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], view)).toBe(false) + }) +}) + +describe('View in folder action execute tests', () => { + test('View in folder', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/' }) + }) + + test('View in (sub) folder', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar/foobar.txt', + root: '/files/admin', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo/Bar' }) + }) + + test('View in folder fails without node', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const exec = await action.exec(null as unknown as Node, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) + + test('View in folder fails without File', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const folder = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + }) + + const exec = await action.exec(folder, view, '/') + expect(exec).toBe(false) + expect(goToRouteMock).toBeCalledTimes(0) + }) +}) diff --git a/apps/files/src/actions/viewInFolderAction.ts b/apps/files/src/actions/viewInFolderAction.ts new file mode 100644 index 00000000000..b22393c1152 --- /dev/null +++ b/apps/files/src/actions/viewInFolderAction.ts @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { isPublicShare } from '@nextcloud/sharing/public' +import { FileAction, FileType, Permission } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw' + +export const action = new FileAction({ + id: 'view-in-folder', + displayName() { + return t('files', 'View in folder') + }, + iconSvgInline: () => FolderMoveSvg, + + enabled(nodes: Node[], view: View) { + // Not enabled for public shares + if (isPublicShare()) { + return false + } + + // Only works outside of the main files view + if (view.id === 'files') { + return false + } + + // Only works on single node + if (nodes.length !== 1) { + return false + } + + const node = nodes[0] + + if (!node.isDavRessource) { + return false + } + + // Can only view files that are in the user root folder + if (!node.root?.startsWith('/files')) { + return false + } + + if (node.permissions === Permission.NONE) { + return false + } + + return node.type === FileType.File + }, + + async exec(node: Node) { + if (!node || node.type !== FileType.File) { + return false + } + + window.OCP.Files.Router.goToRoute( + null, + { view: 'files', fileid: String(node.fileid) }, + { dir: node.dirname }, + ) + return null + }, + + order: 80, +}) diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue new file mode 100644 index 00000000000..8458fd65f3d --- /dev/null +++ b/apps/files/src/components/BreadCrumbs.vue @@ -0,0 +1,310 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcBreadcrumbs data-cy-files-content-breadcrumbs + :aria-label="t('files', 'Current directory path')" + class="files-list__breadcrumbs" + :class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }"> + <!-- Current path sections --> + <NcBreadcrumb v-for="(section, index) in sections" + :key="section.dir" + v-bind="section" + dir="auto" + :to="section.to" + :force-icon-text="index === 0 && fileListWidth >= 486" + :title="titleForSection(index, section)" + :aria-description="ariaForSection(section)" + @click.native="onClick(section.to)" + @dragover.native="onDragOver($event, section.dir)" + @drop="onDrop($event, section.dir)"> + <template v-if="index === 0" #icon> + <NcIconSvgWrapper :size="20" + :svg="viewIcon" /> + </template> + </NcBreadcrumb> + + <!-- Forward the actions slot --> + <template #actions> + <slot name="actions" /> + </template> + </NcBreadcrumbs> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { FileSource } from '../types.ts' + +import { basename } from 'path' +import { defineComponent } from 'vue' +import { Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import HomeSvg from '@mdi/svg/svg/home.svg?raw' +import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb' +import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { useNavigation } from '../composables/useNavigation.ts' +import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { showError } from '@nextcloud/dialogs' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import logger from '../logger' + +export default defineComponent({ + name: 'BreadCrumbs', + + components: { + NcBreadcrumbs, + NcBreadcrumb, + NcIconSvgWrapper, + }, + + props: { + path: { + type: String, + default: '/', + }, + }, + + setup() { + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const fileListWidth = useFileListWidth() + const { currentView, views } = useNavigation() + + return { + draggingStore, + filesStore, + pathsStore, + selectionStore, + uploaderStore, + + currentView, + fileListWidth, + views, + } + }, + + computed: { + dirs(): string[] { + const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`) + // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc + const paths: string[] = this.path.split('/').filter(Boolean).map(cumulativePath('/')) + // Strip away trailing slash + return ['/', ...paths.map((path: string) => path.replace(/^(.+)\/$/, '$1'))] + }, + + sections() { + return this.dirs.map((dir: string, index: number) => { + const source = this.getFileSourceFromPath(dir) + const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined + return { + dir, + exact: true, + name: this.getDirDisplayName(dir), + to: this.getTo(dir, node), + // disable drop on current directory + disableDrop: index === this.dirs.length - 1, + } + }) + }, + + isUploadInProgress(): boolean { + return this.uploaderStore.queue.length !== 0 + }, + + // Hide breadcrumbs if an upload is ongoing + wrapUploadProgressBar(): boolean { + // if an upload is ongoing, and on small screens / mobile, then + // show the progress bar for the upload below breadcrumbs + return this.isUploadInProgress && this.fileListWidth < 512 + }, + + // used to show the views icon for the first breadcrumb + viewIcon(): string { + return this.currentView?.icon ?? HomeSvg + }, + + selectedFiles() { + return this.selectionStore.selected as FileSource[] + }, + + draggingFiles() { + return this.draggingStore.dragging as FileSource[] + }, + }, + + methods: { + getNodeFromSource(source: FileSource): Node | undefined { + return this.filesStore.getNode(source) + }, + getFileSourceFromPath(path: string): FileSource | null { + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null + }, + getDirDisplayName(path: string): string { + if (path === '/') { + return this.currentView?.name || t('files', 'Home') + } + + const source = this.getFileSourceFromPath(path) + const node = source ? this.getNodeFromSource(source) : undefined + return node?.displayname || basename(path) + }, + + getTo(dir: string, node?: Node): Record<string, unknown> { + if (dir === '/') { + return { + ...this.$route, + params: { view: this.currentView?.id }, + query: {}, + } + } + if (node === undefined) { + const view = this.views.find(view => view.params?.dir === dir) + return { + ...this.$route, + params: { fileid: view?.params?.fileid ?? '' }, + query: { dir }, + } + } + return { + ...this.$route, + params: { fileid: String(node.fileid) }, + query: { dir: node.path }, + } + }, + + onClick(to) { + if (to?.query?.dir === this.$route.query.dir) { + this.$emit('reload') + } + }, + + onDragOver(event: DragEvent, path: string) { + if (!event.dataTransfer) { + return + } + + // Cannot drop on the current directory + if (path === this.dirs[this.dirs.length - 1]) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + + async onDrop(event: DragEvent, path: string) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + // Do not stop propagation, so the main content + // drop event can be triggered too and clear the + // dragover state on the DragAndDropNotice component. + event.preventDefault() + + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + const canDrop = (folder.permissions & Permission.CREATE) !== 0 + const isCopy = event.ctrlKey + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!canDrop || event.button !== 0) { + return + } + + logger.debug('Dropped', { event, folder, selection, fileTree }) + + // Check whether we're uploading files + if (fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) + return + } + + // Else we're moving/copying files + const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (selection.some(source => this.selectedFiles.includes(source))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + + titleForSection(index, section) { + if (section?.to?.query?.dir === this.$route.query.dir) { + return t('files', 'Reload current directory') + } else if (index === 0) { + return t('files', 'Go to the "{dir}" directory', section) + } + return null + }, + + ariaForSection(section) { + if (section?.to?.query?.dir === this.$route.query.dir) { + return t('files', 'Reload current directory') + } + return null + }, + + t, + }, +}) +</script> + +<style lang="scss" scoped> +.files-list__breadcrumbs { + // Take as much space as possible + flex: 1 1 100% !important; + width: 100%; + height: 100%; + margin-block: 0; + margin-inline: 10px; + min-width: 0; + + :deep() { + a { + cursor: pointer !important; + } + } + + &--with-progress { + flex-direction: column !important; + align-items: flex-start !important; + } +} +</style> diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue new file mode 100644 index 00000000000..b08d3ba5ee5 --- /dev/null +++ b/apps/files/src/components/CustomElementRender.vue @@ -0,0 +1,54 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span /> +</template> + +<script lang="ts"> +/** + * This component is used to render custom + * elements provided by an API. Vue doesn't allow + * to directly render an HTMLElement, so we can do + * this magic here. + */ +export default { + name: 'CustomElementRender', + props: { + source: { + type: Object, + required: true, + }, + currentView: { + type: Object, + required: true, + }, + render: { + type: Function, + required: true, + }, + }, + watch: { + source() { + this.updateRootElement() + }, + currentView() { + this.updateRootElement() + }, + }, + mounted() { + this.updateRootElement() + }, + methods: { + async updateRootElement() { + const element = await this.render(this.source, this.currentView) + if (element) { + this.$el.replaceChildren(element) + } else { + this.$el.replaceChildren() + } + }, + }, +} +</script> diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue new file mode 100644 index 00000000000..c7684d5c205 --- /dev/null +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -0,0 +1,262 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div v-show="dragover" + data-cy-files-drag-drop-area + class="files-list__drag-drop-notice" + @drop="onDrop"> + <div class="files-list__drag-drop-notice-wrapper"> + <template v-if="canUpload && !isQuotaExceeded"> + <TrayArrowDownIcon :size="48" /> + <h3 class="files-list-drag-drop-notice__title"> + {{ t('files', 'Drag and drop files here to upload') }} + </h3> + </template> + + <!-- Not permitted to drop files here --> + <template v-else> + <h3 class="files-list-drag-drop-notice__title"> + {{ cantUploadLabel }} + </h3> + </template> + </div> + </div> +</template> + +<script lang="ts"> +import type { Folder } from '@nextcloud/files' + +import { Permission } from '@nextcloud/files' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { UploadStatus } from '@nextcloud/upload' +import { defineComponent, type PropType } from 'vue' +import debounce from 'debounce' + +import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' + +import { useNavigation } from '../composables/useNavigation' +import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import logger from '../logger.ts' +import type { RawLocation } from 'vue-router' + +export default defineComponent({ + name: 'DragAndDropNotice', + + components: { + TrayArrowDownIcon, + }, + + props: { + currentFolder: { + type: Object as PropType<Folder>, + required: true, + }, + }, + + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + + data() { + return { + dragover: false, + } + }, + + computed: { + /** + * Check if the current folder has create permissions + */ + canUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 + }, + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 + }, + + cantUploadLabel() { + if (this.isQuotaExceeded) { + return this.t('files', 'Your have used your space quota and cannot upload files anymore') + } else if (!this.canUpload) { + return this.t('files', 'You do not have permission to upload or create files here.') + } + return null + }, + + /** + * Debounced function to reset the drag over state + * Required as Firefox has a bug where no dragleave is emitted: + * https://bugzilla.mozilla.org/show_bug.cgi?id=656164 + */ + resetDragOver() { + return debounce(() => { + this.dragover = false + }, 3000) + }, + }, + + mounted() { + // Add events on parent to cover both the table and DragAndDrop notice + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement + mainContent.addEventListener('dragover', this.onDragOver) + mainContent.addEventListener('dragleave', this.onDragLeave) + mainContent.addEventListener('drop', this.onContentDrop) + }, + + beforeDestroy() { + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement + mainContent.removeEventListener('dragover', this.onDragOver) + mainContent.removeEventListener('dragleave', this.onDragLeave) + mainContent.removeEventListener('drop', this.onContentDrop) + }, + + methods: { + onDragOver(event: DragEvent) { + // Needed to keep the drag/drop events chain working + event.preventDefault() + + const isForeignFile = event.dataTransfer?.types.includes('Files') + if (isForeignFile) { + // Only handle uploading of outside files (not Nextcloud files) + this.dragover = true + this.resetDragOver() + } + }, + + onDragLeave(event: DragEvent) { + // Counter bubbling, make sure we're ending the drag + // only when we're leaving the current element + // Avoid flickering + const currentTarget = event.currentTarget as HTMLElement + if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) { + return + } + + if (this.dragover) { + this.dragover = false + this.resetDragOver.clear() + } + }, + + onContentDrop(event: DragEvent) { + logger.debug('Drag and drop cancelled, dropped on empty space', { event }) + event.preventDefault() + if (this.dragover) { + this.dragover = false + this.resetDragOver.clear() + } + }, + + async onDrop(event: DragEvent) { + // cantUploadLabel is null if we can upload + if (this.cantUploadLabel) { + showError(this.cantUploadLabel) + return + } + + if (this.$el.querySelector('tbody')?.contains(event.target as Node)) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Caching the selection + const items: DataTransferItem[] = [...event.dataTransfer?.items || []] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.currentFolder.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (event.button) { + return + } + + logger.debug('Dropped', { event, folder, fileTree }) + + // Check whether we're uploading files + const uploads = await onDropExternalFiles(fileTree, folder, contents.contents) + + // Scroll to last successful upload in current directory if terminated + const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED + && !upload.file.webkitRelativePath.includes('/') + && upload.response?.headers?.['oc-fileid'] + // Only use the last ID if it's in the current folder + && upload.source.replace(folder.source, '').split('/').length === 2) + + if (lastUpload !== undefined) { + logger.debug('Scrolling to last upload in current folder', { lastUpload }) + const location: RawLocation = { + path: this.$route.path, + // Keep params but change file id + params: { + ...this.$route.params, + fileid: String(lastUpload.response!.headers['oc-fileid']), + }, + query: { + ...this.$route.query, + }, + } + // Remove open file from query + delete location.query.openfile + this.$router.push(location) + } + + this.dragover = false + this.resetDragOver.clear() + }, + + t, + }, +}) +</script> + +<style lang="scss" scoped> +.files-list__drag-drop-notice { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + // Breadcrumbs height + row thead height + min-height: calc(58px + 44px); + margin: 0; + user-select: none; + color: var(--color-text-maxcontrast); + background-color: var(--color-main-background); + border-color: black; + + h3 { + margin-inline-start: 16px; + color: inherit; + } + + &-wrapper { + display: flex; + align-items: center; + justify-content: center; + height: 15vh; + max-height: 70%; + padding: 0 5vw; + border: 2px var(--color-border-dark) dashed; + border-radius: var(--border-radius-large); + } +} + +</style> diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue new file mode 100644 index 00000000000..72fd98d43fb --- /dev/null +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -0,0 +1,165 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="files-list-drag-image"> + <span class="files-list-drag-image__icon"> + <span ref="previewImg" /> + <FolderIcon v-if="isSingleFolder" /> + <FileMultipleIcon v-else /> + </span> + <span class="files-list-drag-image__name">{{ name }}</span> + </div> +</template> + +<script lang="ts"> +import { FileType, Node, formatFileSize } from '@nextcloud/files' +import Vue from 'vue' + +import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' + +import { getSummaryFor } from '../utils/fileUtils.ts' + +export default Vue.extend({ + name: 'DragAndDropPreview', + + components: { + FileMultipleIcon, + FolderIcon, + }, + + data() { + return { + nodes: [] as Node[], + } + }, + + computed: { + isSingleNode() { + return this.nodes.length === 1 + }, + isSingleFolder() { + return this.isSingleNode + && this.nodes[0].type === FileType.Folder + }, + + name() { + if (!this.size) { + return this.summary + } + return `${this.summary} – ${this.size}` + }, + size() { + const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0) + const size = parseInt(totalSize, 10) || 0 + if (typeof size !== 'number' || size < 0) { + return null + } + return formatFileSize(size, true) + }, + summary(): string { + if (this.isSingleNode) { + const node = this.nodes[0] + return node.attributes?.displayname || node.basename + } + + return getSummaryFor(this.nodes) + }, + }, + + methods: { + update(nodes: Node[]) { + this.nodes = nodes + this.$refs.previewImg.replaceChildren() + + // Clone icon node from the list + nodes.slice(0, 3).forEach(node => { + const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`) + if (preview) { + const previewElmt = this.$refs.previewImg as HTMLElement + previewElmt.appendChild(preview.parentNode.cloneNode(true)) + } + }) + + this.$nextTick(() => { + this.$emit('loaded', this.$el) + }) + }, + }, +}) +</script> + +<style lang="scss"> +$size: 28px; +$stack-shift: 6px; + +.files-list-drag-image { + position: absolute; + top: -9999px; + inset-inline-start: -9999px; + display: flex; + overflow: hidden; + align-items: center; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; + background: var(--color-main-background); + + &__icon, + .files-list__row-icon-preview-container { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + width: $size - $stack-shift; + height: $size - $stack-shift;; + border-radius: var(--border-radius); + } + + &__icon { + overflow: visible; + margin-inline-end: $stack-shift * 2; + + img { + max-width: 100%; + max-height: 100%; + } + + .material-design-icon { + color: var(--color-text-maxcontrast); + &.folder-icon { + color: var(--color-primary-element); + } + } + + // Previews container + > span { + display: flex; + + // Stack effect if more than one element + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { + margin-top: $stack-shift; + margin-inline-start: $stack-shift * 2 - $size; + & + .files-list__row-icon-preview-container { + margin-top: $stack-shift * 2; + } + } + + // If we have manually clone the preview, + // let's hide any fallback icons + &:not(:empty) + * { + display: none; + } + } + } + + &__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +</style> diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue new file mode 100644 index 00000000000..d66c3fa0ed7 --- /dev/null +++ b/apps/files/src/components/FileEntry.vue @@ -0,0 +1,276 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr :class="{ + 'files-list__row--dragover': dragover, + 'files-list__row--loading': isLoading, + 'files-list__row--active': isActive, + }" + data-cy-files-list-row + :data-cy-files-list-row-fileid="fileid" + :data-cy-files-list-row-name="source.basename" + :draggable="canDrag" + class="files-list__row" + v-on="rowListeners"> + <!-- Failed indicator --> + <span v-if="isFailedSource" class="files-list__row--failed" /> + + <!-- Checkbox --> + <FileEntryCheckbox :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" + :source="source" /> + + <!-- Link to file --> + <td class="files-list__row-name" data-cy-files-list-row-name> + <!-- Icon or preview --> + <FileEntryPreview ref="preview" + :source="source" + :dragover="dragover" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :basename="basename" + :extension="extension" + :nodes="nodes" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + </td> + + <!-- Actions --> + <FileEntryActions v-show="!isRenamingSmallScreen" + ref="actions" + :class="`files-list__row-actions-${uniqueId}`" + :opened.sync="openedMenu" + :source="source" /> + + <!-- Mime --> + <td v-if="isMimeAvailable" + :title="mime" + class="files-list__row-mime" + data-cy-files-list-row-mime + @click="openDetailsIfAvailable"> + <span>{{ mime }}</span> + </td> + + <!-- Size --> + <td v-if="!compact && isSizeAvailable" + :style="sizeOpacity" + class="files-list__row-size" + data-cy-files-list-row-size + @click="openDetailsIfAvailable"> + <span>{{ size }}</span> + </td> + + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + <span v-else>{{ t('files', 'Unknown date') }}</span> + </td> + + <!-- View columns --> + <td v-for="column in columns" + :key="column.id" + :class="`files-list__row-${currentView.id}-${column.id}`" + class="files-list__row-column-custom" + :data-cy-files-list-row-column-custom="column.id" + @click="openDetailsIfAvailable"> + <CustomElementRender :current-view="currentView" + :render="column.render" + :source="source" /> + </td> + </tr> +</template> + +<script lang="ts"> +import { FileType, formatFileSize } from '@nextcloud/files' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { useRenamingStore } from '../store/renaming.ts' +import { useSelectionStore } from '../store/selection.ts' + +import CustomElementRender from './CustomElementRender.vue' +import FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryMixin from './FileEntryMixin.ts' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' + +export default defineComponent({ + name: 'FileEntry', + + components: { + CustomElementRender, + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, + NcDateTime, + }, + + mixins: [ + FileEntryMixin, + ], + + props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const renamingStore = useRenamingStore() + const selectionStore = useSelectionStore() + const filesListWidth = useFileListWidth() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + + return { + actionsMenuStore, + draggingStore, + filesStore, + renamingStore, + selectionStore, + + currentDir, + currentFileId, + currentView, + filesListWidth, + } + }, + + computed: { + /** + * Conditionally add drag and drop listeners + * Do not add drag start and over listeners on renaming to allow to drag and drop text + */ + rowListeners() { + const conditionals = this.isRenaming + ? {} + : { + dragstart: this.onDragStart, + dragover: this.onDragOver, + } + + return { + ...conditionals, + contextmenu: this.onRightClick, + dragleave: this.onDragLeave, + dragend: this.onDragEnd, + drop: this.onDrop, + } + }, + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512 || this.compact) { + return [] + } + return this.currentView.columns || [] + }, + + mime() { + if (this.source.type === FileType.Folder) { + return this.t('files', 'Folder') + } + + if (!this.source.mime || this.source.mime === 'application/octet-stream') { + return t('files', 'Unknown file type') + } + + if (window.OC?.MimeTypeList?.names?.[this.source.mime]) { + return window.OC.MimeTypeList.names[this.source.mime] + } + + const baseType = this.source.mime.split('/')[0] + const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || '' + if (baseType === 'image') { + return t('files', '{ext} image', { ext }) + } + if (baseType === 'video') { + return t('files', '{ext} video', { ext }) + } + if (baseType === 'audio') { + return t('files', '{ext} audio', { ext }) + } + if (baseType === 'text') { + return t('files', '{ext} text', { ext }) + } + + return this.source.mime + }, + size() { + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return this.t('files', 'Pending') + } + return formatFileSize(size, true) + }, + + sizeOpacity() { + const maxOpacitySize = 10 * 1024 * 1024 + + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return {} + } + + const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2))) + return { + color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, + } + }, + }, + + created() { + useHotKey('Enter', this.triggerDefaultAction, { + stop: true, + prevent: true, + }) + }, + + methods: { + formatFileSize, + + triggerDefaultAction() { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } + + this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue new file mode 100644 index 00000000000..e22b30f4378 --- /dev/null +++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue @@ -0,0 +1,45 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <span :aria-hidden="!title" + :aria-label="title" + class="material-design-icon collectives-icon" + role="img" + v-bind="$attrs" + @click="$emit('click', $event)"> + <svg :fill="fillColor" + class="material-design-icon__svg" + :width="size" + :height="size" + viewBox="0 0 16 16"> + <path d="M2.9,8.8c0-1.2,0.4-2.4,1.2-3.3L0.3,6c-0.2,0-0.3,0.3-0.1,0.4l2.7,2.6C2.9,9,2.9,8.9,2.9,8.8z" /> + <path d="M8,3.7c0.7,0,1.3,0.1,1.9,0.4L8.2,0.6c-0.1-0.2-0.3-0.2-0.4,0L6.1,4C6.7,3.8,7.3,3.7,8,3.7z" /> + <path d="M3.7,11.5L3,15.2c0,0.2,0.2,0.4,0.4,0.3l3.3-1.7C5.4,13.4,4.4,12.6,3.7,11.5z" /> + <path d="M15.7,6l-3.7-0.5c0.7,0.9,1.2,2,1.2,3.3c0,0.1,0,0.2,0,0.3l2.7-2.6C15.9,6.3,15.9,6.1,15.7,6z" /> + <path d="M12.3,11.5c-0.7,1.1-1.8,1.9-3,2.2l3.3,1.7c0.2,0.1,0.4-0.1,0.4-0.3L12.3,11.5z" /> + <path d="M9.6,10.1c-0.4,0.5-1,0.8-1.6,0.8c-1.1,0-2-0.9-2.1-2C5.9,7.7,6.8,6.7,8,6.7c0.6,0,1.1,0.3,1.5,0.7 c0.1,0.1,0.1,0.1,0.2,0.1h1.4c0.2,0,0.4-0.2,0.3-0.5c-0.7-1.3-2.1-2.2-3.8-2.1C5.8,5,4.3,6.6,4.1,8.5C4,10.8,5.8,12.7,8,12.7 c1.6,0,2.9-0.9,3.5-2.3c0.1-0.2-0.1-0.4-0.3-0.4H9.9C9.8,10,9.7,10,9.6,10.1z" /> + </svg> + </span> +</template> + +<script> +export default { + name: 'CollectivesIcon', + props: { + title: { + type: String, + default: '', + }, + fillColor: { + type: String, + default: 'currentColor', + }, + size: { + type: Number, + default: 24, + }, + }, +} +</script> diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue new file mode 100644 index 00000000000..c66cb8fbd7f --- /dev/null +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -0,0 +1,76 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" /> +</template> + +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import StarSvg from '@mdi/svg/svg/star.svg?raw' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +/** + * A favorite icon to be used for overlaying favorite entries like the file preview / icon + * It has a stroke around the star icon to ensure enough contrast for accessibility. + * + * If the background has a hover state you might want to also apply it to the stroke like this: + * ```scss + * .parent:hover :deep(.favorite-marker-icon svg path) { + * stroke: var(--color-background-hover); + * } + * ``` + */ +export default defineComponent({ + name: 'FavoriteIcon', + components: { + NcIconSvgWrapper, + }, + data() { + return { + StarSvg, + } + }, + async mounted() { + await this.$nextTick() + // MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it + const el = this.$el.querySelector('svg') + el?.setAttribute?.('viewBox', '-4 -4 30 30') + }, + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> +.favorite-marker-icon { + color: var(--color-favorite); + // Override NcIconSvgWrapper defaults (clickable area) + min-width: unset !important; + min-height: unset !important; + + :deep() { + svg { + // We added a stroke for a11y so we must increase the size to include the stroke + width: 20px !important; + height: 20px !important; + + // Override NcIconSvgWrapper defaults of 20px + max-width: unset !important; + max-height: unset !important; + + // Sow a border around the icon for better contrast + path { + stroke: var(--color-main-background); + stroke-width: 8px; + stroke-linejoin: round; + paint-order: stroke; + } + } + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue new file mode 100644 index 00000000000..5c537d878fe --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -0,0 +1,399 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <td class="files-list__row-actions" + data-cy-files-list-row-actions> + <!-- Render actions --> + <CustomElementRender v-for="action in enabledRenderActions" + :key="action.id" + :class="'files-list__row-action-' + action.id" + :current-view="currentView" + :render="action.renderInline" + :source="source" + class="files-list__row-action--inline" /> + + <!-- Menu actions --> + <NcActions ref="actionsMenu" + :boundaries-element="getBoundariesElement" + :container="getBoundariesElement" + :force-name="true" + type="tertiary" + :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" + :inline="enabledInlineActions.length" + :open="openedMenu" + @close="onMenuClose" + @closed="onMenuClosed"> + <!-- Non-destructive actions list --> + <!-- Please keep this block in sync with the destructive actions block below --> + <NcActionButton v-for="action, index in renderedNonDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + + <!-- Destructive actions list --> + <template v-if="renderedDestructiveActions.length > 0"> + <NcActionSeparator /> + <NcActionButton v-for="action, index in renderedDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + 'files-list__row-action--destructive': true, + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + + <!-- Submenu actions list--> + <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> + <!-- Back to top-level button --> + <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> + <template #icon> + <ArrowLeftIcon /> + </template> + {{ t('files', 'Back') }} + </NcActionButton> + <NcActionSeparator /> + + <!-- Submenu actions --> + <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]" + :key="action.id" + :class="`files-list__row-action-${action.id}`" + class="files-list__row-action--submenu" + close-after-click + :data-cy-files-list-row-action="action.id" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + </NcActions> + </td> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { FileAction, Node } from '@nextcloud/files' + +import { DefaultType, NodeStatus } from '@nextcloud/files' +import { defineComponent, inject } from 'vue' +import { t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' + +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' +import CustomElementRender from '../CustomElementRender.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { executeAction } from '../../utils/actionUtils.ts' +import { useActiveStore } from '../../store/active.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import actionsMixins from '../../mixins/actionsMixin.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryActions', + + components: { + ArrowLeftIcon, + CustomElementRender, + NcActionButton, + NcActions, + NcActionSeparator, + NcIconSvgWrapper, + NcLoadingIcon, + }, + + mixins: [actionsMixins], + + props: { + opened: { + type: Boolean, + default: false, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory: currentDir } = useRouteParameters() + + const activeStore = useActiveStore() + const filesListWidth = useFileListWidth() + const enabledFileActions = inject<FileAction[]>('enabledFileActions', []) + return { + activeStore, + currentDir, + currentView, + enabledFileActions, + filesListWidth, + t, + } + }, + + computed: { + isActive() { + return this.activeStore?.activeNode?.source === this.source.source + }, + + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + // Enabled action that are displayed inline + enabledInlineActions() { + if (this.filesListWidth < 768 || this.gridMode) { + return [] + } + return this.enabledFileActions.filter(action => { + try { + return action?.inline?.(this.source, this.currentView) + } catch (error) { + logger.error('Error while checking if action is inline', { action, error }) + return false + } + }) + }, + + // Enabled action that are displayed inline with a custom render function + enabledRenderActions() { + if (this.gridMode) { + return [] + } + return this.enabledFileActions.filter(action => typeof action.renderInline === 'function') + }, + + // Actions shown in the menu + enabledMenuActions() { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + const actions = [ + // Showing inline first for the NcActions inline prop + ...this.enabledInlineActions, + // Then the rest + ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'), + ].filter((value, index, self) => { + // Then we filter duplicates to prevent inline actions to be shown twice + return index === self.findIndex(action => action.id === value.id) + }) + + // Generate list of all top-level actions ids + const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[] + + // Filter actions that are not top-level AND have a valid parent + return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) + }, + + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) + }, + + openedMenu: { + get() { + return this.opened + }, + set(value) { + this.$emit('update:opened', value) + }, + }, + + /** + * Making this a function in case the files-list + * reference changes in the future. That way we're + * sure there is one at the time we call it. + */ + getBoundariesElement() { + return document.querySelector('.app-content > .files-list') + }, + }, + + watch: { + // Close any submenu when the menu state changes + openedMenu() { + this.openedSubmenu = null + }, + }, + + created() { + useHotKey('Escape', this.onKeyDown, { + stop: true, + prevent: true, + }) + + useHotKey('a', this.onKeyDown, { + stop: true, + prevent: true, + }) + }, + + methods: { + actionDisplayName(action: FileAction) { + try { + if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') { + // if an inline action is rendered in the menu for + // lack of space we use the title first if defined + const title = action.title([this.source], this.currentView) + if (title) return title + } + return action.displayName([this.source], this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + // Not ideal, but better than nothing + return action.id + } + }, + + isLoadingAction(action: FileAction) { + if (!this.isActive) { + return false + } + return this.activeStore?.activeAction?.id === action.id + }, + + async onActionClick(action) { + // If the action is a submenu, we open it + if (this.enabledSubmenuActions[action.id]) { + this.openedSubmenu = action + return + } + + // Make sure we set the node as active + this.activeStore.activeNode = this.source + + // Execute the action + await executeAction(action) + }, + + onKeyDown(event: KeyboardEvent) { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } + + // ESC close the action menu if opened + if (event.key === 'Escape' && this.openedMenu) { + this.openedMenu = false + } + + // a open the action menu + if (event.key === 'a' && !this.openedMenu) { + this.openedMenu = true + } + }, + + onMenuClose() { + // We reset the submenu state when the menu is closing + this.openedSubmenu = null + }, + + onMenuClosed() { + // We reset the actions menu state when the menu is finally closed + this.openedMenu = false + }, + }, +}) +</script> + +<style lang="scss"> +// Allow right click to define the position of the menu +// only if defined +main.app-content[style*="mouse-pos-x"] .v-popper__popper { + transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important; + + // If the menu is too close to the bottom, we move it up + &[data-popper-placement="top"] { + // 34px added to align with the top of the cursor + transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important; + } + // Hide arrow if floating + .v-popper__arrow-container { + display: none; + } +} +</style> + +<style scoped lang="scss"> +.files-list__row-action { + --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline)); + + // inline icons can have clickable area size so they still fit into the row + &.files-list__row-action--inline { + --max-icon-size: var(--default-clickable-area); + } + + // Some icons exceed the default size so we need to enforce a max width and height + .files-list__row-action-icon :deep(svg) { + max-height: var(--max-icon-size) !important; + max-width: var(--max-icon-size) !important; + } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } + } +} + +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue new file mode 100644 index 00000000000..5b80a971118 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -0,0 +1,173 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <td class="files-list__row-checkbox" + @keyup.esc.exact="resetSelection"> + <NcLoadingIcon v-if="isLoading" :name="loadingLabel" /> + <NcCheckboxRadioSwitch v-else + :aria-label="ariaLabel" + :checked="isSelected" + data-cy-files-list-row-checkbox + @update:checked="onSelectionChange" /> + </td> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../../types.ts' + +import { FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { useActiveStore } from '../../store/active.ts' +import { useKeyboardStore } from '../../store/keyboard.ts' +import { useSelectionStore } from '../../store/selection.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryCheckbox', + + components: { + NcCheckboxRadioSwitch, + NcLoadingIcon, + }, + + props: { + fileid: { + type: Number, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + }, + + setup() { + const selectionStore = useSelectionStore() + const keyboardStore = useKeyboardStore() + const activeStore = useActiveStore() + + return { + activeStore, + keyboardStore, + selectionStore, + t, + } + }, + + computed: { + isActive() { + return this.activeStore.activeNode?.source === this.source.source + }, + + selectedFiles() { + return this.selectionStore.selected + }, + isSelected() { + return this.selectedFiles.includes(this.source.source) + }, + index() { + return this.nodes.findIndex((node: Node) => node.source === this.source.source) + }, + isFile() { + return this.source.type === FileType.File + }, + ariaLabel() { + return this.isFile + ? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename }) + : t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename }) + }, + loadingLabel() { + return this.isFile + ? t('files', 'File is loading') + : t('files', 'Folder is loading') + }, + }, + + created() { + // ctrl+space toggle selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + }) + + // ctrl+shift+space toggle range selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + shift: true, + }) + }, + + methods: { + onSelectionChange(selected: boolean) { + const newSelectedIndex = this.index + const lastSelectedIndex = this.selectionStore.lastSelectedIndex + + // Get the last selected and select all files in between + if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) { + const isAlreadySelected = this.selectedFiles.includes(this.source.source) + + const start = Math.min(newSelectedIndex, lastSelectedIndex) + const end = Math.max(lastSelectedIndex, newSelectedIndex) + + const lastSelection = this.selectionStore.lastSelection + const filesToSelect = this.nodes + .map(file => file.source) + .slice(start, end + 1) + .filter(Boolean) as FileSource[] + + // If already selected, update the new selection _without_ the current file + const selection = [...lastSelection, ...filesToSelect] + .filter(source => !isAlreadySelected || source !== this.source.source) + + logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected }) + // Keep previous lastSelectedIndex to be use for further shift selections + this.selectionStore.set(selection) + return + } + + const selection = selected + ? [...this.selectedFiles, this.source.source] + : this.selectedFiles.filter(source => source !== this.source.source) + + logger.debug('Updating selection', { selection }) + this.selectionStore.set(selection) + this.selectionStore.setLastIndex(newSelectedIndex) + }, + + resetSelection() { + this.selectionStore.reset() + }, + + onToggleSelect() { + // Don't react if the node is not active + if (!this.isActive) { + return + } + + logger.debug('Toggling selection for file', { source: this.source }) + this.onSelectionChange(!this.isSelected) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue new file mode 100644 index 00000000000..418f9581eb6 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,288 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + ref="renameForm" + v-on-click-outside="onRename" + :aria-label="t('files', 'Rename file')" + class="files-list__row-rename" + @submit.prevent.stop="onRename"> + <NcTextField ref="renameInput" + :label="renameLabel" + :autofocus="true" + :minlength="1" + :required="true" + :value.sync="newName" + enterkeyhint="done" + @keyup.esc="stopRenaming" /> + </form> + + <component :is="linkTo.is" + v-else + ref="basename" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + v-bind="linkTo.params"> + <!-- Filename --> + <span class="files-list__row-name-text" dir="auto"> + <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="basename" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> + </span> + </component> +</template> + +<script lang="ts"> +import type { FileAction, Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { showError, showSuccess } from '@nextcloud/dialogs' +import { FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent, inject } from 'vue' + +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation.ts' +import { useRenamingStore } from '../../store/renaming.ts' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + /** + * The filename without extension + */ + basename: { + type: String, + required: true, + }, + /** + * The extension of the filename + */ + extension: { + type: String, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() + const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') + + return { + currentView, + defaultFileAction, + directory, + filesListWidth, + + renamingStore, + userConfigStore, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get(): string { + return this.renamingStore.newNodeName + }, + set(newName: string) { + this.renamingStore.newNodeName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'Filename'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.status === NodeStatus.FAILED) { + return { + is: 'span', + params: { + title: t('files', 'This node is unavailable'), + }, + } + } + + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) + return { + is: 'button', + params: { + 'aria-label': displayName, + title: displayName, + tabindex: '0', + }, + } + } + + // nothing interactive here, there is no default action + // so if not even the download action works we only can show the list entry + return { + is: 'span', + } + }, + }, + + watch: { + /** + * If renaming starts, select the filename + * in the input, without the extension. + * @param renaming + */ + isRenaming: { + immediate: true, + handler(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + newName() { + // Check validity of the new name + const newName = this.newName.trim?.() || '' + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return + } + + let validity = getFilenameValidity(newName) + // Checking if already exists + if (validity === '' && this.checkIfNodeExists(newName)) { + validity = t('files', 'Another entry with the same name already exists.') + } + this.$nextTick(() => { + if (this.isRenaming) { + input.setCustomValidity(validity) + input.reportValidity() + } + }) + }, + }, + + methods: { + checkIfNodeExists(name: string) { + return this.nodes.find(node => node.basename === name && node !== this.source) + }, + + startRenaming() { + this.$nextTick(() => { + // Using split to get the true string length + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + logger.error('Could not find the rename input') + return + } + input.focus() + const length = this.source.basename.length - (this.source.extension ?? '').length + input.setSelectionRange(0, length) + + // Trigger a keyup event to update the input validity + input.dispatchEvent(new Event('keyup')) + }) + }, + + stopRenaming() { + if (!this.isRenaming) { + return + } + + // Reset the renaming store + this.renamingStore.$reset() + }, + + // Rename and move the file + async onRename() { + const newName = this.newName.trim?.() || '' + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) + return + } + + const oldName = this.source.basename + if (newName === oldName) { + this.stopRenaming() + return + } + + try { + const status = await this.renamingStore.rename() + if (status) { + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) + this.$nextTick(() => { + const nameContainer = this.$refs.basename as HTMLElement | undefined + nameContainer?.focus() + }) + } else { + // Was cancelled - meaning the renaming state is just reset + } + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() + } + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +button.files-list__row-name-link { + background-color: unset; + border: none; + font-weight: normal; + + &:active { + // No active styles - handled by the row entry + background-color: unset !important; + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue new file mode 100644 index 00000000000..3d0fffe7584 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -0,0 +1,300 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <span class="files-list__row-icon"> + <template v-if="source.type === 'folder'"> + <FolderOpenIcon v-if="dragover" v-once /> + <template v-else> + <FolderIcon v-once /> + <OverlayIcon :is="folderOverlay" + v-if="folderOverlay" + class="files-list__row-icon-overlay" /> + </template> + </template> + + <!-- Decorative images, should not be aria documented --> + <span v-else-if="previewUrl" class="files-list__row-icon-preview-container"> + <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)" + ref="canvas" + class="files-list__row-icon-blurhash" + aria-hidden="true" /> + <img v-if="backgroundFailed !== true" + :key="source.fileid" + ref="previewImg" + alt="" + class="files-list__row-icon-preview" + :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" + loading="lazy" + :src="previewUrl" + @error="onBackgroundError" + @load="onBackgroundLoad"> + </span> + + <FileIcon v-else v-once /> + + <!-- Favorite icon --> + <span v-if="isFavorite" class="files-list__row-icon-favorite"> + <FavoriteIcon v-once /> + </span> + + <OverlayIcon :is="fileOverlay" + v-if="fileOverlay" + class="files-list__row-icon-overlay files-list__row-icon-overlay--file" /> + </span> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { UserConfig } from '../../types.ts' + +import { Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { decode } from 'blurhash' +import { defineComponent } from 'vue' + +import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue' +import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' +import FileIcon from 'vue-material-design-icons/File.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' +import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue' +import KeyIcon from 'vue-material-design-icons/Key.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue' +import TagIcon from 'vue-material-design-icons/Tag.vue' +import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue' + +import CollectivesIcon from './CollectivesIcon.vue' +import FavoriteIcon from './FavoriteIcon.vue' + +import { isLivePhoto } from '../../services/LivePhotos' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryPreview', + + components: { + AccountGroupIcon, + AccountPlusIcon, + CollectivesIcon, + FavoriteIcon, + FileIcon, + FolderIcon, + FolderOpenIcon, + KeyIcon, + LinkIcon, + NetworkIcon, + TagIcon, + }, + + props: { + source: { + type: Object as PropType<Node>, + required: true, + }, + dragover: { + type: Boolean, + default: false, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + const userConfigStore = useUserConfigStore() + const isPublic = isPublicShare() + const publicSharingToken = getSharingToken() + + return { + userConfigStore, + + isPublic, + publicSharingToken, + } + }, + + data() { + return { + backgroundFailed: undefined as boolean | undefined, + backgroundLoaded: false, + } + }, + + computed: { + isFavorite(): boolean { + return this.source.attributes.favorite === 1 + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + cropPreviews(): boolean { + return this.userConfig.crop_image_previews === true + }, + + previewUrl() { + if (this.source.type === FileType.Folder) { + return null + } + + if (this.backgroundFailed === true) { + return null + } + + if (this.source.attributes['has-preview'] !== true + && this.source.mime !== undefined + && this.source.mime !== 'application/octet-stream' + ) { + const previewUrl = generateUrl('/core/mimeicon?mime={mime}', { + mime: this.source.mime, + }) + const url = new URL(window.location.origin + previewUrl) + return url.href + } + + try { + const previewUrl = this.source.attributes.previewUrl + || (this.isPublic + ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', { + token: this.publicSharingToken, + file: this.source.path, + }) + : generateUrl('/core/preview?fileId={fileid}', { + fileid: String(this.source.fileid), + }) + ) + const url = new URL(window.location.origin + previewUrl) + + // Request tiny previews + url.searchParams.set('x', this.gridMode ? '128' : '32') + url.searchParams.set('y', this.gridMode ? '128' : '32') + url.searchParams.set('mimeFallback', 'true') + + // Etag to force refresh preview on change + const etag = this.source?.attributes?.etag || '' + url.searchParams.set('v', etag.slice(0, 6)) + + // Handle cropping + url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') + return url.href + } catch (e) { + return null + } + }, + + fileOverlay() { + if (isLivePhoto(this.source)) { + return PlayCircleIcon + } + + return null + }, + + folderOverlay() { + if (this.source.type !== FileType.Folder) { + return null + } + + // Encrypted folders + if (this.source?.attributes?.['is-encrypted'] === 1) { + return KeyIcon + } + + // System tags + if (this.source?.attributes?.['is-tag']) { + return TagIcon + } + + // Link and mail shared folders + const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[] + if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) { + return LinkIcon + } + + // Shared folders + if (shareTypes.length > 0) { + return AccountPlusIcon + } + + switch (this.source?.attributes?.['mount-type']) { + case 'external': + case 'external-session': + return NetworkIcon + case 'group': + return AccountGroupIcon + case 'collective': + return CollectivesIcon + case 'shared': + return AccountPlusIcon + } + + return null + }, + + hasBlurhash() { + return this.source.attributes['metadata-blurhash'] !== undefined + }, + }, + + mounted() { + if (this.hasBlurhash && this.$refs.canvas) { + this.drawBlurhash() + } + }, + + methods: { + // Called from FileEntry + reset() { + // Reset background state to cancel any ongoing requests + this.backgroundFailed = undefined + this.backgroundLoaded = false + const previewImg = this.$refs.previewImg as HTMLImageElement | undefined + if (previewImg) { + previewImg.src = '' + } + }, + + onBackgroundLoad() { + this.backgroundFailed = false + this.backgroundLoaded = true + }, + + onBackgroundError(event) { + // Do not fail if we just reset the background + if (event.target?.src === '') { + return + } + this.backgroundFailed = true + this.backgroundLoaded = false + }, + + drawBlurhash() { + const canvas = this.$refs.canvas as HTMLCanvasElement + + const width = canvas.width + const height = canvas.height + + const pixels = decode(this.source.attributes['metadata-blurhash'], width, height) + + const ctx = canvas.getContext('2d') + if (ctx === null) { + logger.error('Cannot create context for blurhash canvas') + return + } + + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) + }, + + t, + }, +}) +</script> diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue new file mode 100644 index 00000000000..1bd0572f53b --- /dev/null +++ b/apps/files/src/components/FileEntryGrid.vue @@ -0,0 +1,135 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" + data-cy-files-list-row + :data-cy-files-list-row-fileid="fileid" + :data-cy-files-list-row-name="source.basename" + :draggable="canDrag" + class="files-list__row" + @contextmenu="onRightClick" + @dragover="onDragOver" + @dragleave="onDragLeave" + @dragstart="onDragStart" + @dragend="onDragEnd" + @drop="onDrop"> + <!-- Failed indicator --> + <span v-if="isFailedSource" class="files-list__row--failed" /> + + <!-- Checkbox --> + <FileEntryCheckbox :fileid="fileid" + :is-loading="isLoading" + :nodes="nodes" + :source="source" /> + + <!-- Link to file --> + <td class="files-list__row-name" data-cy-files-list-row-name> + <!-- Icon or preview --> + <FileEntryPreview ref="preview" + :dragover="dragover" + :grid-mode="true" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + + <FileEntryName ref="name" + :basename="basename" + :extension="extension" + :grid-mode="true" + :nodes="nodes" + :source="source" + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + </td> + + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + </td> + + <!-- Actions --> + <FileEntryActions ref="actions" + :class="`files-list__row-actions-${uniqueId}`" + :grid-mode="true" + :opened.sync="openedMenu" + :source="source" /> + </tr> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' + +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useDragAndDropStore } from '../store/dragging.ts' +import { useFilesStore } from '../store/files.ts' +import { useRenamingStore } from '../store/renaming.ts' +import { useSelectionStore } from '../store/selection.ts' +import FileEntryMixin from './FileEntryMixin.ts' +import FileEntryActions from './FileEntry/FileEntryActions.vue' +import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' +import FileEntryName from './FileEntry/FileEntryName.vue' +import FileEntryPreview from './FileEntry/FileEntryPreview.vue' + +export default defineComponent({ + name: 'FileEntryGrid', + + components: { + FileEntryActions, + FileEntryCheckbox, + FileEntryName, + FileEntryPreview, + NcDateTime, + }, + + mixins: [ + FileEntryMixin, + ], + + inheritAttrs: false, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const draggingStore = useDragAndDropStore() + const filesStore = useFilesStore() + const renamingStore = useRenamingStore() + const selectionStore = useSelectionStore() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + + return { + actionsMenuStore, + draggingStore, + filesStore, + renamingStore, + selectionStore, + + currentDir, + currentFileId, + currentView, + } + }, + + data() { + return { + gridMode: true, + } + }, +}) +</script> diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts new file mode 100644 index 00000000000..735490c45b3 --- /dev/null +++ b/apps/files/src/components/FileEntryMixin.ts @@ -0,0 +1,509 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + +import { extname } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { isPublicShare } from '@nextcloud/sharing/public' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { vOnClickOutside } from '@vueuse/components' +import Vue, { computed, defineComponent } from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { hashCode } from '../utils/hashUtils.ts' +import { isDownloadable } from '../utils/permissions.ts' +import logger from '../logger.ts' + +Vue.directive('onClickOutside', vOnClickOutside) + +const actions = getFileActions() + +export default defineComponent({ + props: { + source: { + type: [Folder, NcFile, Node] as PropType<Node>, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + compact: { + type: Boolean, + default: false, + }, + }, + + provide() { + return { + defaultFileAction: computed(() => this.defaultFileAction), + enabledFileActions: computed(() => this.enabledFileActions), + } + }, + + data() { + return { + dragover: false, + gridMode: false, + } + }, + + computed: { + fileid() { + return this.source.fileid ?? 0 + }, + + uniqueId() { + return hashCode(this.source.source) + }, + + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + /** + * The display name of the current node + * Either the nodes filename or a custom display name (e.g. for shares) + */ + displayName() { + // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0 + return this.source.displayname || this.source.basename + }, + /** + * The display name without extension + */ + basename() { + if (this.extension === '') { + return this.displayName + } + return this.displayName.slice(0, 0 - this.extension.length) + }, + /** + * The extension of the file + */ + extension() { + if (this.source.type === FileType.Folder) { + return '' + } + + return extname(this.displayName) + }, + + draggingFiles() { + return this.draggingStore.dragging as FileSource[] + }, + selectedFiles() { + return this.selectionStore.selected as FileSource[] + }, + isSelected() { + return this.selectedFiles.includes(this.source.source) + }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + + isActive() { + return String(this.fileid) === String(this.currentFileId) + }, + + /** + * Check if the source is in a failed state after an API request + */ + isFailedSource() { + return this.source.status === NodeStatus.FAILED + }, + + canDrag(): boolean { + if (this.isRenaming) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return false + } + + const canDrag = (node: Node): boolean => { + return (node?.permissions & Permission.UPDATE) !== 0 + } + + // If we're dragging a selection, we need to check all files + if (this.selectedFiles.length > 0) { + const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[] + return nodes.every(canDrag) + } + return canDrag(this.source) + }, + + canDrop(): boolean { + if (this.source.type !== FileType.Folder) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return false + } + + // If the current folder is also being dragged, we can't drop it on itself + if (this.draggingFiles.includes(this.source.source)) { + return false + } + + return (this.source.permissions & Permission.CREATE) !== 0 + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId.toString() + }, + set(opened) { + // If the menu is opened on another file entry, we ignore closed events + if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) { + return + } + + // If opened, we specify the current file id + // else we set it to null to close the menu + this.actionsMenuStore.opened = opened + ? this.uniqueId.toString() + : null + }, + }, + + mtime() { + // If the mtime is not a valid date, return it as is + if (this.source.mtime && !isNaN(this.source.mtime.getDate())) { + return this.source.mtime + } + + if (this.source.crtime && !isNaN(this.source.crtime.getDate())) { + return this.source.crtime + } + + return null + }, + + mtimeOpacity() { + if (!this.mtime) { + return {} + } + + // The time when we start reducing the opacity + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + // everything older than the maxOpacityTime will have the same value + const timeDiff = Date.now() - this.mtime.getTime() + if (timeDiff < 0) { + // this means we have an invalid mtime which is in the future! + return {} + } + + // inversed time difference from 0 to maxOpacityTime (which would mean today) + const opacityTime = Math.max(0, maxOpacityTime - timeDiff) + // 100 = today, 0 = 31 days ago or older + const percentage = Math.round(opacityTime * 100 / maxOpacityTime) + return { + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, + } + }, + + /** + * Sorted actions that are enabled for this node + */ + enabledFileActions() { + if (this.source.status === NodeStatus.FAILED) { + return [] + } + + return actions + .filter(action => { + if (!action.enabled) { + return true + } + + // In case something goes wrong, since we don't want to break + // the entire list, we filter out actions that throw an error. + try { + return action.enabled([this.source], this.currentView) + } catch (error) { + logger.error('Error while checking action', { action, error }) + return false + } + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + defaultFileAction() { + return this.enabledFileActions.find((action) => action.default !== undefined) + }, + }, + + watch: { + /** + * When the source changes, reset the preview + * and fetch the new one. + * @param newSource The new value of the source prop + * @param oldSource The previous value + */ + source(newSource: Node, oldSource: Node) { + if (newSource.source !== oldSource.source) { + this.resetState() + } + }, + + openedMenu() { + // Checking if the menu is really closed and not + // just a change in the open state to another file entry. + if (this.actionsMenuStore.opened === null) { + // Reset any right menu position potentially set + logger.debug('All actions menu closed, resetting right menu position...') + const root = this.$el?.closest('main.app-content') as HTMLElement + if (root !== null) { + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + } + }, + }, + + beforeDestroy() { + this.resetState() + }, + + methods: { + resetState() { + // Reset the preview state + this.$refs?.preview?.reset?.() + + // Close menu + this.openedMenu = false + }, + + // Open the actions menu on right click + onRightClick(event) { + // If already opened, fallback to default browser + if (this.openedMenu) { + return + } + + // Ignore right click if the node is not available + if (this.isFailedSource) { + return + } + + // The grid mode is compact enough to not care about + // the actions menu mouse position + if (!this.gridMode) { + // Actions menu is contained within the app content + const root = this.$el?.closest('main.app-content') as HTMLElement + const contentRect = root.getBoundingClientRect() + // Using Math.min/max to prevent the menu from going out of the AppContent + // 200 = max width of the menu + logger.debug('Setting actions menu position...') + root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px') + root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px') + } else { + // Reset any right menu position potentially set + const root = this.$el?.closest('main.app-content') as HTMLElement + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + + // If the clicked row is in the selection, open global menu + const isMoreThanOneSelected = this.selectedFiles.length > 1 + this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId.toString() + + // Prevent any browser defaults + event.preventDefault() + event.stopPropagation() + }, + + execDefaultAction(event: MouseEvent) { + // Ignore click if we are renaming + if (this.isRenaming) { + return + } + + // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4) + if (Boolean(event.button & 2) || event.button > 4) { + return + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return + } + + // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab + // also if there is no default action use this as a fallback + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 + if (metaKeyPressed || !this.defaultFileAction) { + // If no download permission, then we can not allow to download (direct link) the files + if (isPublicShare() && !isDownloadable(this.source)) { + return + } + + const url = isPublicShare() + ? this.source.encodedSource + : generateUrl('/f/{fileId}', { fileId: this.fileid }) + event.preventDefault() + event.stopPropagation() + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') + return + } + + // every special case handled so just execute the default action + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.defaultFileAction.exec(this.source, this.currentView, this.currentDir) + }, + + openDetailsIfAvailable(event) { + event.preventDefault() + event.stopPropagation() + if (sidebarAction?.enabled?.([this.source], this.currentView)) { + sidebarAction.exec(this.source, this.currentView, this.currentDir) + } + }, + + onDragOver(event: DragEvent) { + this.dragover = this.canDrop + if (!this.canDrop) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + onDragLeave(event: DragEvent) { + // Counter bubbling, make sure we're ending the drag + // only when we're leaving the current element + const currentTarget = event.currentTarget as HTMLElement + if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { + return + } + + this.dragover = false + }, + + async onDragStart(event: DragEvent) { + event.stopPropagation() + if (!this.canDrag || !this.fileid) { + event.preventDefault() + event.stopPropagation() + return + } + + logger.debug('Drag started', { event }) + + // Make sure that we're not dragging a file like the preview + event.dataTransfer?.clearData?.() + + // Reset any renaming + this.renamingStore.$reset() + + // Dragging set of files, if we're dragging a file + // that is already selected, we use the entire selection + if (this.selectedFiles.includes(this.source.source)) { + this.draggingStore.set(this.selectedFiles) + } else { + this.draggingStore.set([this.source.source]) + } + + const nodes = this.draggingStore.dragging + .map(source => this.filesStore.getNode(source)) as Node[] + + const image = await getDragAndDropPreview(nodes) + event.dataTransfer?.setDragImage(image, -10, -10) + }, + onDragEnd() { + this.draggingStore.reset() + this.dragover = false + logger.debug('Drag ended') + }, + + async onDrop(event: DragEvent) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.source.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!this.canDrop || event.button) { + return + } + + const isCopy = event.ctrlKey + this.dragover = false + + logger.debug('Dropped', { event, folder, selection, fileTree }) + + // Check whether we're uploading files + if (selection.length === 0 && fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) + return + } + + // Else we're moving/copying files + const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (selection.some(source => this.selectedFiles.includes(source))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + + t, + }, +}) diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue new file mode 100644 index 00000000000..bd3ac867ed5 --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilter.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcActions force-menu + :type="isActive ? 'secondary' : 'tertiary'" + :menu-name="filterName"> + <template #icon> + <slot name="icon" /> + </template> + <slot /> + + <template v-if="isActive"> + <NcActionSeparator /> + <NcActionButton class="files-list-filter__clear-button" + close-after-click + @click="$emit('reset-filter')"> + {{ t('files', 'Clear filter') }} + </NcActionButton> + </template> + </NcActions> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' + +defineProps<{ + isActive: boolean + filterName: string +}>() + +defineEmits<{ + (event: 'reset-filter'): void +}>() +</script> + +<style scoped> +.files-list-filter__clear-button :deep(.action-button__text) { + color: var(--color-error-text); +} + +:deep(.button-vue) { + font-weight: normal !important; + + * { + font-weight: normal !important; + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue new file mode 100644 index 00000000000..3a843b2bc3e --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter :is-active="isActive" + :filter-name="t('files', 'Modified')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiCalendarRangeOutline" /> + </template> + <NcActionButton v-for="preset of timePresets" + :key="preset.id" + type="radio" + close-after-click + :model-value.sync="selectedOption" + :value="preset.id"> + {{ preset.label }} + </NcActionButton> + <!-- TODO: Custom time range --> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITimePreset } from '../../filters/ModifiedFilter.ts' + +import { mdiCalendarRangeOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + timePresets: { + type: Array as PropType<ITimePreset[]>, + required: true, + }, + }, + + setup() { + return { + // icons used in template + mdiCalendarRangeOutline, + } + }, + + data() { + return { + selectedOption: null as string | null, + timeRangeEnd: null as number | null, + timeRangeStart: null as number | null, + } + }, + + computed: { + /** + * Is the filter currently active + */ + isActive() { + return this.selectedOption !== null + }, + + currentPreset() { + return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null + }, + }, + + watch: { + selectedOption() { + if (this.selectedOption === null) { + this.$emit('update:preset') + } else { + const preset = this.currentPreset + this.$emit('update:preset', preset) + } + }, + }, + + methods: { + t, + + resetFilter() { + this.selectedOption = null + this.timeRangeEnd = null + this.timeRangeStart = null + }, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list-filter-time { + &__clear-button :deep(.action-button__text) { + color: var(--color-error-text); + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue new file mode 100644 index 00000000000..938be171f6d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton v-show="isVisible" @click="onClick"> + {{ t('files', 'Search everywhere') }} + </NcButton> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getPinia } from '../../store/index.ts' +import { useSearchStore } from '../../store/search.ts' + +const isVisible = ref(false) + +defineExpose({ + hideButton, + showButton, +}) + +/** + * Hide the button - called by the filter class + */ +function hideButton() { + isVisible.value = false +} + +/** + * Show the button - called by the filter class + */ +function showButton() { + isVisible.value = true +} + +/** + * Button click handler to make the filtering a global search. + */ +function onClick() { + const searchStore = useSearchStore(getPinia()) + searchStore.scope = 'globally' +} +</script> diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue new file mode 100644 index 00000000000..d3ad791513f --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter class="file-list-filter-type" + :is-active="isActive" + :filter-name="t('files', 'Type')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiFileOutline" /> + </template> + <NcActionButton v-for="fileType of typePresets" + :key="fileType.id" + type="checkbox" + :model-value="selectedOptions.includes(fileType)" + @click="toggleOption(fileType)"> + <template #icon> + <NcIconSvgWrapper :svg="fileType.icon" /> + </template> + {{ fileType.label }} + </NcActionButton> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITypePreset } from '../../filters/TypeFilter.ts' + +import { mdiFileOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + name: 'FileListFilterType', + + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + presets: { + type: Array as PropType<ITypePreset[]>, + default: () => [], + }, + typePresets: { + type: Array as PropType<ITypePreset[]>, + required: true, + }, + }, + + setup() { + return { + mdiFileOutline, + t, + } + }, + + data() { + return { + selectedOptions: [] as ITypePreset[], + } + }, + + computed: { + isActive() { + return this.selectedOptions.length > 0 + }, + }, + + watch: { + /** Reset selected options if property is changed */ + presets() { + this.selectedOptions = this.presets ?? [] + }, + selectedOptions(newValue, oldValue) { + if (this.selectedOptions.length === 0) { + if (oldValue.length !== 0) { + this.$emit('update:presets') + } + } else { + this.$emit('update:presets', this.selectedOptions) + } + }, + }, + + mounted() { + this.selectedOptions = this.presets ?? [] + }, + + methods: { + resetFilter() { + this.selectedOptions = [] + }, + + /** + * Toggle option from selected option + * @param option The option to toggle + */ + toggleOption(option: ITypePreset) { + const idx = this.selectedOptions.indexOf(option) + if (idx !== -1) { + this.selectedOptions.splice(idx, 1) + } else { + this.selectedOptions.push(option) + } + }, + }, +}) +</script> + +<style> +.file-list-filter-type { + max-width: 220px; +} +</style> diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue new file mode 100644 index 00000000000..7f0d71fd85a --- /dev/null +++ b/apps/files/src/components/FileListFilters.vue @@ -0,0 +1,74 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="file-list-filters"> + <div class="file-list-filters__filter" data-cy-files-filters> + <span v-for="filter of visualFilters" + :key="filter.id" + ref="filterElements" /> + </div> + <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')"> + <li v-for="(chip, index) of activeChips" :key="index"> + <NcChip :aria-label-close="t('files', 'Remove filter')" + :icon-svg="chip.icon" + :text="chip.text" + @close="chip.onclick"> + <template v-if="chip.user" #icon> + <NcAvatar disable-menu + :show-user-status="false" + :size="24" + :user="chip.user" /> + </template> + </NcChip> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { computed, ref, watchEffect } from 'vue' +import { useFiltersStore } from '../store/filters.ts' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcChip from '@nextcloud/vue/components/NcChip' + +const filterStore = useFiltersStore() +const visualFilters = computed(() => filterStore.filtersWithUI) +const activeChips = computed(() => filterStore.activeChips) + +const filterElements = ref<HTMLElement[]>([]) +watchEffect(() => { + filterElements.value + .forEach((el, index) => visualFilters.value[index].mount(el)) +}) +</script> + +<style scoped lang="scss"> +.file-list-filters { + display: flex; + flex-direction: column; + gap: var(--default-grid-baseline); + height: 100%; + width: 100%; + + &__filter { + display: flex; + align-items: start; + justify-content: start; + gap: calc(var(--default-grid-baseline, 4px) * 2); + + > * { + flex: 0 1 fit-content; + } + } + + &__active { + display: flex; + flex-direction: row; + gap: calc(var(--default-grid-baseline, 4px) * 2); + } +} +</style> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue new file mode 100644 index 00000000000..31458398028 --- /dev/null +++ b/apps/files/src/components/FilesListHeader.vue @@ -0,0 +1,100 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div v-show="enabled" :class="`files-list__header-${header.id}`"> + <span ref="mount" /> + </div> +</template> + +<script lang="ts"> +import type { Folder, Header, View } from '@nextcloud/files' +import type { PropType } from 'vue' + +import PQueue from 'p-queue' + +import logger from '../logger.ts' + +/** + * This component is used to render custom + * elements provided by an API. Vue doesn't allow + * to directly render an HTMLElement, so we can do + * this magic here. + */ +export default { + name: 'FilesListHeader', + props: { + header: { + type: Object as PropType<Header>, + required: true, + }, + currentFolder: { + type: Object as PropType<Folder>, + required: true, + }, + currentView: { + type: Object as PropType<View>, + required: true, + }, + }, + setup() { + // Create a queue to ensure that the header is only rendered once at a time + const queue = new PQueue({ concurrency: 1 }) + + return { + queue, + } + }, + computed: { + enabled() { + return this.header.enabled?.(this.currentFolder, this.currentView) ?? true + }, + }, + watch: { + enabled(enabled) { + if (!enabled) { + return + } + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) + }, + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) + }, + }, + + mounted() { + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + }, +} +</script> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue new file mode 100644 index 00000000000..9e8cdc159ee --- /dev/null +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -0,0 +1,164 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <tr> + <th class="files-list__row-checkbox"> + <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table --> + <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span> + </th> + + <!-- Link to file --> + <td class="files-list__row-name"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Summary --> + <span>{{ summary }}</span> + </td> + + <!-- Actions --> + <td class="files-list__row-actions" /> + + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + + <!-- Size --> + <td v-if="isSizeAvailable" + class="files-list__column files-list__row-size"> + <span>{{ totalSize }}</span> + </td> + + <!-- Mtime --> + <td v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" /> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)"> + <span>{{ column.summary?.(nodes, currentView) }}</span> + </th> + </tr> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { View, formatFileSize } from '@nextcloud/files' +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' + +export default defineComponent({ + name: 'FilesListTableFooter', + + props: { + currentView: { + type: View, + required: true, + }, + isMimeAvailable: { + type: Boolean, + default: false, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + summary: { + type: String, + default: '', + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const pathsStore = usePathsStore() + const filesStore = useFilesStore() + const { directory } = useRouteParameters() + return { + filesStore, + pathsStore, + directory, + } + }, + + computed: { + currentFolder() { + if (!this.currentView?.id) { + return + } + + if (this.directory === '/') { + return this.filesStore.getRoot(this.currentView.id) + } + const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)! + return this.filesStore.getNode(fileId) + }, + + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512) { + return [] + } + return this.currentView?.columns || [] + }, + + totalSize() { + // If we have the size already, let's use it + if (this.currentFolder?.size) { + return formatFileSize(this.currentFolder.size, true) + } + + // Otherwise let's compute it + return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true) + }, + }, + + methods: { + classForColumn(column) { + return { + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView.id}-${column.id}`]: true, + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +// Scoped row +tr { + margin-bottom: var(--body-container-margin); + border-top: 1px solid var(--color-border); + // Prevent hover effect on the whole row + background-color: transparent !important; + border-bottom: none !important; + + td { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; + } +} +</style> diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue new file mode 100644 index 00000000000..23e631199eb --- /dev/null +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -0,0 +1,237 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <tr class="files-list__row-head"> + <th class="files-list__column files-list__row-checkbox" + @keyup.esc.exact="resetSelection"> + <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" /> + </th> + + <!-- Columns display --> + + <!-- Link to file --> + <th class="files-list__column files-list__row-name files-list__column--sortable" + :aria-sort="ariaSortForMode('basename')"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Name --> + <FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" /> + </th> + + <!-- Actions --> + <th class="files-list__row-actions" /> + + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + + <!-- Size --> + <th v-if="isSizeAvailable" + class="files-list__column files-list__row-size" + :class="{ 'files-list__column--sortable': isSizeAvailable }" + :aria-sort="ariaSortForMode('size')"> + <FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" /> + </th> + + <!-- Mtime --> + <th v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" + :class="{ 'files-list__column--sortable': isMtimeAvailable }" + :aria-sort="ariaSortForMode('mtime')"> + <FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" /> + </th> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)" + :aria-sort="ariaSortForMode(column.id)"> + <FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> + <span v-else> + {{ column.title }} + </span> + </th> + </tr> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + +import { translate as t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import { useFilesStore } from '../store/files.ts' +import { useNavigation } from '../composables/useNavigation' +import { useSelectionStore } from '../store/selection.ts' +import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' +import filesSortingMixin from '../mixins/filesSorting.ts' +import logger from '../logger.ts' + +export default defineComponent({ + name: 'FilesListTableHeader', + + components: { + FilesListTableHeaderButton, + NcCheckboxRadioSwitch, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + + return { + filesStore, + selectionStore, + + currentView, + } + }, + + computed: { + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512) { + return [] + } + return this.currentView?.columns || [] + }, + + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + selectAllBind() { + const label = t('files', 'Toggle selection for all files and folders') + return { + 'aria-label': label, + checked: this.isAllSelected, + indeterminate: this.isSomeSelected, + title: label, + } + }, + + selectedNodes() { + return this.selectionStore.selected + }, + + isAllSelected() { + return this.selectedNodes.length === this.nodes.length + }, + + isNoneSelected() { + return this.selectedNodes.length === 0 + }, + + isSomeSelected() { + return !this.isAllSelected && !this.isNoneSelected + }, + }, + + created() { + // ctrl+a selects all + useHotKey('a', this.onToggleAll, { + ctrl: true, + stop: true, + prevent: true, + }) + + // Escape key cancels selection + useHotKey('Escape', this.resetSelection, { + stop: true, + prevent: true, + }) + }, + + methods: { + ariaSortForMode(mode: string): 'ascending'|'descending'|null { + if (this.sortingMode === mode) { + return this.isAscSorting ? 'ascending' : 'descending' + } + return null + }, + + classForColumn(column) { + return { + 'files-list__column': true, + 'files-list__column--sortable': !!column.sort, + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView?.id}-${column.id}`]: true, + } + }, + + onToggleAll(selected = true) { + if (selected) { + const selection = this.nodes.map(node => node.source).filter(Boolean) as FileSource[] + logger.debug('Added all nodes to selection', { selection }) + this.selectionStore.setLastIndex(null) + this.selectionStore.set(selection) + } else { + logger.debug('Cleared selection') + this.selectionStore.reset() + } + }, + + resetSelection() { + if (this.isNoneSelected) { + return + } + this.selectionStore.reset() + }, + + t, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__column { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; + + &--sortable { + cursor: pointer; + } +} + +</style> diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue new file mode 100644 index 00000000000..6a808355c58 --- /dev/null +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -0,0 +1,337 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions> + <NcActions ref="actionsMenu" + container="#app-content-vue" + :boundaries-element="boundariesElement" + :disabled="!!loading || areSomeNodesLoading" + :force-name="true" + :inline="enabledInlineActions.length" + :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null" + :open.sync="openedMenu" + @close="openedSubmenu = null"> + <!-- Default actions list--> + <NcActionButton v-for="action in enabledMenuActions" + :key="action.id" + :ref="`action-batch-${action.id}`" + :class="{ + [`files-list__row-actions-batch-${action.id}`]: true, + [`files-list__row-actions-batch--menu`]: isValidMenu(action) + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-selection-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + + <!-- Submenu actions list--> + <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> + <!-- Back to top-level button --> + <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> + <template #icon> + <ArrowLeftIcon /> + </template> + {{ t('files', 'Back') }} + </NcActionButton> + <NcActionSeparator /> + + <!-- Submenu actions --> + <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]" + :key="action.id" + :class="`files-list__row-actions-batch-${action.id}`" + class="files-list__row-actions-batch--submenu" + close-after-click + :data-cy-files-list-selection-action="action.id" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + </template> + </NcActions> + </div> +</template> + +<script lang="ts"> +import type { FileAction, Node, View } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types' + +import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useFilesStore } from '../store/files.ts' +import { useSelectionStore } from '../store/selection.ts' +import actionsMixins from '../mixins/actionsMixin.ts' +import logger from '../logger.ts' + +// The registered actions list +const actions = getFileActions() + +export default defineComponent({ + name: 'FilesListTableHeaderActions', + + components: { + ArrowLeftIcon, + NcActions, + NcActionButton, + NcIconSvgWrapper, + NcLoadingIcon, + }, + + mixins: [actionsMixins], + + props: { + currentView: { + type: Object as PropType<View>, + required: true, + }, + selectedNodes: { + type: Array as PropType<FileSource[]>, + default: () => ([]), + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + const fileListWidth = useFileListWidth() + const { directory } = useRouteParameters() + + const boundariesElement = document.getElementById('app-content-vue') + + return { + directory, + fileListWidth, + + actionsMenuStore, + filesStore, + selectionStore, + + boundariesElement, + } + }, + + data() { + return { + loading: null, + } + }, + + computed: { + enabledFileActions(): FileAction[] { + return actions + // We don't handle renderInline actions in this component + .filter(action => !action.renderInline) + // We don't handle actions that are not visible + .filter(action => action.default !== DefaultType.HIDDEN) + .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + /** + * Return the list of enabled actions that are + * allowed to be rendered inlined. + * This means that they are not within a menu, nor + * being the parent of submenu actions. + */ + enabledInlineActions(): FileAction[] { + return this.enabledFileActions + // Remove all actions that are not top-level actions + .filter(action => action.parent === undefined) + // Remove all actions that are not batch actions + .filter(action => action.execBatch !== undefined) + // Remove all top-menu entries + .filter(action => !this.isValidMenu(action)) + // Return a maximum actions to fit the screen + .slice(0, this.inlineActions) + }, + + /** + * Return the rest of enabled actions that are not + * rendered inlined. + */ + enabledMenuActions(): FileAction[] { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + // We filter duplicates to prevent inline actions to be shown twice + const actions = this.enabledFileActions.filter((value, index, self) => { + return index === self.findIndex(action => action.id === value.id) + }) + + // Generate list of all top-level actions ids + const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[] + + const menuActions = actions + .filter(action => { + // If the action is not a batch action, we need + // to make sure it's a top-level parent entry + // and that we have some children actions bound to it + if (!action.execBatch) { + return childrenActionsIds.includes(action.id) + } + + // Rendering second-level actions is done in the template + // when openedSubmenu is set. + if (action.parent) { + return false + } + + return true + }) + .filter(action => !this.enabledInlineActions.includes(action)) + + // Make sure we render the inline actions first + // and then the rest of the actions. + // We do NOT want nested actions to be rendered inlined + return [...this.enabledInlineActions, ...menuActions] + }, + + nodes() { + return this.selectedNodes + .map(source => this.getNode(source)) + .filter(Boolean) as Node[] + }, + + areSomeNodesLoading() { + return this.nodes.some(node => node.status === NodeStatus.LOADING) + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === 'global' + }, + set(opened) { + this.actionsMenuStore.opened = opened ? 'global' : null + }, + }, + + inlineActions() { + if (this.fileListWidth < 512) { + return 0 + } + if (this.fileListWidth < 768) { + return 1 + } + if (this.fileListWidth < 1024) { + return 2 + } + return 3 + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param source The source of the node to get + */ + getNode(source: string): Node|undefined { + return this.filesStore.getNode(source) + }, + + async onActionClick(action) { + // If the action is a submenu, we open it + if (this.enabledSubmenuActions[action.id]) { + this.openedSubmenu = action + return + } + + let displayName = action.id + try { + displayName = action.displayName(this.nodes, this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + + const selectionSources = this.selectedNodes + try { + // Set loading markers + this.loading = action.id + this.nodes.forEach(node => { + this.$set(node, 'status', NodeStatus.LOADING) + }) + + // Dispatch action execution + const results = await action.execBatch(this.nodes, this.currentView, this.directory) + + // Check if all actions returned null + if (!results.some(result => result !== null)) { + // If the actions returned null, we stay silent + this.selectionStore.reset() + return + } + + // Handle potential failures + if (results.some(result => result === false)) { + // Remove the failed ids from the selection + const failedSources = selectionSources + .filter((source, index) => results[index] === false) + this.selectionStore.set(failedSources) + + if (results.some(result => result === null)) { + // If some actions returned null, we assume that the dev + // is handling the error messages and we stay silent + return + } + + showError(this.t('files', '{displayName}: failed on some elements', { displayName })) + return + } + + // Show success message and clear selection + showSuccess(this.t('files', '{displayName}: done', { displayName })) + this.selectionStore.reset() + } catch (e) { + logger.error('Error while executing action', { action, e }) + showError(this.t('files', '{displayName}: failed', { displayName })) + } finally { + // Remove loading markers + this.loading = null + this.nodes.forEach(node => { + this.$set(node, 'status', undefined) + }) + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__row-actions-batch { + flex: 1 1 100% !important; + max-width: 100%; +} +</style> diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue new file mode 100644 index 00000000000..d2e14a5495f --- /dev/null +++ b/apps/files/src/components/FilesListTableHeaderButton.vue @@ -0,0 +1,91 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcButton :class="['files-list__column-sort-button', { + 'files-list__column-sort-button--active': sortingMode === mode, + 'files-list__column-sort-button--size': sortingMode === 'size', + }]" + :alignment="mode === 'size' ? 'end' : 'start-reverse'" + type="tertiary" + :title="name" + @click="toggleSortBy(mode)"> + <template #icon> + <MenuUp v-if="sortingMode !== mode || isAscSorting" class="files-list__column-sort-button-icon" /> + <MenuDown v-else class="files-list__column-sort-button-icon" /> + </template> + <span class="files-list__column-sort-button-text">{{ name }}</span> + </NcButton> +</template> + +<script lang="ts"> +import { translate } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import MenuDown from 'vue-material-design-icons/MenuDown.vue' +import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import NcButton from '@nextcloud/vue/components/NcButton' + +import filesSortingMixin from '../mixins/filesSorting.ts' + +export default defineComponent({ + name: 'FilesListTableHeaderButton', + + components: { + MenuDown, + MenuUp, + NcButton, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + name: { + type: String, + required: true, + }, + mode: { + type: String, + required: true, + }, + }, + + methods: { + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__column-sort-button { + // Compensate for cells margin + margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1); + min-width: calc(100% - 3 * var(--cell-margin))!important; + + &-text { + color: var(--color-text-maxcontrast); + font-weight: normal; + } + + &-icon { + color: var(--color-text-maxcontrast); + opacity: 0; + transition: opacity var(--animation-quick); + inset-inline-start: -10px; + } + + &--size &-icon { + inset-inline-start: 10px; + } + + &--active &-icon, + &:hover &-icon, + &:focus &-icon, + &:active &-icon { + opacity: 1; + } +} +</style> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue new file mode 100644 index 00000000000..47b8ef19b19 --- /dev/null +++ b/apps/files/src/components/FilesListVirtual.vue @@ -0,0 +1,1035 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <VirtualList ref="table" + :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry" + :data-key="'source'" + :data-sources="nodes" + :grid-mode="userConfig.grid_view" + :extra-props="{ + isMimeAvailable, + isMtimeAvailable, + isSizeAvailable, + nodes, + }" + :scroll-to-index="scrollToIndex" + :caption="caption"> + <template #filters> + <FileListFilters /> + </template> + + <template v-if="!isNoneSelected" #header-overlay> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> + <FilesListTableHeaderActions :current-view="currentView" + :selected-nodes="selectedNodes" /> + </template> + + <template #before> + <!-- Headers --> + <FilesListHeader v-for="header in headers" + :key="header.id" + :current-folder="currentFolder" + :current-view="currentView" + :header="header" /> + </template> + + <!-- Thead--> + <template #header> + <!-- Table header and sort buttons --> + <FilesListTableHeader ref="thead" + :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" + :is-mtime-available="isMtimeAvailable" + :is-size-available="isSizeAvailable" + :nodes="nodes" /> + </template> + + <!-- Body replacement if no files are available --> + <template #empty> + <slot name="empty" /> + </template> + + <!-- Tfoot--> + <template #footer> + <FilesListTableFooter :current-view="currentView" + :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" + :is-mtime-available="isMtimeAvailable" + :is-size-available="isSizeAvailable" + :nodes="nodes" + :summary="summary" /> + </template> + </VirtualList> +</template> + +<script lang="ts"> +import type { UserConfig } from '../types' +import type { Node as NcNode } from '@nextcloud/files' +import type { ComponentPublicInstance, PropType } from 'vue' + +import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' +import { showError } from '@nextcloud/dialogs' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { n, t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useActiveStore } from '../store/active.ts' +import { useFileListHeaders } from '../composables/useFileListHeaders.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useSelectionStore } from '../store/selection.js' +import { useUserConfigStore } from '../store/userconfig.ts' +import logger from '../logger.ts' + +import FileEntry from './FileEntry.vue' +import FileEntryGrid from './FileEntryGrid.vue' +import FileListFilters from './FileListFilters.vue' +import FilesListHeader from './FilesListHeader.vue' +import FilesListTableFooter from './FilesListTableFooter.vue' +import FilesListTableHeader from './FilesListTableHeader.vue' +import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' +import VirtualList from './VirtualList.vue' + +export default defineComponent({ + name: 'FilesListVirtual', + + components: { + FileListFilters, + FilesListHeader, + FilesListTableFooter, + FilesListTableHeader, + VirtualList, + FilesListTableHeaderActions, + }, + + props: { + currentView: { + type: View, + required: true, + }, + currentFolder: { + type: Folder, + required: true, + }, + nodes: { + type: Array as PropType<NcNode[]>, + required: true, + }, + summary: { + type: String, + required: true, + }, + }, + + setup() { + const activeStore = useActiveStore() + const selectionStore = useSelectionStore() + const userConfigStore = useUserConfigStore() + + const fileListWidth = useFileListWidth() + const { fileId, openDetails, openFile } = useRouteParameters() + + return { + fileId, + fileListWidth, + headers: useFileListHeaders(), + openDetails, + openFile, + + activeStore, + selectionStore, + userConfigStore, + + n, + t, + } + }, + + data() { + return { + FileEntry, + FileEntryGrid, + scrollToIndex: 0, + } + }, + + computed: { + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + + isMimeAvailable() { + if (!this.userConfig.show_mime_column) { + return false + } + // Hide mime column on narrow screens + if (this.fileListWidth < 1024) { + return false + } + return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream') + }, + isMtimeAvailable() { + // Hide mtime column on narrow screens + if (this.fileListWidth < 768) { + return false + } + return this.nodes.some(node => node.mtime !== undefined) + }, + isSizeAvailable() { + // Hide size column on narrow screens + if (this.fileListWidth < 768) { + return false + } + return this.nodes.some(node => node.size !== undefined) + }, + + cantUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0 + }, + + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 + }, + + caption() { + const defaultCaption = t('files', 'List of files and folders.') + const viewCaption = this.currentView.caption || defaultCaption + const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null + const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null + const sortableCaption = t('files', 'Column headers with buttons are sortable.') + const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') + return [ + viewCaption, + cantUploadCaption, + quotaExceededCaption, + sortableCaption, + virtualListNote, + ].filter(Boolean).join('\n') + }, + + selectedNodes() { + return this.selectionStore.selected + }, + + isNoneSelected() { + return this.selectedNodes.length === 0 + }, + + isEmpty() { + return this.nodes.length === 0 + }, + }, + + watch: { + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() + }, + fileId() { + this.handleOpenQueries() + }, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() + }, + }, + + created() { + useHotKey('Escape', this.unselectFile, { + stop: true, + prevent: true, + }) + + useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, { + stop: true, + prevent: true, + }) + }, + + mounted() { + // Add events on parent to cover both the table and DragAndDrop notice + const mainContent = window.document.querySelector('main.app-content') as HTMLElement + mainContent.addEventListener('dragover', this.onDragOver) + subscribe('files:sidebar:closed', this.onSidebarClosed) + }, + + beforeDestroy() { + const mainContent = window.document.querySelector('main.app-content') as HTMLElement + mainContent.removeEventListener('dragover', this.onDragOver) + unsubscribe('files:sidebar:closed', this.onSidebarClosed) + }, + + methods: { + handleOpenQueries() { + // If the list is empty, or we don't have a fileId, + // there's nothing to be done. + if (this.isEmpty || !this.fileId) { + return + } + + logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', { + nodes: this.nodes, + fileId: this.fileId, + openFile: this.openFile, + openDetails: this.openDetails, + }) + + if (this.openFile) { + this.handleOpenFile(this.fileId) + } + + if (this.openDetails) { + this.openSidebarForFile(this.fileId) + } + + if (this.fileId) { + this.scrollToFile(this.fileId, false) + } + }, + + openSidebarForFile(fileId) { + // Open the sidebar for the given URL fileid + // iif we just loaded the app. + const node = this.nodes.find(n => n.fileid === fileId) as NcNode + if (node && sidebarAction?.enabled?.([node], this.currentView)) { + logger.debug('Opening sidebar on file ' + node.path, { node }) + sidebarAction.exec(node, this.currentView, this.currentFolder.path) + return + } + logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) + }, + + scrollToFile(fileId: number|null, warn = true) { + if (fileId) { + // Do not uselessly scroll to the top of the list. + if (fileId === this.currentFolder.fileid) { + return + } + + const index = this.nodes.findIndex(node => node.fileid === fileId) + if (warn && index === -1 && fileId !== this.currentFolder.fileid) { + showError(t('files', 'File not found')) + } + + this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) + } + }, + + /** + * Unselect the current file and clear open parameters from the URL + */ + unselectFile() { + const query = { ...this.$route.query } + delete query.openfile + delete query.opendetails + + this.activeStore.activeNode = undefined + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, + query, + true, + ) + }, + + // When sidebar is closed, we remove the openDetails parameter from the URL + onSidebarClosed() { + if (this.openDetails) { + const query = { ...this.$route.query } + delete query.opendetails + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + query, + ) + } + }, + + /** + * Handle opening a file (e.g. by ?openfile=true) + * @param fileId File to open + */ + async handleOpenFile(fileId: number) { + const node = this.nodes.find(n => n.fileid === fileId) as NcNode + if (node === undefined) { + return + } + + if (node.type === FileType.File) { + const defaultAction = getFileActions() + // Get only default actions (visible and hidden) + .filter((action) => !!action?.default) + // Find actions that are either always enabled or enabled for the current node + .filter((action) => !action.enabled || action.enabled([node], this.currentView)) + .filter((action) => action.id !== 'download') + // Sort enabled default actions by order + .sort((a, b) => (a.order || 0) - (b.order || 0)) + // Get the first one + .at(0) + + // Some file types do not have a default action (e.g. they can only be downloaded) + // So if there is an enabled default action, so execute it + if (defaultAction) { + logger.debug('Opening file ' + node.path, { node }) + return await defaultAction.exec(node, this.currentView, this.currentFolder.path) + } + } + // The file is either a folder or has no default action other than downloading + // in this case we need to open the details instead and remove the route from the history + logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node }) + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + { ...this.$route.query, openfile: undefined, opendetails: '' }, + true, // silent update of the URL + ) + }, + + onDragOver(event: DragEvent) { + // Detect if we're only dragging existing files or not + const isForeignFile = event.dataTransfer?.types.includes('Files') + if (isForeignFile) { + // Only handle uploading of existing Nextcloud files + // See DragAndDropNotice for handling of foreign files + return + } + + event.preventDefault() + event.stopPropagation() + + const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el + const tableTop = tableElement.getBoundingClientRect().top + const tableBottom = tableTop + tableElement.getBoundingClientRect().height + + // If reaching top, scroll up. Using 100 because of the floating header + if (event.clientY < tableTop + 100) { + tableElement.scrollTop = tableElement.scrollTop - 25 + return + } + + // If reaching bottom, scroll down + if (event.clientY > tableBottom - 50) { + tableElement.scrollTop = tableElement.scrollTop + 25 + } + }, + + onKeyDown(event: KeyboardEvent) { + // Up and down arrow keys + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const columnCount = this.$refs.table?.columnCount ?? 1 + const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0 + const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount + if (nextIndex < 0 || nextIndex >= this.nodes.length) { + return + } + + const nextNode = this.nodes[nextIndex] + + if (nextNode && nextNode?.fileid) { + this.setActiveNode(nextNode) + } + } + + // if grid mode, left and right arrow keys + if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0 + const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1 + if (nextIndex < 0 || nextIndex >= this.nodes.length) { + return + } + + const nextNode = this.nodes[nextIndex] + + if (nextNode && nextNode?.fileid) { + this.setActiveNode(nextNode) + } + } + }, + + setActiveNode(node: NcNode & { fileid: number }) { + logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid }) + this.scrollToFile(node.fileid) + + // Remove openfile and opendetails from the URL + const query = { ...this.$route.query } + delete query.openfile + delete query.opendetails + + this.activeStore.activeNode = node + + // Silent update of the URL + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: String(node.fileid) }, + query, + true, + ) + }, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list { + --row-height: 44px; + --cell-margin: 14px; + + --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); + --checkbox-size: 24px; + --clickable-area: var(--default-clickable-area); + --icon-preview-size: 24px; + + --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + will-change: scroll-position; + + &:has(.file-list-filters__active) { + --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small)); + } + + & :deep() { + // Table head, body and footer + tbody { + will-change: padding; + contain: layout paint style; + display: flex; + flex-direction: column; + width: 100%; + // Necessary for virtual scrolling absolute + position: relative; + + /* Hover effect on tbody lines only */ + tr { + contain: strict; + &:hover, + &:focus { + background-color: var(--color-background-dark); + } + } + } + + // Before table and thead + .files-list__before { + display: flex; + flex-direction: column; + } + + .files-list__selected { + padding-inline-end: 12px; + white-space: nowrap; + } + + .files-list__table { + display: block; + + &.files-list__table--with-thead-overlay { + // Hide the table header below the overlay + margin-block-start: calc(-1 * var(--row-height)); + } + + // Visually hide the table when there are no files + &--hidden { + visibility: hidden; + z-index: -1; + opacity: 0; + } + } + + .files-list__filters { + // Pinned on top when scrolling above table header + position: sticky; + top: 0; + // ensure there is a background to hide the file list on scroll + background-color: var(--color-main-background); + z-index: 10; + // fixed the size + padding-inline: var(--row-height) var(--default-grid-baseline, 4px); + height: var(--fixed-block-start-position); + width: 100%; + } + + .files-list__thead-overlay { + // Pinned on top when scrolling + position: sticky; + top: var(--fixed-block-start-position); + // Save space for a row checkbox + margin-inline-start: var(--row-height); + // More than .files-list__thead + z-index: 20; + + display: flex; + align-items: center; + + // Reuse row styles + background-color: var(--color-main-background); + border-block-end: 1px solid var(--color-border); + height: var(--row-height); + flex: 0 0 var(--row-height); + } + + .files-list__thead, + .files-list__tfoot { + display: flex; + flex-direction: column; + width: 100%; + background-color: var(--color-main-background); + } + + // Table header + .files-list__thead { + // Pinned on top when scrolling + position: sticky; + z-index: 10; + top: var(--fixed-block-start-position); + } + + // Empty content + .files-list__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + tr { + position: relative; + display: flex; + align-items: center; + width: 100%; + border-block-end: 1px solid var(--color-border); + box-sizing: border-box; + user-select: none; + height: var(--row-height); + } + + td, th { + display: flex; + align-items: center; + flex: 0 0 auto; + justify-content: start; + width: var(--row-height); + height: var(--row-height); + margin: 0; + padding: 0; + color: var(--color-text-maxcontrast); + border: none; + + // Columns should try to add any text + // node wrapped in a span. That should help + // with the ellipsis on overflow. + span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .files-list__row--failed { + position: absolute; + display: block; + top: 0; + inset-inline: 0; + bottom: 0; + opacity: .1; + z-index: -1; + background: var(--color-error); + } + + .files-list__row-checkbox { + justify-content: center; + + .checkbox-radio-switch { + display: flex; + justify-content: center; + + --icon-size: var(--checkbox-size); + + label.checkbox-radio-switch__label { + width: var(--clickable-area); + height: var(--clickable-area); + margin: 0; + padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2); + } + + .checkbox-radio-switch__icon { + margin: 0 !important; + } + } + } + + .files-list__row { + &:hover, &:focus, &:active, &--active, &--dragover { + // WCAG AA compliant + background-color: var(--color-background-hover); + // text-maxcontrast have been designed to pass WCAG AA over + // a white background, we need to adjust then. + --color-text-maxcontrast: var(--color-main-text); + > * { + --color-border: var(--color-border-dark); + } + + // Hover state of the row should also change the favorite markers background + .favorite-marker-icon svg path { + stroke: var(--color-background-hover); + } + } + + &--dragover * { + // Prevent dropping on row children + pointer-events: none; + } + } + + // Entry preview or mime icon + .files-list__row-icon { + position: relative; + display: flex; + overflow: visible; + align-items: center; + // No shrinking or growing allowed + flex: 0 0 var(--icon-preview-size); + justify-content: center; + width: var(--icon-preview-size); + height: 100%; + // Show same padding as the checkbox right padding for visual balance + margin-inline-end: var(--checkbox-padding); + color: var(--color-primary-element); + + // Icon is also clickable + * { + cursor: pointer; + } + + & > span { + justify-content: flex-start; + + &:not(.files-list__row-icon-favorite) svg { + width: var(--icon-preview-size); + height: var(--icon-preview-size); + } + + // Slightly increase the size of the folder icon + &.folder-icon, + &.folder-open-icon { + margin: -3px; + svg { + width: calc(var(--icon-preview-size) + 6px); + height: calc(var(--icon-preview-size) + 6px); + } + } + } + + &-preview-container { + position: relative; // Needed for the blurshash to be positioned correctly + overflow: hidden; + width: var(--icon-preview-size); + height: var(--icon-preview-size); + border-radius: var(--border-radius); + } + + &-blurhash { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + height: 100%; + width: 100%; + object-fit: cover; + } + + &-preview { + // Center and contain the preview + object-fit: contain; + object-position: center; + + height: 100%; + width: 100%; + + /* Preview not loaded animation effect */ + &:not(.files-list__row-icon-preview--loaded) { + background: var(--color-loading-dark); + // animation: preview-gradient-fade 1.2s ease-in-out infinite; + } + } + + &-favorite { + position: absolute; + top: 0px; + inset-inline-end: -10px; + } + + // File and folder overlay + &-overlay { + position: absolute; + max-height: calc(var(--icon-preview-size) * 0.6); + max-width: calc(var(--icon-preview-size) * 0.6); + color: var(--color-primary-element-text); + // better alignment with the folder icon + margin-block-start: 2px; + + // Improve icon contrast with a background for files + &--file { + color: var(--color-main-text); + background: var(--color-main-background); + border-radius: 100%; + } + } + } + + // Entry link + .files-list__row-name { + // Prevent link from overflowing + overflow: hidden; + // Take as much space as possible + flex: 1 1 auto; + + button.files-list__row-name-link { + display: flex; + align-items: center; + text-align: start; + // Fill cell height and width + width: 100%; + height: 100%; + // Necessary for flex grow to work + min-width: 0; + margin: 0; + padding: 0; + + // Already added to the inner text, see rule below + &:focus-visible { + outline: none !important; + } + + // Keyboard indicator a11y + &:focus .files-list__row-name-text { + outline: var(--border-width-input-focused) solid var(--color-main-text) !important; + border-radius: var(--border-radius-element); + } + &:focus:not(:focus-visible) .files-list__row-name-text { + outline: none !important; + } + } + + .files-list__row-name-text { + color: var(--color-main-text); + // Make some space for the outline + padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline)); + padding-inline-start: -10px; + // Align two name and ext + display: inline-flex; + } + + .files-list__row-name-ext { + color: var(--color-text-maxcontrast); + // always show the extension + overflow: visible; + } + } + + // Rename form + .files-list__row-rename { + width: 100%; + max-width: 600px; + input { + width: 100%; + // Align with text, 0 - padding - border + margin-inline-start: -8px; + padding: 2px 6px; + border-width: 2px; + + &:invalid { + // Show red border on invalid input + border-color: var(--color-error); + color: red; + } + } + } + + .files-list__row-actions { + // take as much space as necessary + width: auto; + + // Add margin to all cells after the actions + & ~ td, + & ~ th { + margin: 0 var(--cell-margin); + } + + button { + .button-vue__text { + // Remove bold from default button styling + font-weight: normal; + } + } + } + + .files-list__row-action--inline { + margin-inline-end: 7px; + } + + .files-list__row-mime, + .files-list__row-mtime, + .files-list__row-size { + color: var(--color-text-maxcontrast); + } + + .files-list__row-size { + width: calc(var(--row-height) * 2); + // Right align content/text + justify-content: flex-end; + } + + .files-list__row-mtime { + width: calc(var(--row-height) * 2.5); + } + + .files-list__row-mime { + width: calc(var(--row-height) * 3.5); + } + + .files-list__row-column-custom { + width: calc(var(--row-height) * 2.5); + } + } +} + +@media screen and (max-width: 512px) { + .files-list :deep(.files-list__filters) { + // Reduce padding on mobile + padding-inline: var(--default-grid-baseline, 4px); + } +} + +</style> + +<style lang="scss"> +// Grid mode +.files-list--grid tbody.files-list__tbody { + --item-padding: 16px; + --icon-preview-size: 166px; + --name-height: var(--default-clickable-area); + --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline)); + --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2); + --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2); + --checkbox-padding: 0px; + display: grid; + grid-template-columns: repeat(auto-fill, var(--row-width)); + + align-content: center; + align-items: center; + justify-content: space-around; + justify-items: center; + + tr { + display: flex; + flex-direction: column; + width: var(--row-width); + height: var(--row-height); + border: none; + border-radius: var(--border-radius-large); + padding: var(--item-padding); + } + + // Checkbox in the top left + .files-list__row-checkbox { + position: absolute; + z-index: 9; + top: calc(var(--item-padding) / 2); + inset-inline-start: calc(var(--item-padding) / 2); + overflow: hidden; + --checkbox-container-size: 44px; + width: var(--checkbox-container-size); + height: var(--checkbox-container-size); + + // Add a background to the checkbox so we do not see the image through it. + .checkbox-radio-switch__content::after { + content: ''; + width: 16px; + height: 16px; + position: absolute; + inset-inline-start: 50%; + margin-inline-start: -8px; + z-index: -1; + background: var(--color-main-background); + } + } + + // Star icon in the top right + .files-list__row-icon-favorite { + position: absolute; + top: 0; + inset-inline-end: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--clickable-area); + height: var(--clickable-area); + } + + .files-list__row-name { + display: flex; + flex-direction: column; + width: var(--icon-preview-size); + height: calc(var(--icon-preview-size) + var(--name-height)); + // Ensure that the name outline is visible. + overflow: visible; + + span.files-list__row-icon { + width: var(--icon-preview-size); + height: var(--icon-preview-size); + } + + .files-list__row-name-text { + margin: 0; + // Ensure that the outline is not too close to the text. + margin-inline-start: -4px; + padding: 0px 4px; + } + } + + .files-list__row-mtime { + width: var(--icon-preview-size); + height: var(--mtime-height); + font-size: var(--font-size-small); + } + + .files-list__row-actions { + position: absolute; + inset-inline-end: calc(var(--clickable-area) / 4); + inset-block-end: calc(var(--mtime-height) / 2); + width: var(--clickable-area); + height: var(--clickable-area); + } +} + +@media screen and (max-width: 768px) { + // there is no mtime + .files-list--grid tbody.files-list__tbody { + --mtime-height: 0px; + + // so we move the action to the name + .files-list__row-actions { + inset-block-end: var(--item-padding); + } + + // and we need to keep space on the name for the actions + .files-list__row-name-text { + padding-inline-end: var(--clickable-area) !important; + } + } +} +</style> diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue new file mode 100644 index 00000000000..c29bc00c67f --- /dev/null +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -0,0 +1,182 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcAppNavigationItem v-for="view in currentViews" + :key="view.id" + class="files-navigation__item" + allow-collapse + :loading="view.loading" + :data-cy-files-navigation-item="view.id" + :exact="useExactRouteMatching(view)" + :icon="view.iconClass" + :name="view.name" + :open="isExpanded(view)" + :pinned="view.sticky" + :to="generateToNavigation(view)" + :style="style" + @update:open="(open) => onOpen(open, view)"> + <template v-if="view.icon" #icon> + <NcIconSvgWrapper :svg="view.icon" /> + </template> + + <!-- Hack to force the collapse icon to be displayed --> + <li v-if="view.loadChildViews && !view.loaded" style="display: none" /> + + <!-- Recursively nest child views --> + <FilesNavigationItem v-if="hasChildViews(view)" + :parent="view" + :level="level + 1" + :views="filterView(views, parent.id)" /> + </NcAppNavigationItem> + </Fragment> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { View } from '@nextcloud/files' + +import { defineComponent } from 'vue' +import { Fragment } from 'vue-frag' + +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { useNavigation } from '../composables/useNavigation.js' +import { useViewConfigStore } from '../store/viewConfig.js' + +const maxLevel = 7 // Limit nesting to not exceed max call stack size + +export default defineComponent({ + name: 'FilesNavigationItem', + + components: { + Fragment, + NcAppNavigationItem, + NcIconSvgWrapper, + }, + + props: { + parent: { + type: Object as PropType<View>, + default: () => ({}), + }, + level: { + type: Number, + default: 0, + }, + views: { + type: Object as PropType<Record<string, View[]>>, + default: () => ({}), + }, + }, + + setup() { + const { currentView } = useNavigation() + const viewConfigStore = useViewConfigStore() + return { + currentView, + viewConfigStore, + } + }, + + computed: { + currentViews(): View[] { + if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level + return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) + .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) + } + return this.filterVisible(this.views[this.parent.id] ?? []) + }, + + style() { + if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level + return null + } + return { + 'padding-left': '16px', + } + }, + }, + + methods: { + filterVisible(views: View[]) { + return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + }, + + hasChildViews(view: View): boolean { + if (this.level >= maxLevel) { + return false + } + return this.filterVisible(this.views[view.id] ?? []).length > 0 + }, + + /** + * Only use exact route matching on routes with child views + * Because if a view does not have children (like the files view) then multiple routes might be matched for it + * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view + * @param view The view to check + */ + useExactRouteMatching(view: View): boolean { + return this.hasChildViews(view) + }, + + /** + * Generate the route to a view + * @param view View to generate "to" navigation for + */ + generateToNavigation(view: View) { + if (view.params) { + const { dir } = view.params + return { name: 'filelist', params: { ...view.params }, query: { dir } } + } + return { name: 'filelist', params: { view: view.id } } + }, + + /** + * Check if a view is expanded by user config + * or fallback to the default value. + * @param view View to check if expanded + */ + isExpanded(view: View): boolean { + return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' + ? this.viewConfigStore.getConfig(view.id).expanded === true + : view.expanded === true + }, + + /** + * Expand/collapse a a view with children and permanently + * save this setting in the server. + * @param open True if open + * @param view View + */ + async onOpen(open: boolean, view: View) { + // Invert state + const isExpanded = this.isExpanded(view) + // Update the view expanded state, might not be necessary + view.expanded = !isExpanded + this.viewConfigStore.update(view.id, 'expanded', !isExpanded) + if (open && view.loadChildViews) { + await view.loadChildViews(view) + } + }, + + /** + * Return the view map with the specified view id removed + * + * @param viewMap Map of views + * @param id View id + */ + filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> { + return Object.fromEntries( + Object.entries(viewMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([viewId, _views]) => viewId !== id), + ) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..0890dffcb39 --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnify, mdiSearchWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useSearchStore } from '../store/search.ts' +import { VIEW_ID } from '../views/search.ts' + +const { currentView } = useNavigation(true) +const searchStore = useSearchStore() + +/** + * When the route is changed from search view to something different + * we need to clear the search box. + */ +onBeforeNavigation((to, from, next) => { + if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) { + // we are leaving the search view so unset the query + searchStore.query = '' + searchStore.scope = 'filter' + } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) { + // fix the query if the user refreshed the view + if (searchStore.query && !to.query.query) { + // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3) + return next({ + ...to, + query: { + ...to.query, + query: searchStore.query, + }, + }) + } + } + next() +}) + +/** + * Are we currently on the search view. + * Needed to disable the action menu (we cannot change the search mode there) + */ +const isSearchView = computed(() => currentView.value.id === VIEW_ID) + +/** + * Different searchbox label depending if filtering or searching + */ +const searchLabel = computed(() => { + if (searchStore.scope === 'globally') { + return t('files', 'Search everywhere …') + } + return t('files', 'Search here …') +}) +</script> + +<template> + <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel"> + <template #actions> + <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView"> + <template #icon> + <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" /> + </template> + <NcActionButton close-after-click @click="searchStore.scope = 'filter'"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + {{ t('files', 'Search here') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search everywhere') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue index 4a50ed558f0..b5a792d9029 100644 --- a/apps/files/src/components/LegacyView.vue +++ b/apps/files/src/components/LegacyView.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> @@ -50,10 +33,8 @@ export default { }, methods: { setFileInfo(fileInfo) { - this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + this.component.setFileInfo(fileInfo) }, }, } </script> -<style> -</style> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index bfcbaea3776..46c8e5c9af4 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -1,6 +1,10 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> <template> <NcAppNavigationItem v-if="storageStats" - :aria-label="t('files', 'Storage informations')" + :aria-description="t('files', 'Storage information')" :class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}" :loading="loadingStorageStats" :name="storageStatsTitle" @@ -13,25 +17,27 @@ <!-- Progress bar --> <NcProgressBar v-if="storageStats.quota >= 0" slot="extra" + :aria-label="t('files', 'Storage quota')" :error="storageStats.relative > 80" :value="Math.min(storageStats.relative, 100)" /> </NcAppNavigationItem> </template> <script> +import { debounce, throttle } from 'throttle-debounce' import { formatFileSize } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' -import { debounce, throttle } from 'throttle-debounce' +import { subscribe } from '@nextcloud/event-bus' import { translate } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import ChartPie from 'vue-material-design-icons/ChartPie.vue' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import logger from '../logger.js' -import { subscribe } from '@nextcloud/event-bus' +import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' + +import logger from '../logger.ts' export default { name: 'NavigationQuota', @@ -51,8 +57,8 @@ export default { computed: { storageStatsTitle() { - const usedQuotaByte = formatFileSize(this.storageStats?.used) - const quotaByte = formatFileSize(this.storageStats?.quota) + const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { @@ -80,15 +86,26 @@ export default { */ setInterval(this.throttleUpdateStorageStats, 60 * 1000) - subscribe('files:file:created', this.throttleUpdateStorageStats) - subscribe('files:file:deleted', this.throttleUpdateStorageStats) - subscribe('files:file:moved', this.throttleUpdateStorageStats) - subscribe('files:file:updated', this.throttleUpdateStorageStats) + subscribe('files:node:created', this.throttleUpdateStorageStats) + subscribe('files:node:deleted', this.throttleUpdateStorageStats) + subscribe('files:node:moved', this.throttleUpdateStorageStats) + subscribe('files:node:updated', this.throttleUpdateStorageStats) + }, - subscribe('files:folder:created', this.throttleUpdateStorageStats) - subscribe('files:folder:deleted', this.throttleUpdateStorageStats) - subscribe('files:folder:moved', this.throttleUpdateStorageStats) - subscribe('files:folder:updated', this.throttleUpdateStorageStats) + mounted() { + // If the user has a quota set, warn if the available account storage is <=0 + // + // NOTE: This doesn't catch situations where actual *server* + // disk (non-quota) space is low, but those should probably + // be handled differently anyway since a regular user can't + // can't do much about them (If we did want to indicate server disk + // space matters to users, we'd probably want to use a warning + // specific to that situation anyhow. So this covers warning covers + // our primary day-to-day concern (individual account quota usage). + // + if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) { + this.showStorageFullWarning() + } }, methods: { @@ -105,7 +122,7 @@ export default { * Update the storage stats * Throttled at max 1 refresh per minute * - * @param {Event} [event = null] if user interaction + * @param {Event} [event] if user interaction */ async updateStorageStats(event = null) { if (this.loadingStorageStats) { @@ -118,6 +135,13 @@ export default { if (!response?.data?.data) { throw new Error('Invalid storage stats') } + + // Warn the user if the available account storage changed from > 0 to 0 + // (unless only because quota was intentionally set to 0 by admin in the interim) + if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) { + this.showStorageFullWarning() + } + this.storageStats = response.data.data } catch (error) { logger.error('Could not refresh storage stats', { error }) @@ -130,6 +154,10 @@ export default { } }, + showStorageFullWarning() { + showError(this.t('files', 'Your storage is full, files can not be updated or synced anymore!')) + }, + t: translate, }, } @@ -139,15 +167,18 @@ export default { // User storage stats display .app-navigation-entry__settings-quota { // Align title with progress and icon - &--not-unlimited::v-deep .app-navigation-entry__title { - margin-top: -4px; + --app-navigation-quota-margin: calc((var(--default-clickable-area) - 24px) / 2); // 20px icon size and 4px progress bar + + &--not-unlimited :deep(.app-navigation-entry__name) { + line-height: 1; + margin-top: var(--app-navigation-quota-margin); } progress { position: absolute; - bottom: 10px; - margin-left: 44px; - width: calc(100% - 44px - 22px); + bottom: var(--app-navigation-quota-margin); + margin-inline-start: var(--default-clickable-area); + width: calc(100% - (1.5 * var(--default-clickable-area))); } } </style> diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue new file mode 100644 index 00000000000..ca10935940d --- /dev/null +++ b/apps/files/src/components/NewNodeDialog.vue @@ -0,0 +1,168 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog data-cy-files-new-node-dialog + :name="name" + :open="open" + close-on-click-outside + out-transition + @update:open="emit('close', null)"> + <template #actions> + <NcButton data-cy-files-new-node-dialog-submit + type="primary" + :disabled="validity !== ''" + @click="submit"> + {{ t('files', 'Create') }} + </NcButton> + </template> + <form ref="formElement" + class="new-node-dialog__form" + @submit.prevent="emit('close', localDefaultName)"> + <NcTextField ref="nameInput" + data-cy-files-new-node-dialog-input + :error="validity !== ''" + :helper-text="validity" + :label="label" + :value.sync="localDefaultName" /> + + <!-- Hidden file warning --> + <NcNoteCard v-if="isHiddenFileName" + type="warning" + :text="t('files', 'Files starting with a dot are hidden by default')" /> + </form> + </NcDialog> +</template> + +<script setup lang="ts"> +import type { ComponentPublicInstance, PropType } from 'vue' +import { getUniqueName } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { extname } from 'path' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { getFilenameValidity } from '../utils/filenameValidity.ts' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +const props = defineProps({ + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), + }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], + }, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, + }, + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), + }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), + }, +}) + +const emit = defineEmits<{ + (event: 'close', name: string | null): void +}>() + +const localDefaultName = ref<string>(props.defaultName) +const nameInput = ref<ComponentPublicInstance>() +const formElement = ref<HTMLFormElement>() +const validity = ref('') + +const isHiddenFileName = computed(() => { + // Check if the name starts with a dot, which indicates a hidden file + return localDefaultName.value.trim().startsWith('.') +}) + +/** + * Focus the filename input field + */ +function focusInput() { + nextTick(() => { + // get the input element + const input = nameInput.value?.$el.querySelector('input') + if (!props.open || !input) { + return + } + + // length of the basename + const length = localDefaultName.value.length - extname(localDefaultName.value).length + // focus the input + input.focus() + // and set the selection to the basename (name without extension) + input.setSelectionRange(0, length) + }) +} + +/** + * Trigger submit on the form + */ +function submit() { + formElement.value?.requestSubmit() +} + +// Reset local name on props change +watch(() => [props.defaultName, props.otherNames], () => { + localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim() +}) + +// Validate the local name +watchEffect(() => { + if (props.otherNames.includes(localDefaultName.value.trim())) { + validity.value = t('files', 'This name is already in use.') + } else { + validity.value = getFilenameValidity(localDefaultName.value.trim()) + } + const input = nameInput.value?.$el.querySelector('input') + if (input) { + input.setCustomValidity(validity.value) + input.reportValidity() + } +}) + +// Ensure the input is focussed even if the dialog is already mounted but not open +watch(() => props.open, () => { + nextTick(() => { + focusInput() + }) +}) + +onMounted(() => { + // on mounted lets use the unique name + localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim() + nextTick(() => focusInput()) +}) +</script> + +<style scoped> +.new-node-dialog__form { + /* Ensure the dialog does not jump when there is a validity error */ + min-height: calc(2 * var(--default-clickable-area)); +} +</style> diff --git a/apps/files/src/components/PersonalSettings.vue b/apps/files/src/components/PersonalSettings.vue index 1431ae4053a..b076b0c1e3d 100644 --- a/apps/files/src/components/PersonalSettings.vue +++ b/apps/files/src/components/PersonalSettings.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div id="files-personal-settings" class="section"> @@ -27,7 +11,7 @@ </template> <script> -import TransferOwnershipDialogue from './TransferOwnershipDialogue' +import TransferOwnershipDialogue from './TransferOwnershipDialogue.vue' export default { name: 'PersonalSettings', diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue index c55a2841517..7a9ffb137a2 100644 --- a/apps/files/src/components/Setting.vue +++ b/apps/files/src/components/Setting.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue index ac3cfba7d02..d86e5da9d20 100644 --- a/apps/files/src/components/SidebarTab.vue +++ b/apps/files/src/components/SidebarTab.vue @@ -1,25 +1,7 @@ - <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebarTab :id="id" ref="tab" @@ -39,8 +21,8 @@ </template> <script> -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' export default { name: 'SidebarTab', @@ -66,7 +48,7 @@ export default { }, icon: { type: String, - required: false, + default: '', }, /** diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue new file mode 100644 index 00000000000..3f1db8dfd58 --- /dev/null +++ b/apps/files/src/components/TemplateFiller.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcModal label-id="template-field-modal__label"> + <div class="template-field-modal__content"> + <form> + <h3 id="template-field-modal__label"> + {{ t('files', 'Fill template fields') }} + </h3> + + <div v-for="field in fields" :key="field.index"> + <component :is="getFieldComponent(field.type)" + v-if="fieldHasLabel(field)" + :field="field" + @input="trackInput" /> + </div> + </form> + </div> + + <div class="template-field-modal__buttons"> + <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" /> + <NcButton aria-label="Submit button" + type="primary" + @click="submit"> + {{ t('files', 'Submit') }} + </NcButton> + </div> + </NcModal> +</template> + +<script> +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcModal from '@nextcloud/vue/components/NcModal' +import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue' +import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue' + +export default defineComponent({ + name: 'TemplateFiller', + + components: { + NcModal, + NcButton, + NcLoadingIcon, + TemplateRichTextField, + TemplateCheckboxField, + }, + + props: { + fields: { + type: Array, + default: () => [], + }, + onSubmit: { + type: Function, + default: async () => {}, + }, + }, + + data() { + return { + localFields: {}, + loading: false, + } + }, + + methods: { + t, + trackInput({ index, property, value }) { + if (!this.localFields[index]) { + this.localFields[index] = {} + } + + this.localFields[index][property] = value + }, + getFieldComponent(fieldType) { + const fieldComponentType = fieldType.split('-') + .map((str) => { + return str.charAt(0).toUpperCase() + str.slice(1) + }) + .join('') + + return `Template${fieldComponentType}Field` + }, + fieldHasLabel(field) { + return field.name || field.alias + }, + async submit() { + this.loading = true + + await this.onSubmit(this.localFields) + + this.$emit('close') + }, + }, +}) +</script> + +<style lang="scss" scoped> +$modal-margin: calc(var(--default-grid-baseline) * 4); + +.template-field-modal__content { + padding: $modal-margin; + + h3 { + text-align: center; + } +} + +.template-field-modal__buttons { + display: flex; + justify-content: flex-end; + gap: var(--default-grid-baseline); + margin: $modal-margin; + margin-top: 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue new file mode 100644 index 00000000000..18536171bd2 --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue @@ -0,0 +1,68 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__checkbox"> + <NcCheckboxRadioSwitch :id="fieldId" + :checked.sync="value" + type="switch" + @update:checked="input"> + {{ fieldLabel }} + </NcCheckboxRadioSwitch> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +export default defineComponent({ + name: 'TemplateCheckboxField', + + components: { + NcCheckboxRadioSwitch, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: this.field.checked ?? false, + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return label.charAt(0).toUpperCase() + label.slice(1) + }, + fieldId() { + return 'checkbox-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'checked', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__checkbox { + margin: 20px 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue new file mode 100644 index 00000000000..f49819f7e7c --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue @@ -0,0 +1,77 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__text"> + <label :for="fieldId"> + {{ fieldLabel }} + </label> + + <NcTextField :id="fieldId" + type="text" + :value.sync="value" + :label="fieldLabel" + :label-outside="true" + :placeholder="field.content" + @input="input" /> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default defineComponent({ + name: 'TemplateRichTextField', + + components: { + NcTextField, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: '', + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return (label.charAt(0).toUpperCase() + label.slice(1)) + }, + fieldId() { + return 'text-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'content', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__text { + margin: 20px 0; + + label { + font-weight: bold; + } +} +</style> diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue index ad152af9ea3..7927948d3af 100644 --- a/apps/files/src/components/TemplatePreview.vue +++ b/apps/files/src/components/TemplatePreview.vue @@ -1,35 +1,19 @@ <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="template-picker__item"> <input :id="id" + ref="input" :checked="checked" type="radio" class="radio" name="template-picker" @change="onCheck"> - <label :for="id" class="template-picker__label"> + <label :for="id" class="template-picker__label" @click="onClick"> <div class="template-picker__preview" :class="failedPreview ? 'template-picker__preview--failed' : ''"> <img class="template-picker__image" @@ -47,9 +31,9 @@ </template> <script> +import { encodePath } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' -import { encodeFilePath } from '../utils/fileUtils' -import { getToken, isPublic } from '../utils/davUtils' +import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public' // preview width generation const previewWidth = 256 @@ -123,8 +107,8 @@ export default { return this.previewUrl } // TODO: find a nicer standard way of doing this? - if (isPublic()) { - return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) + if (isPublicShare()) { + return generateUrl(`/apps/files_sharing/publicpreview/${getSharingToken()}?fileId=${this.fileid}&file=${encodePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) } return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`) }, @@ -141,6 +125,14 @@ export default { onFailure() { this.failedPreview = true }, + focus() { + this.$refs.input?.focus() + }, + onClick() { + if (this.checked) { + this.$emit('confirm-click', this.fileid) + } + }, }, } </script> @@ -182,7 +174,7 @@ export default { border-radius: var(--border-radius-large); input:checked + label > & { - border-color: var(--color-primary); + border-color: var(--color-primary-element); } &--failed { @@ -209,12 +201,9 @@ export default { } &__title { - overflow: hidden; // also count preview border - max-width: calc(var(--width) + 2*2px); + max-width: calc(var(--width) + 2 * 2px); padding: var(--margin); - white-space: nowrap; - text-overflow: ellipsis; } } diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index 67840b18829..3d668da8144 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> @@ -25,41 +9,33 @@ <form @submit.prevent="submit"> <p class="transfer-select-row"> <span>{{ readableDirectory }}</span> - <NcButton v-if="directory === undefined" @click.prevent="start"> + <NcButton v-if="directory === undefined" + class="transfer-select-row__choose_button" + @click.prevent="start"> {{ t('files', 'Choose file or folder to transfer') }} </NcButton> <NcButton v-else @click.prevent="start"> {{ t('files', 'Change') }} </NcButton> - <span class="error">{{ directoryPickerError }}</span> </p> - <p class="new-owner-row"> + <p class="new-owner"> <label for="targetUser"> <span>{{ t('files', 'New owner') }}</span> </label> - <NcMultiselect id="targetUser" - v-model="selectedUser" + <NcSelect v-model="selectedUser" + input-id="targetUser" :options="formatedUserSuggestions" :multiple="false" - :searchable="true" - :placeholder="t('files', 'Search users')" - :preselect-first="true" - :preserve-search="true" :loading="loadingUsers" - track-by="user" - label="displayName" - :internal-search="false" - :clear-on-select="false" :user-select="true" - class="middle-align" - @search-change="findUserDebounced" /> + @search="findUserDebounced" /> </p> <p> - <input type="submit" - class="primary" - :value="submitButtonText" + <NcButton native-type="submit" + type="primary" :disabled="!canSubmit"> - <span class="error">{{ submitError }}</span> + {{ submitButtonText }} + </NcButton> </p> </form> </div> @@ -69,16 +45,15 @@ import axios from '@nextcloud/axios' import debounce from 'debounce' import { generateOcsUrl } from '@nextcloud/router' -import { getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs' -import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect' +import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs' +import NcSelect from '@nextcloud/vue/components/NcSelect' import Vue from 'vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton' +import NcButton from '@nextcloud/vue/components/NcButton' -import logger from '../logger' +import logger from '../logger.ts' const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer')) .setMultiSelect(false) - .setModal(true) .setType(1) .allowDirectories() .build() @@ -86,7 +61,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans export default { name: 'TransferOwnershipDialogue', components: { - NcMultiselect, + NcSelect, NcButton, }, data() { @@ -113,6 +88,7 @@ export default { user: user.uid, displayName: user.displayName, icon: 'icon-user', + subname: user.shareWithDisplayNameUnique, } }) }, @@ -152,6 +128,7 @@ export default { logger.error(`Selecting object for transfer aborted: ${error.message || 'Unknown error'}`, { error }) this.directoryPickerError = error.message || t('files', 'Unknown error') + showError(this.directoryPickerError) }) }, async findUser(query) { @@ -178,6 +155,7 @@ export default { Vue.set(this.userSuggestions, user.value.shareWith, { uid: user.value.shareWith, displayName: user.label, + shareWithDisplayNameUnique: user.shareWithDisplayNameUnique, }) }) } catch (error) { @@ -217,6 +195,7 @@ export default { } else { this.submitError = error.message || t('files', 'Unknown error') } + showError(this.submitError) }) }, }, @@ -224,33 +203,34 @@ export default { </script> <style scoped lang="scss"> -.middle-align { - vertical-align: middle; -} p { margin-top: 12px; margin-bottom: 12px; } -.new-owner-row { + +.new-owner { display: flex; + flex-direction: column; + max-width: 400px; label { display: flex; align-items: center; + margin-bottom: calc(var(--default-grid-baseline) * 2); span { - margin-right: 8px; + margin-inline-end: 8px; } } - - .multiselect { - flex-grow: 1; - max-width: 280px; - } } + .transfer-select-row { span { - margin-right: 8px; + margin-inline-end: 8px; + } + + &__choose_button { + width: min(100%, 400px) !important; } } </style> diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue new file mode 100644 index 00000000000..4746fedf863 --- /dev/null +++ b/apps/files/src/components/VirtualList.vue @@ -0,0 +1,424 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <div class="files-list" + :class="{ 'files-list--grid': gridMode }" + data-cy-files-list + @scroll.passive="onScroll"> + <!-- Header --> + <div ref="before" class="files-list__before"> + <slot name="before" /> + </div> + + <div ref="filters" class="files-list__filters"> + <slot name="filters" /> + </div> + + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> + <slot name="header-overlay" /> + </div> + + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> + <!-- Accessibility table caption for screen readers --> + <caption v-if="caption" class="hidden-visually"> + {{ caption }} + </caption> + + <!-- Header --> + <thead ref="thead" class="files-list__thead" data-cy-files-list-thead> + <slot name="header" /> + </thead> + + <!-- Body --> + <tbody :style="tbodyStyle" + class="files-list__tbody" + data-cy-files-list-tbody> + <component :is="dataComponent" + v-for="({key, item}, i) in renderedItems" + :key="key" + :source="item" + :index="i" + v-bind="extraProps" /> + </tbody> + + <!-- Footer --> + <tfoot ref="footer" + class="files-list__tfoot" + data-cy-files-list-tfoot> + <slot name="footer" /> + </tfoot> + </table> + </div> +</template> + +<script lang="ts"> +import type { File, Folder, Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { defineComponent } from 'vue' +import debounce from 'debounce' + +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import logger from '../logger.ts' + +interface RecycledPoolItem { + key: string, + item: Node, +} + +type DataSource = File | Folder +type DataSourceKey = keyof DataSource + +export default defineComponent({ + name: 'VirtualList', + + props: { + dataComponent: { + type: [Object, Function], + required: true, + }, + dataKey: { + type: String as PropType<DataSourceKey>, + required: true, + }, + dataSources: { + type: Array as PropType<DataSource[]>, + required: true, + }, + extraProps: { + type: Object as PropType<Record<string, unknown>>, + default: () => ({}), + }, + scrollToIndex: { + type: Number, + default: 0, + }, + gridMode: { + type: Boolean, + default: false, + }, + /** + * Visually hidden caption for the table accessibility + */ + caption: { + type: String, + default: '', + }, + }, + + setup() { + const fileListWidth = useFileListWidth() + + return { + fileListWidth, + } + }, + + data() { + return { + index: this.scrollToIndex, + beforeHeight: 0, + footerHeight: 0, + headerHeight: 0, + tableHeight: 0, + resizeObserver: null as ResizeObserver | null, + } + }, + + computed: { + // Wait for measurements to be done before rendering + isReady() { + return this.tableHeight > 0 + }, + + // Items to render before and after the visible area + bufferItems() { + if (this.gridMode) { + // 1 row before and after in grid mode + return this.columnCount + } + // 3 rows before and after + return 3 + }, + + itemHeight() { + // Align with css in FilesListVirtual + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 + }, + + // Grid mode only + itemWidth() { + // 166px + 16px x 2 (padding left and right) + return 166 + 16 + 16 + }, + + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) + }, + + /** + * Number of rows that will be rendered. + * This includes only visible + buffer rows. + */ + rowCount(): number { + return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1 + }, + + /** + * Number of columns. + * 1 for list view otherwise depending on the file list width. + */ + columnCount(): number { + if (!this.gridMode) { + return 1 + } + return Math.floor(this.fileListWidth / this.itemWidth) + }, + + /** + * Index of the first item to be rendered + * The index can be any file, not just the first one + * But the start index is the first item to be rendered, + * which needs to align with the column count + */ + startIndex() { + const firstColumnIndex = this.index - (this.index % this.columnCount) + return Math.max(0, firstColumnIndex - this.bufferItems) + }, + + /** + * Number of items to be rendered at the same time + * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount` + */ + shownItems() { + // If in grid mode, we need to multiply the number of rows by the number of columns + if (this.gridMode) { + return this.rowCount * this.columnCount + } + + return this.rowCount + }, + + renderedItems(): RecycledPoolItem[] { + if (!this.isReady) { + return [] + } + + const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[] + + const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey])) + const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string) + const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key])) + + return items.map(item => { + const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey]) + // If defined, let's keep the key + if (index !== -1) { + return { + key: Object.keys(this.$_recycledPool)[index], + item, + } + } + + // Get and consume reusable key or generate a new one + const key = unusedKeys.pop() || Math.random().toString(36).substr(2) + this.$_recycledPool[key] = item[this.dataKey] + return { key, item } + }) + }, + + /** + * The total number of rows that are available + */ + totalRowCount() { + return Math.ceil(this.dataSources.length / this.columnCount) + }, + + tbodyStyle() { + // The number of (virtual) rows above the currently rendered ones. + // start index is aligned so this should always be an integer + const rowsAbove = Math.round(this.startIndex / this.columnCount) + // The number of (virtual) rows below the currently rendered ones. + const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount) + + return { + paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`, + minHeight: `${this.totalRowCount * this.itemHeight}px`, + } + }, + }, + watch: { + scrollToIndex(index) { + this.scrollTo(index) + }, + + totalRowCount() { + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }, + + columnCount(columnCount, oldColumnCount) { + if (oldColumnCount === 0) { + // We're initializing, the scroll position is handled on mounted + logger.debug('VirtualList: columnCount is 0, skipping scroll') + return + } + // If the column count changes in grid view, + // update the scroll position again + this.scrollTo(this.index) + }, + }, + + mounted() { + this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> + + this.resizeObserver = new ResizeObserver(debounce(() => { + this.updateHeightVariables() + logger.debug('VirtualList: resizeObserver updated') + this.onScroll() + }, 100)) + this.resizeObserver.observe(this.$el) + this.resizeObserver.observe(this.$refs.before as HTMLElement) + this.resizeObserver.observe(this.$refs.filters as HTMLElement) + this.resizeObserver.observe(this.$refs.footer as HTMLElement) + + this.$nextTick(() => { + // Make sure height values are initialized + this.updateHeightVariables() + // If we need to scroll to an index we do so in the next tick. + // This is needed to apply updates from the initialization of the height variables + // which will update the tbody styles until next tick. + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }) + }, + + beforeDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + }, + + methods: { + scrollTo(index: number) { + if (!this.$el || this.index === index) { + return + } + + // Check if the content is smaller (not equal! keep the footer in mind) than the viewport + // meaning there is no scrollbar + if (this.totalRowCount < this.visibleRows) { + logger.debug('VirtualList: Skip scrolling, nothing to scroll', { + index, + totalRows: this.totalRowCount, + visibleRows: this.visibleRows, + }) + return + } + + // We can not scroll further as the last page of rows + // For the grid view we also need to account for all columns in that row (columnCount - 1) + const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1) + // The scroll position + let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex)) + + // First we need to update the internal index for rendering. + // This will cause the <tbody> element to be resized allowing us to set the correct scroll position. + this.index = index + + // If this is not the first row we can add a half row from above. + // This is to help users understand the table is scrolled and not items did not just disappear. + // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area) + if (index >= this.columnCount && index <= clampedIndex) { + scrollTop -= (this.itemHeight / 2) + // As we render one half row more we also need to adjust the internal index + this.index = index - this.columnCount + } else if (index > clampedIndex) { + // If we are on the last page we cannot scroll any further + // but we can at least scroll the footer into view + if (index <= (clampedIndex + this.columnCount)) { + // We only show have of the footer for the first of the last page + // To still show the previous row partly. Same reasoning as above: + // help the user understand that the table is scrolled not "magically trimmed" + scrollTop += this.footerHeight / 2 + } else { + // We reached the very end of the files list and we are focussing not the first visible row + // so all we now can do is scroll to the end (footer) + scrollTop += this.footerHeight + } + } + + // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position + this.$nextTick(() => { + this.$el.scrollTop = scrollTop + logger.debug(`VirtualList: scrolling to index ${index}`, { + clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight, + }) + }) + }, + + onScroll() { + this._onScrollHandle ??= requestAnimationFrame(() => { + this._onScrollHandle = null + + const index = this.scrollPosToIndex(this.$el.scrollTop) + if (index === this.index) { + return + } + + // Max 0 to prevent negative index + this.index = Math.max(0, Math.floor(index)) + this.$emit('scroll') + }) + }, + + // Convert scroll position to index + // It should be the opposite of `indexToScrollPos` + scrollPosToIndex(scrollPos: number): number { + const topScroll = scrollPos - this.beforeHeight + // Max 0 to prevent negative index + return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount + }, + + // Convert index to scroll position + // It should be the opposite of `scrollPosToIndex` + indexToScrollPos(index: number): number { + return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight + }, + + /** + * Update the height variables. + * To be called by resize observer and `onMount` + */ + updateHeightVariables(): void { + this.tableHeight = this.$el?.clientHeight ?? 0 + this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0 + this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0 + + // Get the header height which consists of table header and filters + const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0 + const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0 + this.headerHeight = theadHeight + filterHeight + }, + }, +}) +</script> diff --git a/apps/files/src/composables/useBeforeNavigation.ts b/apps/files/src/composables/useBeforeNavigation.ts new file mode 100644 index 00000000000..38b72e40fb3 --- /dev/null +++ b/apps/files/src/composables/useBeforeNavigation.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { NavigationGuard } from 'vue-router' + +import { onUnmounted } from 'vue' +import { useRouter } from 'vue-router/composables' + +/** + * Helper until we use Vue-Router v4 (Vue3). + * + * @param fn - The navigation guard + */ +export function onBeforeNavigation(fn: NavigationGuard) { + const router = useRouter() + const remove = router.beforeResolve(fn) + onUnmounted(remove) +} diff --git a/apps/files/src/composables/useFileListHeaders.spec.ts b/apps/files/src/composables/useFileListHeaders.spec.ts new file mode 100644 index 00000000000..c407156412b --- /dev/null +++ b/apps/files/src/composables/useFileListHeaders.spec.ts @@ -0,0 +1,41 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Header } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useFileListHeaders } from './useFileListHeaders.ts' + +const getFileListHeaders = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/files', async (originalModule) => { + return { + ...(await originalModule()), + getFileListHeaders, + } +}) + +describe('useFileListHeaders', () => { + beforeEach(() => vi.resetAllMocks()) + + it('gets the headers', () => { + const header = new Header({ id: '1', order: 5, render: vi.fn(), updated: vi.fn() }) + getFileListHeaders.mockImplementationOnce(() => [header]) + + const headers = useFileListHeaders() + expect(headers.value).toEqual([header]) + expect(getFileListHeaders).toHaveBeenCalledOnce() + }) + + it('headers are sorted', () => { + const header = new Header({ id: '1', order: 10, render: vi.fn(), updated: vi.fn() }) + const header2 = new Header({ id: '2', order: 5, render: vi.fn(), updated: vi.fn() }) + getFileListHeaders.mockImplementationOnce(() => [header, header2]) + + const headers = useFileListHeaders() + // lower order first + expect(headers.value.map(({ id }) => id)).toStrictEqual(['2', '1']) + expect(getFileListHeaders).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/files/src/composables/useFileListHeaders.ts b/apps/files/src/composables/useFileListHeaders.ts new file mode 100644 index 00000000000..b57bcbb1432 --- /dev/null +++ b/apps/files/src/composables/useFileListHeaders.ts @@ -0,0 +1,19 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Header } from '@nextcloud/files' +import type { ComputedRef } from 'vue' + +import { getFileListHeaders } from '@nextcloud/files' +import { computed, ref } from 'vue' + +/** + * Get the registered and sorted file list headers. + */ +export function useFileListHeaders(): ComputedRef<Header[]> { + const headers = ref(getFileListHeaders()) + const sorted = computed(() => [...headers.value].sort((a, b) => a.order - b.order) as Header[]) + + return sorted +} diff --git a/apps/files/src/composables/useFileListWidth.cy.ts b/apps/files/src/composables/useFileListWidth.cy.ts new file mode 100644 index 00000000000..b0d42c4a2d6 --- /dev/null +++ b/apps/files/src/composables/useFileListWidth.cy.ts @@ -0,0 +1,56 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineComponent } from 'vue' +import { useFileListWidth } from './useFileListWidth.ts' + +const ComponentMock = defineComponent({ + template: '<div id="test-component" style="width: 100%;background: white;">{{ fileListWidth }}</div>', + setup() { + return { + fileListWidth: useFileListWidth(), + } + }, +}) +const FileListMock = defineComponent({ + template: '<main id="app-content-vue" style="width: 100%;"><component-mock /></main>', + components: { + ComponentMock, + }, +}) + +describe('composable: fileListWidth', () => { + + it('Has initial value', () => { + cy.viewport(600, 400) + + cy.mount(FileListMock, {}) + cy.get('#app-content-vue') + .should('be.visible') + .and('contain.text', '600') + }) + + it('Is reactive to size change', () => { + cy.viewport(600, 400) + cy.mount(FileListMock) + cy.get('#app-content-vue').should('contain.text', '600') + + cy.viewport(800, 400) + cy.screenshot() + cy.get('#app-content-vue').should('contain.text', '800') + }) + + it('Is reactive to style changes', () => { + cy.viewport(600, 400) + cy.mount(FileListMock) + cy.get('#app-content-vue') + .should('be.visible') + .and('contain.text', '600') + .invoke('attr', 'style', 'width: 100px') + + cy.get('#app-content-vue') + .should('contain.text', '100') + }) +}) diff --git a/apps/files/src/composables/useFileListWidth.ts b/apps/files/src/composables/useFileListWidth.ts new file mode 100644 index 00000000000..621ef204836 --- /dev/null +++ b/apps/files/src/composables/useFileListWidth.ts @@ -0,0 +1,50 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Ref } from 'vue' +import { onMounted, readonly, ref } from 'vue' + +/** The element we observe */ +let element: HTMLElement | undefined + +/** The current width of the element */ +const width = ref(0) + +const observer = new ResizeObserver((elements) => { + if (elements[0].contentBoxSize) { + // use the newer `contentBoxSize` property if available + width.value = elements[0].contentBoxSize[0].inlineSize + } else { + // fall back to `contentRect` + width.value = elements[0].contentRect.width + } +}) + +/** + * Update the observed element if needed and reconfigure the observer + */ +function updateObserver() { + const el = document.querySelector<HTMLElement>('#app-content-vue') ?? document.body + if (el !== element) { + // if already observing: stop observing the old element + if (element) { + observer.unobserve(element) + } + // observe the new element if needed + observer.observe(el) + element = el + } +} + +/** + * Get the reactive width of the file list + */ +export function useFileListWidth(): Readonly<Ref<number>> { + // Update the observer when the component is mounted (e.g. because this is the files app) + onMounted(updateObserver) + // Update the observer also in setup context, so we already have an initial value + updateObserver() + + return readonly(width) +} diff --git a/apps/files/src/composables/useHotKeys.spec.ts b/apps/files/src/composables/useHotKeys.spec.ts new file mode 100644 index 00000000000..9c001e8b5ff --- /dev/null +++ b/apps/files/src/composables/useHotKeys.spec.ts @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Location } from 'vue-router' + +import { File, Folder, Permission, View } from '@nextcloud/files' +import { enableAutoDestroy, mount } from '@vue/test-utils' +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest' +import { defineComponent, nextTick } from 'vue' +import axios from '@nextcloud/axios' + +import { getPinia } from '../store/index.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useHotKeys } from './useHotKeys.ts' +import { useUserConfigStore } from '../store/userconfig.ts' + +// this is the mocked current route +const route = vi.hoisted(() => ({ + name: 'test', + params: { + fileId: 123, + }, + query: { + openFile: 'false', + dir: '/parent/dir', + }, +})) + +// mocked router +const router = vi.hoisted(() => ({ + push: vi.fn<(route: Location) => void>(), +})) + +vi.mock('../actions/sidebarAction.ts', { spy: true }) +vi.mock('../actions/deleteAction.ts', { spy: true }) +vi.mock('../actions/favoriteAction.ts', { spy: true }) +vi.mock('../actions/renameAction.ts', { spy: true }) + +vi.mock('vue-router/composables', () => ({ + useRoute: vi.fn(() => route), + useRouter: vi.fn(() => router), +})) + +let file: File +const view = { + id: 'files', + name: 'Files', +} as View + +const TestComponent = defineComponent({ + name: 'test', + setup() { + useHotKeys() + }, + template: '<div />', +}) + +describe('HotKeysService testing', () => { + const activeStore = useActiveStore(getPinia()) + + let initialState: HTMLInputElement + + enableAutoDestroy(afterEach) + + afterEach(() => { + document.body.removeChild(initialState) + }) + + beforeEach(() => { + // Make sure the router is reset before each test + router.push.mockClear() + + // Make sure the file is reset before each test + file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE }) + const files = useFilesStore(getPinia()) + files.setRoot({ service: 'files', root }) + + // Setting the view first as it reset the active node + activeStore.activeView = view + activeStore.activeNode = file + + window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) + + mount(TestComponent) + }) + + it('Pressing d should open the sidebar once', () => { + dispatchEvent({ key: 'd', code: 'KeyD' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', altKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true }) + + expect(sidebarAction.enabled).toHaveReturnedWith(true) + expect(sidebarAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing F2 should rename the file', () => { + dispatchEvent({ key: 'F2', code: 'F2' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', altKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', metaKey: true }) + + expect(renameAction.enabled).toHaveReturnedWith(true) + expect(renameAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing s should toggle favorite', () => { + vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) + dispatchEvent({ key: 's', code: 'KeyS' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', altKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', metaKey: true }) + + expect(favoriteAction.enabled).toHaveReturnedWith(true) + expect(favoriteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing Delete should delete the file', async () => { + // @ts-expect-error unit testing + vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true) + + dispatchEvent({ key: 'Delete', code: 'Delete' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'Delete', code: 'Delete', ctrlKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', altKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', shiftKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', metaKey: true }) + + expect(deleteAction.enabled).toHaveReturnedWith(true) + expect(deleteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing alt+up should go to parent directory', () => { + expect(router.push).toHaveBeenCalledTimes(0) + dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true }) + + expect(router.push).toHaveBeenCalledOnce() + expect(router.push.mock.calls[0][0].query?.dir).toBe('/parent') + }) + + it('Pressing v should toggle grid view', async () => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent({ key: 'v', code: 'KeyV' }) + expect(userConfigStore.userConfig.grid_view).toBe(true) + }) + + it.each([ + ['ctrlKey'], + ['altKey'], + // those meta keys are still triggering... + // ['shiftKey'], + // ['metaKey'] + ])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true })) + + await nextTick() + + expect(userConfigStore.userConfig.grid_view).toBe(false) + }) +}) + +/** + * Helper to dispatch the correct event. + * + * @param init - KeyboardEvent options + */ +function dispatchEvent(init: KeyboardEventInit) { + document.body.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true })) +} diff --git a/apps/files/src/composables/useHotKeys.ts b/apps/files/src/composables/useHotKeys.ts new file mode 100644 index 00000000000..ff56627b2f9 --- /dev/null +++ b/apps/files/src/composables/useHotKeys.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { dirname } from 'path' +import { useRoute, useRouter } from 'vue-router/composables' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useRouteParameters } from './useRouteParameters.ts' +import { executeAction } from '../utils/actionUtils.ts' +import logger from '../logger.ts' + +/** + * This register the hotkeys for the Files app. + * As much as possible, we try to have all the hotkeys in one place. + * Please make sure to add tests for the hotkeys after adding a new one. + */ +export function useHotKeys(): void { + const userConfigStore = useUserConfigStore() + const { directory } = useRouteParameters() + const router = useRouter() + const route = useRoute() + + // d opens the sidebar + useHotKey('d', () => executeAction(sidebarAction), { + stop: true, + prevent: true, + }) + + // F2 renames the file + useHotKey('F2', () => executeAction(renameAction), { + stop: true, + prevent: true, + }) + + // s toggle favorite + useHotKey('s', () => executeAction(favoriteAction), { + stop: true, + prevent: true, + }) + + // Delete deletes the file + useHotKey('Delete', () => executeAction(deleteAction), { + stop: true, + prevent: true, + }) + + // alt+up go to parent directory + useHotKey('ArrowUp', goToParentDir, { + stop: true, + prevent: true, + alt: true, + }) + + // v toggle grid view + useHotKey('v', toggleGridView, { + stop: true, + prevent: true, + }) + + logger.debug('Hotkeys registered') + + /** + * Use the router to go to the parent directory + */ + function goToParentDir() { + const dir = dirname(directory.value) + + logger.debug('Navigating to parent directory', { dir }) + router.push({ params: { ...route.params }, query: { ...route.query, dir } }) + } + + /** + * Toggle the grid view + */ + function toggleGridView() { + const value = userConfigStore.userConfig.grid_view + logger.debug('Toggling grid view', { old: value, new: !value }) + userConfigStore.update('grid_view', !value) + } +} diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts new file mode 100644 index 00000000000..b9eb671a181 --- /dev/null +++ b/apps/files/src/composables/useNavigation.spec.ts @@ -0,0 +1,106 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Navigation, View } from '@nextcloud/files' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +import { useNavigation } from './useNavigation' +import * as nextcloudFiles from '@nextcloud/files' + +// Just a wrapper so we can test the composable +const TestComponent = defineComponent({ + template: '<div></div>', + setup() { + const { currentView, views } = useNavigation() + return { + currentView, + views, + } + }, +}) + +describe('Composables: useNavigation', () => { + const spy = vi.spyOn(nextcloudFiles, 'getNavigation') + let navigation: Navigation + + describe('currentView', () => { + beforeEach(() => { + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() + spy.mockImplementation(() => navigation) + }) + + it('should return null without active navigation', () => { + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null) + }) + + it('should return already active navigation', async () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + navigation.register(view) + navigation.setActive(view) + // Now the navigation is already set it should take the active navigation + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view) + }) + + it('should be reactive on updating active navigation', async () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + navigation.register(view) + const wrapper = mount(TestComponent) + + // no active navigation + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null) + + navigation.setActive(view) + // Now the navigation is set it should take the active navigation + expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(view) + }) + }) + + describe('views', () => { + beforeEach(() => { + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() + spy.mockImplementation(() => navigation) + }) + + it('should return empty array without registered views', () => { + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([]) + }) + + it('should return already registered views', () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view]) + }) + + it('should be reactive on registering new views', () => { + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 }) + + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view]) + + // now register view 2 and check it is reactivly added + navigation.register(view2) + expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view, view2]) + }) + }) +}) diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts new file mode 100644 index 00000000000..2a6f22a1232 --- /dev/null +++ b/apps/files/src/composables/useNavigation.ts @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { View } from '@nextcloud/files' +import type { ShallowRef } from 'vue' + +import { getNavigation } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue' + +/** + * Composable to get the currently active files view from the files navigation + * @param _loaded If set enforce a current view is loaded + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useNavigation<T extends boolean>(_loaded?: T) { + type MaybeView = T extends true ? View : (View | null); + const navigation = getNavigation() + const views: ShallowRef<View[]> = shallowRef(navigation.views) + const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView) + + /** + * Event listener to update the `currentView` + * @param event The update event + */ + function onUpdateActive(event: CustomEvent<View|null>) { + currentView.value = event.detail as MaybeView + } + + /** + * Event listener to update all registered views + */ + function onUpdateViews() { + views.value = navigation.views + triggerRef(views) + } + + onMounted(() => { + navigation.addEventListener('update', onUpdateViews) + navigation.addEventListener('updateActive', onUpdateActive) + subscribe('files:navigation:updated', onUpdateViews) + }) + onUnmounted(() => { + navigation.removeEventListener('update', onUpdateViews) + navigation.removeEventListener('updateActive', onUpdateActive) + }) + + return { + currentView, + views, + } +} diff --git a/apps/files/src/composables/useRouteParameters.ts b/apps/files/src/composables/useRouteParameters.ts new file mode 100644 index 00000000000..dbb8ca7f081 --- /dev/null +++ b/apps/files/src/composables/useRouteParameters.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { computed } from 'vue' +import { useRoute } from 'vue-router/composables' + +/** + * Get information about the current route + */ +export function useRouteParameters() { + + const route = useRoute() + + /** + * Get the path of the current active directory + */ + const directory = computed<string>( + () => String(route.query.dir || '/') + // Remove any trailing slash but leave root slash + .replace(/^(.+)\/$/, '$1'), + ) + + /** + * Get the current fileId used on the route + */ + const fileId = computed<number | null>(() => { + const fileId = Number.parseInt(route.params.fileid ?? '0') || null + return Number.isNaN(fileId) ? null : fileId + }) + + /** + * State of `openFile` route param + */ + const openFile = computed<boolean>( + // if `openfile` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'), + ) + + const openDetails = computed<boolean>( + // if `opendetails` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'opendetails' in route.query && (typeof route.query.opendetails !== 'string' || route.query.opendetails.toLocaleLowerCase() !== 'false'), + ) + + return { + /** Path of currently open directory */ + directory, + + /** Current active fileId */ + fileId, + + /** Should the active node should be opened (`openFile` route param) */ + openFile, + + /** Should the details sidebar be shown (`openDetails` route param) */ + openDetails, + } +} diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts new file mode 100644 index 00000000000..ab8dbb63dfc --- /dev/null +++ b/apps/files/src/eventbus.d.ts @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFileListFilter, Node, View } from '@nextcloud/files' +import type { SearchScope } from './types' + +declare module '@nextcloud/event-bus' { + export interface NextcloudEvents { + // mapping of 'event name' => 'event type' + 'files:config:updated': { key: string, value: boolean } + 'files:view-config:updated': { key: string, value: string|number|boolean, view: string } + + 'files:favorites:removed': Node + 'files:favorites:added': Node + + 'files:filter:added': IFileListFilter + 'files:filter:removed': string + // the state of some filters has changed + 'files:filters:changed': undefined + + 'files:navigation:changed': View + + 'files:node:created': Node + 'files:node:deleted': Node + 'files:node:updated': Node + 'files:node:rename': Node + 'files:node:renamed': Node + 'files:node:moved': { node: Node, oldSource: string } + + 'files:search:updated': { query: string, scope: SearchScope } + } +} + +export {} diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts new file mode 100644 index 00000000000..f86269ccd99 --- /dev/null +++ b/apps/files/src/filters/FilenameFilter.ts @@ -0,0 +1,75 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { getPinia } from '../store/index.ts' +import { useSearchStore } from '../store/search.ts' + +/** + * Register the filename filter + */ +export function registerFilenameFilter() { + registerFileListFilter(new FilenameFilter()) +} + +/** + * Simple file list filter controlled by the Navigation search box + */ +class FilenameFilter extends FileListFilter { + + private searchQuery = '' + + constructor() { + super('files:filename', 5) + subscribe('files:search:updated', ({ query, scope }) => { + if (scope === 'filter') { + this.updateQuery(query) + } + }) + } + + public filter(nodes: INode[]): INode[] { + const queryParts = this.searchQuery.toLocaleLowerCase().split(' ').filter(Boolean) + return nodes.filter((node) => { + const displayname = node.displayname.toLocaleLowerCase() + return queryParts.every((part) => displayname.includes(part)) + }) + } + + public reset(): void { + this.updateQuery('') + } + + public updateQuery(query: string) { + query = (query || '').trim() + + // Only if the query is different we update the filter to prevent re-computing all nodes + if (query !== this.searchQuery) { + this.searchQuery = query + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (query !== '') { + chips.push({ + text: query, + onclick: () => { + this.updateQuery('') + }, + }) + } else { + // make sure to also reset the search store when pressing the "X" on the filter chip + const store = useSearchStore(getPinia()) + if (store.scope === 'filter') { + store.query = '' + } + } + this.updateChips(chips) + } + } + +} diff --git a/apps/files/src/filters/HiddenFilesFilter.ts b/apps/files/src/filters/HiddenFilesFilter.ts new file mode 100644 index 00000000000..e48881d4ab7 --- /dev/null +++ b/apps/files/src/filters/HiddenFilesFilter.ts @@ -0,0 +1,42 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { UserConfig } from '../types' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' + +class HiddenFilesFilter extends FileListFilter { + + private showHidden?: boolean + + constructor() { + super('files:hidden', 0) + this.showHidden = loadState<Partial<UserConfig>>('files', 'config', { show_hidden: false }).show_hidden + + subscribe('files:config:updated', ({ key, value }) => { + if (key === 'show_hidden') { + this.showHidden = Boolean(value) + this.filterUpdated() + } + }) + } + + public filter(nodes: INode[]): INode[] { + if (this.showHidden) { + return nodes + } + return nodes.filter((node) => (node.attributes.hidden !== true && !node.basename.startsWith('.'))) + } + +} + +/** + * Register a file list filter to only show hidden files if enabled by user config + */ +export function registerHiddenFilesFilter() { + registerFileListFilter(new HiddenFilesFilter()) +} diff --git a/apps/files/src/filters/ModifiedFilter.ts b/apps/files/src/filters/ModifiedFilter.ts new file mode 100644 index 00000000000..e7d7c2f26a7 --- /dev/null +++ b/apps/files/src/filters/ModifiedFilter.ts @@ -0,0 +1,114 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import Vue from 'vue' +import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue' + +import calendarSvg from '@mdi/svg/svg/calendar.svg?raw' + +export interface ITimePreset { + id: string, + label: string, + filter: (time: number) => boolean +} + +const startOfToday = () => (new Date()).setHours(0, 0, 0, 0) + +/** + * Available presets + */ +const timePresets: ITimePreset[] = [ + { + id: 'today', + label: t('files', 'Today'), + filter: (time: number) => time > startOfToday(), + }, + { + id: 'last-7', + label: t('files', 'Last 7 days'), + filter: (time: number) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)), + }, + { + id: 'last-30', + label: t('files', 'Last 30 days'), + filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)), + }, + { + id: 'this-year', + label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }), + filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1), + }, + { + id: 'last-year', + label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }), + filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)), + }, +] as const + +class ModifiedFilter extends FileListFilter { + + private currentInstance?: Vue + private currentPreset?: ITimePreset + + constructor() { + super('files:modified', 50) + } + + public mount(el: HTMLElement) { + if (this.currentInstance) { + this.currentInstance.$destroy() + } + + const View = Vue.extend(FileListFilterModified as never) + this.currentInstance = new View({ + propsData: { + timePresets, + }, + el, + }) + .$on('update:preset', this.setPreset.bind(this)) + .$mount() + } + + public filter(nodes: INode[]): INode[] { + if (!this.currentPreset) { + return nodes + } + + return nodes.filter((node) => node.mtime === undefined || this.currentPreset!.filter(node.mtime.getTime())) + } + + public reset(): void { + this.setPreset() + } + + public setPreset(preset?: ITimePreset) { + this.currentPreset = preset + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (preset) { + chips.push({ + icon: calendarSvg, + text: preset.label, + onclick: () => this.setPreset(), + }) + } else { + (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter() + } + this.updateChips(chips) + } + +} + +/** + * Register the file list filter by modification date + */ +export function registerModifiedFilter() { + registerFileListFilter(new ModifiedFilter()) +} diff --git a/apps/files/src/filters/SearchFilter.ts b/apps/files/src/filters/SearchFilter.ts new file mode 100644 index 00000000000..4c7231fd26a --- /dev/null +++ b/apps/files/src/filters/SearchFilter.ts @@ -0,0 +1,49 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { ComponentPublicInstance } from 'vue' + +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import Vue from 'vue' +import FileListFilterToSearch from '../components/FileListFilter/FileListFilterToSearch.vue' + +class SearchFilter extends FileListFilter { + + private currentInstance?: ComponentPublicInstance<typeof FileListFilterToSearch> + + constructor() { + super('files:filter-to-search', 999) + subscribe('files:search:updated', ({ query, scope }) => { + if (query && scope === 'filter') { + this.currentInstance?.showButton() + } else { + this.currentInstance?.hideButton() + } + }) + } + + public mount(el: HTMLElement) { + if (this.currentInstance) { + this.currentInstance.$destroy() + } + + const View = Vue.extend(FileListFilterToSearch) + this.currentInstance = new View().$mount(el) as unknown as ComponentPublicInstance<typeof FileListFilterToSearch> + } + + public filter(nodes: INode[]): INode[] { + return nodes + } + +} + +/** + * Register a file list filter to only show hidden files if enabled by user config + */ +export function registerFilterToSearchToggle() { + registerFileListFilter(new SearchFilter()) +} diff --git a/apps/files/src/filters/TypeFilter.ts b/apps/files/src/filters/TypeFilter.ts new file mode 100644 index 00000000000..3170e22b260 --- /dev/null +++ b/apps/files/src/filters/TypeFilter.ts @@ -0,0 +1,192 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import Vue from 'vue' +import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue' + +// TODO: Create a modern replacement for OC.MimeType... +import svgDocument from '@mdi/svg/svg/file-document.svg?raw' +import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw' +import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw' +import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw' +import svgFolder from '@mdi/svg/svg/folder.svg?raw' +import svgAudio from '@mdi/svg/svg/music.svg?raw' +import svgImage from '@mdi/svg/svg/image.svg?raw' +import svgMovie from '@mdi/svg/svg/movie.svg?raw' + +export interface ITypePreset { + id: string + label: string + icon: string + mime: string[] +} + +const colorize = (svg: string, color: string) => { + return svg.replace('<path ', `<path fill="${color}" `) +} + +/** + * Available presets + */ +const getTypePresets = async () => [ + { + id: 'document', + label: t('files', 'Documents'), + icon: colorize(svgDocument, '#49abea'), + mime: ['x-office/document'], + }, + { + id: 'spreadsheet', + label: t('files', 'Spreadsheets'), + icon: colorize(svgSpreadsheet, '#9abd4e'), + mime: ['x-office/spreadsheet'], + }, + { + id: 'presentation', + label: t('files', 'Presentations'), + icon: colorize(svgPresentation, '#f0965f'), + mime: ['x-office/presentation'], + }, + { + id: 'pdf', + label: t('files', 'PDFs'), + icon: colorize(svgPDF, '#dc5047'), + mime: ['application/pdf'], + }, + { + id: 'folder', + label: t('files', 'Folders'), + icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')), + mime: ['httpd/unix-directory'], + }, + { + id: 'audio', + label: t('files', 'Audio'), + icon: svgAudio, + mime: ['audio'], + }, + { + id: 'image', + // TRANSLATORS: This is for filtering files, e.g. PNG or JPEG, so photos, drawings, or images in general + label: t('files', 'Images'), + icon: svgImage, + mime: ['image'], + }, + { + id: 'video', + label: t('files', 'Videos'), + icon: svgMovie, + mime: ['video'], + }, +] as ITypePreset[] + +class TypeFilter extends FileListFilter { + + private currentInstance?: Vue + private currentPresets: ITypePreset[] + private allPresets?: ITypePreset[] + + constructor() { + super('files:type', 10) + this.currentPresets = [] + } + + public async mount(el: HTMLElement) { + // We need to defer this as on init script this is not available: + if (this.allPresets === undefined) { + this.allPresets = await getTypePresets() + } + + // Already mounted + if (this.currentInstance) { + this.currentInstance.$destroy() + delete this.currentInstance + } + + const View = Vue.extend(FileListFilterType as never) + this.currentInstance = new View({ + propsData: { + presets: this.currentPresets, + typePresets: this.allPresets!, + }, + el, + }) + .$on('update:presets', this.setPresets.bind(this)) + .$mount() + } + + public filter(nodes: INode[]): INode[] { + if (!this.currentPresets || this.currentPresets.length === 0) { + return nodes + } + + const mimeList = this.currentPresets.reduce((previous: string[], current) => [...previous, ...current.mime], [] as string[]) + return nodes.filter((node) => { + if (!node.mime) { + return false + } + const mime = node.mime.toLowerCase() + + if (mimeList.includes(mime)) { + return true + } else if (mimeList.includes(window.OC.MimeTypeList.aliases[mime])) { + return true + } else if (mimeList.includes(mime.split('/')[0])) { + return true + } + return false + }) + } + + public reset(): void { + this.setPresets() + } + + public setPresets(presets?: ITypePreset[]) { + this.currentPresets = presets ?? [] + if (this.currentInstance !== undefined) { + // could be called before the instance was created + // (meaning the files list is not mounted yet) + this.currentInstance.$props.presets = presets + } + + this.filterUpdated() + + const chips: IFileListFilterChip[] = [] + if (presets && presets.length > 0) { + for (const preset of presets) { + chips.push({ + icon: preset.icon, + text: preset.label, + onclick: () => this.removeFilterPreset(preset.id), + }) + } + } else { + (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter() + } + this.updateChips(chips) + } + + /** + * Helper callback that removed a preset from selected. + * This is used when clicking on "remove" on a filter-chip. + * @param presetId Id of preset to remove + */ + private removeFilterPreset(presetId: string) { + const filtered = this.currentPresets.filter(({ id }) => id !== presetId) + this.setPresets(filtered) + } + +} + +/** + * Register the file list filter by file type + */ +export function registerTypeFilter() { + registerFileListFilter(new TypeFilter()) +} diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts new file mode 100644 index 00000000000..74eca0969b4 --- /dev/null +++ b/apps/files/src/init.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { addNewFileMenuEntry, registerDavProperty, registerFileAction } from '@nextcloud/files' + +import { action as deleteAction } from './actions/deleteAction' +import { action as downloadAction } from './actions/downloadAction' +import { action as editLocallyAction } from './actions/openLocallyAction.ts' +import { action as favoriteAction } from './actions/favoriteAction' +import { action as moveOrCopyAction } from './actions/moveOrCopyAction' +import { action as openFolderAction } from './actions/openFolderAction' +import { action as openInFilesAction } from './actions/openInFilesAction' +import { action as renameAction } from './actions/renameAction' +import { action as sidebarAction } from './actions/sidebarAction' +import { action as viewInFolderAction } from './actions/viewInFolderAction' + +import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts' +import { registerTypeFilter } from './filters/TypeFilter.ts' +import { registerModifiedFilter } from './filters/ModifiedFilter.ts' + +import { entry as newFolderEntry } from './newMenu/newFolder.ts' +import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts' +import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' + +import { registerFavoritesView } from './views/favorites.ts' +import registerRecentView from './views/recent' +import { registerPersonalFilesView } from './views/personal-files' +import { registerFilesView } from './views/files' +import { registerFolderTreeView } from './views/folderTree.ts' +import { registerSearchView } from './views/search.ts' + +import registerPreviewServiceWorker from './services/ServiceWorker.js' + +import { initLivePhotos } from './services/LivePhotos' +import { isPublicShare } from '@nextcloud/sharing/public' +import { registerConvertActions } from './actions/convertAction.ts' +import { registerFilenameFilter } from './filters/FilenameFilter.ts' +import { registerFilterToSearchToggle } from './filters/SearchFilter.ts' + +// Register file actions +registerConvertActions() +registerFileAction(deleteAction) +registerFileAction(downloadAction) +registerFileAction(editLocallyAction) +registerFileAction(favoriteAction) +registerFileAction(moveOrCopyAction) +registerFileAction(openFolderAction) +registerFileAction(openInFilesAction) +registerFileAction(renameAction) +registerFileAction(sidebarAction) +registerFileAction(viewInFolderAction) + +// Register new menu entry +addNewFileMenuEntry(newFolderEntry) +addNewFileMenuEntry(newTemplatesFolder) +registerTemplateEntries() + +// Register files views when not on public share +if (isPublicShare() === false) { + registerFavoritesView() + registerFilesView() + registerPersonalFilesView() + registerRecentView() + registerSearchView() + registerFolderTreeView() +} + +// Register file list filters +registerHiddenFilesFilter() +registerTypeFilter() +registerModifiedFilter() +registerFilenameFilter() +registerFilterToSearchToggle() + +// Register preview service worker +registerPreviewServiceWorker() + +registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-blurhash', { nc: 'http://nextcloud.org/ns' }) + +initLivePhotos() diff --git a/apps/files/src/legacy/filelistSearch.js b/apps/files/src/legacy/filelistSearch.js deleted file mode 100644 index 9512f47eccc..00000000000 --- a/apps/files/src/legacy/filelistSearch.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { subscribe } from '@nextcloud/event-bus' - -(function() { - - const FilesPlugin = { - attach(fileList) { - subscribe('nextcloud:unified-search.search', ({ query }) => { - fileList.setFilter(query) - }) - subscribe('nextcloud:unified-search.reset', () => { - this.query = null - fileList.setFilter('') - }) - - }, - } - - window.OC.Plugins.register('OCA.Files.FileList', FilesPlugin) - -})() diff --git a/apps/files/src/legacy/navigationMapper.js b/apps/files/src/legacy/navigationMapper.js deleted file mode 100644 index 764a7cb6cd9..00000000000 --- a/apps/files/src/legacy/navigationMapper.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { loadState } from '@nextcloud/initial-state' -import logger from '../logger.js' - -/** - * Fetch and register the legacy files views - */ -export default function() { - const legacyViews = Object.values(loadState('files', 'navigation', {})) - - if (legacyViews.length > 0) { - logger.debug('Legacy files views detected. Processing...', legacyViews) - legacyViews.forEach(view => { - registerLegacyView(view) - if (view.sublist) { - view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id })) - } - }) - } -} - -const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded, params }) { - OCP.Files.Navigation.register({ - id, - name, - order, - params, - parent, - expanded: expanded === true, - iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id, - legacy: true, - sticky: classes.includes('pinned'), - }) -} diff --git a/apps/files/src/logger.js b/apps/files/src/logger.js deleted file mode 100644 index 39283bd331d..00000000000 --- a/apps/files/src/logger.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -import { getLoggerBuilder } from '@nextcloud/logger' - -export default getLoggerBuilder() - .setApp('files') - .detectUser() - .build() diff --git a/apps/files/src/logger.ts b/apps/files/src/logger.ts new file mode 100644 index 00000000000..33f87b424e0 --- /dev/null +++ b/apps/files/src/logger.ts @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder() + .setApp('files') + .detectUser() + .build() diff --git a/apps/files/src/main-personal-settings.js b/apps/files/src/main-personal-settings.js index 1d1942e85bb..dce190f0160 100644 --- a/apps/files/src/main-personal-settings.js +++ b/apps/files/src/main-personal-settings.js @@ -1,38 +1,17 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' -import { getRequestToken } from '@nextcloud/auth' +import { getCSPNonce } from '@nextcloud/auth' -import PersonalSettings from './components/PersonalSettings' +import PersonalSettings from './components/PersonalSettings.vue' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) +__webpack_nonce__ = getCSPNonce() Vue.prototype.t = t - -if (!window.TESTING) { - const View = Vue.extend(PersonalSettings) - new View().$mount('#files-personal-settings') -} +const View = Vue.extend(PersonalSettings) +const instance = new View() +instance.$mount('#files-personal-settings') diff --git a/apps/files/src/main.js b/apps/files/src/main.js deleted file mode 100644 index 3099a4c619c..00000000000 --- a/apps/files/src/main.js +++ /dev/null @@ -1,39 +0,0 @@ -import './templates.js' -import './legacy/filelistSearch.js' -import processLegacyFilesViews from './legacy/navigationMapper.js' - -import Vue from 'vue' -import NavigationService from './services/Navigation.ts' -import NavigationView from './views/Navigation.vue' - -import SettingsService from './services/Settings.js' -import SettingsModel from './models/Setting.js' - -import router from './router/router.js' - -// Init private and public Files namespace -window.OCA.Files = window.OCA.Files ?? {} -window.OCP.Files = window.OCP.Files ?? {} - -// Init Navigation Service -const Navigation = new NavigationService() -Object.assign(window.OCP.Files, { Navigation }) - -// Init Files App Settings Service -const Settings = new SettingsService() -Object.assign(window.OCA.Files, { Settings }) -Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel }) - -// Init Navigation View -const View = Vue.extend(NavigationView) -const FilesNavigationRoot = new View({ - name: 'FilesNavigationRoot', - propsData: { - Navigation, - }, - router, -}) -FilesNavigationRoot.$mount('#app-navigation-files') - -// Init legacy files views -processLegacyFilesViews() diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts new file mode 100644 index 00000000000..463ecaf6239 --- /dev/null +++ b/apps/files/src/main.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Pinia } from 'pinia' +import { getCSPNonce } from '@nextcloud/auth' +import { PiniaVuePlugin } from 'pinia' +import Vue from 'vue' + +import { getPinia } from './store/index.ts' +import FilesApp from './FilesApp.vue' +import router from './router/router' +import RouterService from './services/RouterService' +import SettingsModel from './models/Setting.js' +import SettingsService from './services/Settings.js' + +__webpack_nonce__ = getCSPNonce() + +declare global { + interface Window { + OC: Nextcloud.v29.OC + OCP: Nextcloud.v29.OCP + // eslint-disable-next-line @typescript-eslint/no-explicit-any + OCA: Record<string, any> + _nc_files_pinia: Pinia + } +} + +// Init private and public Files namespace +window.OCA.Files = window.OCA.Files ?? {} +window.OCP.Files = window.OCP.Files ?? {} + +// Expose router +if (!window.OCP.Files.Router) { + const Router = new RouterService(router) + Object.assign(window.OCP.Files, { Router }) +} + +// Init Pinia store +Vue.use(PiniaVuePlugin) + +// Init Files App Settings Service +const Settings = new SettingsService() +Object.assign(window.OCA.Files, { Settings }) +Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel }) + +const FilesAppVue = Vue.extend(FilesApp) +new FilesAppVue({ + router: (window.OCP.Files.Router as RouterService)._router, + pinia: getPinia(), +}).$mount('#content') diff --git a/apps/files/src/mixins/actionsMixin.ts b/apps/files/src/mixins/actionsMixin.ts new file mode 100644 index 00000000000..f81b0754431 --- /dev/null +++ b/apps/files/src/mixins/actionsMixin.ts @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { FileAction } from '@nextcloud/files' +import { defineComponent } from 'vue' + +export default defineComponent({ + + data() { + return { + openedSubmenu: null as FileAction|null, + } + }, + + computed: { + enabledSubmenuActions(): Record<string, FileAction[]> { + return (this.enabledFileActions as FileAction[]) + .reduce((record, action) => { + if (action.parent !== undefined) { + if (!record[action.parent]) { + record[action.parent] = [] + } + + record[action.parent].push(action) + } + return record + }, {} as Record<string, FileAction[]>) + }, + }, + + methods: { + /** + * Check if a menu is valid, meaning it is + * defined and has at least one action + * + * @param action The action to check + */ + isValidMenu(action: FileAction): boolean { + return this.enabledSubmenuActions[action.id]?.length > 0 + }, + + async onBackToMenuClick(action: FileAction|null) { + if (!action) { + return + } + + this.openedSubmenu = null + // Wait for first render + await this.$nextTick() + + // Focus the previous menu action button + this.$nextTick(() => { + // Focus the action button, test both batch and single action references + // as this mixin is used in both single and batch actions. + const menuAction = this.$refs[`action-batch-${action.id}`]?.[0] + || this.$refs[`action-${action.id}`]?.[0] + if (menuAction) { + menuAction.$el.querySelector('button')?.focus() + } + }) + }, + }, +}) diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts new file mode 100644 index 00000000000..12515db103f --- /dev/null +++ b/apps/files/src/mixins/filesSorting.ts @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Vue from 'vue' + +import { mapState } from 'pinia' +import { useViewConfigStore } from '../store/viewConfig' +import { useNavigation } from '../composables/useNavigation' + +export default Vue.extend({ + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + + computed: { + ...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']), + + /** + * Get the sorting mode for the current view + */ + sortingMode(): string { + return this.getConfig(this.currentView.id)?.sorting_mode as string + || this.currentView?.defaultSortKey + || 'basename' + }, + + /** + * Get the sorting direction for the current view + */ + isAscSorting(): boolean { + const sortingDirection = this.getConfig(this.currentView.id)?.sorting_direction + return sortingDirection !== 'desc' + }, + }, + + methods: { + toggleSortBy(key: string) { + // If we're already sorting by this key, flip the direction + if (this.sortingMode === key) { + this.toggleSortingDirection(this.currentView.id) + return + } + // else sort ASC by this new key + this.setSortingBy(key, this.currentView.id) + }, + }, +}) diff --git a/apps/files/src/models/Setting.js b/apps/files/src/models/Setting.js index db276da85af..1db1d818e69 100644 --- a/apps/files/src/models/Setting.js +++ b/apps/files/src/models/Setting.js @@ -1,24 +1,6 @@ /** - * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Gary Kim <gary@garykim.dev> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class Setting { @@ -27,6 +9,7 @@ export default class Setting { _el _name _open + _order /** * Create a new files app setting @@ -37,12 +20,14 @@ export default class Setting { * @param {Function} component.el function that returns an unmounted dom element to be added * @param {Function} [component.open] callback for when setting is added * @param {Function} [component.close] callback for when setting is closed + * @param {number} [component.order] the order of this setting, lower numbers are shown first */ - constructor(name, { el, open, close }) { + constructor(name, { el, open, close, order }) { this._name = name this._el = el this._open = open this._close = close + this._order = order || 0 if (typeof this._open !== 'function') { this._open = () => {} @@ -51,6 +36,18 @@ export default class Setting { if (typeof this._close !== 'function') { this._close = () => {} } + + if (typeof this._el !== 'function') { + throw new Error('Setting must have an `el` function that returns a DOM element') + } + + if (typeof this._name !== 'string') { + throw new Error('Setting must have a `name` string') + } + + if (typeof this._order !== 'number') { + throw new Error('Setting must have an `order` number') + } } get name() { @@ -69,4 +66,8 @@ export default class Setting { return this._close } + get order() { + return this._order + } + } diff --git a/apps/files/src/models/Tab.js b/apps/files/src/models/Tab.js index 63d1ad97ff6..b67d51f277f 100644 --- a/apps/files/src/models/Tab.js +++ b/apps/files/src/models/Tab.js @@ -1,25 +1,8 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { sanitizeSVG } from '@skjnldsv/sanitize-svg' +import DOMPurify from 'dompurify' export default class Tab { @@ -28,6 +11,7 @@ export default class Tab { _icon _iconSvgSanitized _mount + _setIsActive _update _destroy _enabled @@ -42,12 +26,13 @@ export default class Tab { * @param {?string} options.icon the icon css class * @param {?string} options.iconSvg the icon in svg format * @param {Function} options.mount function to mount the tab + * @param {Function} [options.setIsActive] function to forward the active state of the tab * @param {Function} options.update function to update the tab * @param {Function} options.destroy function to destroy the tab * @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean * @param {Function} [options.scrollBottomReached] executed when the tab is scrolled to the bottom */ - constructor({ id, name, icon, iconSvg, mount, update, destroy, enabled, scrollBottomReached } = {}) { + constructor({ id, name, icon, iconSvg, mount, setIsActive, update, destroy, enabled, scrollBottomReached } = {}) { if (enabled === undefined) { enabled = () => true } @@ -68,6 +53,9 @@ export default class Tab { if (typeof mount !== 'function') { throw new Error('The mount argument should be a function') } + if (setIsActive !== undefined && typeof setIsActive !== 'function') { + throw new Error('The setIsActive argument should be a function') + } if (typeof update !== 'function') { throw new Error('The update argument should be a function') } @@ -85,16 +73,14 @@ export default class Tab { this._name = name this._icon = icon this._mount = mount + this._setIsActive = setIsActive this._update = update this._destroy = destroy this._enabled = enabled this._scrollBottomReached = scrollBottomReached if (typeof iconSvg === 'string') { - sanitizeSVG(iconSvg) - .then(sanitizedSvg => { - this._iconSvgSanitized = sanitizedSvg - }) + this._iconSvgSanitized = DOMPurify.sanitize(iconSvg) } } @@ -119,6 +105,10 @@ export default class Tab { return this._mount } + get setIsActive() { + return this._setIsActive || (() => undefined) + } + get update() { return this._update } diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts new file mode 100644 index 00000000000..f0f854d2801 --- /dev/null +++ b/apps/files/src/newMenu/newFolder.ts @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Entry, Node } from '@nextcloud/files' + +import { basename } from 'path' +import { emit } from '@nextcloud/event-bus' +import { getCurrentUser } from '@nextcloud/auth' +import { Permission, Folder } from '@nextcloud/files' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' + +import FolderPlusSvg from '@mdi/svg/svg/folder-plus-outline.svg?raw' + +import { newNodeName } from '../utils/newNodeDialog' +import logger from '../logger' + +type createFolderResponse = { + fileid: number + source: string +} + +const createNewFolder = async (root: Folder, name: string): Promise<createFolderResponse> => { + const source = root.source + '/' + name + const encodedSource = root.encodedSource + '/' + encodeURIComponent(name) + + const response = await axios({ + method: 'MKCOL', + url: encodedSource, + headers: { + Overwrite: 'F', + }, + }) + return { + fileid: parseInt(response.headers['oc-fileid']), + source, + } +} + +export const entry = { + id: 'newFolder', + displayName: t('files', 'New folder'), + enabled: (context: Folder) => Boolean(context.permissions & Permission.CREATE) && Boolean(context.permissions & Permission.READ), + + // Make the svg icon color match the primary element color + iconSvgInline: FolderPlusSvg.replace(/viewBox/gi, 'style="color: var(--color-primary-element)" viewBox'), + order: 0, + + async handler(context: Folder, content: Node[]) { + const name = await newNodeName(t('files', 'New folder'), content) + if (name === null) { + return + } + try { + const { fileid, source } = await createNewFolder(context, name.trim()) + + // Create the folder in the store + const folder = new Folder({ + source, + id: fileid, + mtime: new Date(), + owner: context.owner, + permissions: Permission.ALL, + root: context?.root || '/files/' + getCurrentUser()?.uid, + // Include mount-type from parent folder as this is inherited + attributes: { + 'mount-type': context.attributes?.['mount-type'], + 'owner-id': context.attributes?.['owner-id'], + 'owner-display-name': context.attributes?.['owner-display-name'], + }, + }) + + // Show success + emit('files:node:created', folder) + showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) })) + logger.debug('Created new folder', { folder, source }) + + // Navigate to the new folder + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: String(fileid) }, + { dir: context.path }, + ) + } catch (error) { + logger.error('Creating new folder failed', { error }) + showError('Creating new folder failed') + } + }, +} as Entry diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts new file mode 100644 index 00000000000..356fc5e1611 --- /dev/null +++ b/apps/files/src/newMenu/newFromTemplate.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Entry } from '@nextcloud/files' +import type { ComponentInstance } from 'vue' +import type { TemplateFile } from '../types.ts' + +import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { newNodeName } from '../utils/newNodeDialog' +import { translate as t } from '@nextcloud/l10n' +import Vue, { defineAsyncComponent } from 'vue' + +// async to reduce bundle size +const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue')) +let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null + +const getTemplatePicker = async (context: Folder) => { + if (TemplatePicker === null) { + // Create document root + const mountingPoint = document.createElement('div') + mountingPoint.id = 'template-picker' + document.body.appendChild(mountingPoint) + + // Init vue app + TemplatePicker = new Vue({ + render: (h) => h( + TemplatePickerVue, + { + ref: 'picker', + props: { + parent: context, + }, + }, + ), + methods: { open(...args) { this.$refs.picker.open(...args) } }, + el: mountingPoint, + }) + } + return TemplatePicker +} + +/** + * Register all new-file-menu entries for all template providers + */ +export function registerTemplateEntries() { + const templates = loadState<TemplateFile[]>('files', 'templates', []) + + // Init template files menu + templates.forEach((provider, index) => { + addNewFileMenuEntry({ + id: `template-new-${provider.app}-${index}`, + displayName: provider.label, + iconClass: provider.iconClass || 'icon-file', + iconSvgInline: provider.iconSvgInline, + enabled(context: Folder): boolean { + return (context.permissions & Permission.CREATE) !== 0 + }, + order: 11, + async handler(context: Folder, content: Node[]) { + const templatePicker = getTemplatePicker(context) + const name = await newNodeName(`${provider.label}${provider.extension}`, content, { + label: t('files', 'Filename'), + name: provider.label, + }) + + if (name !== null) { + // Create the file + const picker = await templatePicker + picker.open(name.trim(), provider) + } + }, + } as Entry) + }) +} diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts new file mode 100644 index 00000000000..bf6862bda08 --- /dev/null +++ b/apps/files/src/newMenu/newTemplatesFolder.ts @@ -0,0 +1,83 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Entry, Folder, Node } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { showError } from '@nextcloud/dialogs' +import { Permission, removeNewFileMenuEntry } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateOcsUrl } from '@nextcloud/router' +import { join } from 'path' +import { newNodeName } from '../utils/newNodeDialog' + +import PlusSvg from '@mdi/svg/svg/plus.svg?raw' +import axios from '@nextcloud/axios' +import logger from '../logger.ts' + +const templatesEnabled = loadState<boolean>('files', 'templates_enabled', true) +let templatesPath = loadState<string|false>('files', 'templates_path', false) +logger.debug('Templates folder enabled', { templatesEnabled }) +logger.debug('Initial templates folder', { templatesPath }) + +/** + * Init template folder + * @param directory Folder where to create the templates folder + * @param name Name to use or the templates folder + */ +const initTemplatesFolder = async function(directory: Folder, name: string) { + const templatePath = join(directory.path, name) + try { + logger.debug('Initializing the templates directory', { templatePath }) + const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), { + templatePath, + copySystemTemplates: true, + }) + + // Go to template directory + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: undefined }, + { dir: templatePath }, + ) + + logger.info('Created new templates folder', { + ...data.ocs.data, + }) + templatesPath = data.ocs.data.templates_path as string + } catch (error) { + logger.error('Unable to initialize the templates directory') + showError(t('files', 'Unable to initialize the templates directory')) + } +} + +export const entry = { + id: 'template-picker', + displayName: t('files', 'Create templates folder'), + iconSvgInline: PlusSvg, + order: 30, + enabled(context: Folder): boolean { + // Templates disabled or templates folder already initialized + if (!templatesEnabled || templatesPath) { + return false + } + // Allow creation on your own folders only + if (context.owner !== getCurrentUser()?.uid) { + return false + } + return (context.permissions & Permission.CREATE) !== 0 + }, + async handler(context: Folder, content: Node[]) { + const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') }) + + if (name !== null) { + // Create the template folder + initTemplatesFolder(context, name) + + // Remove the menu entry + removeNewFileMenuEntry('template-picker') + } + }, +} as Entry diff --git a/apps/files/src/plugins/search/folderSearch.ts b/apps/files/src/plugins/search/folderSearch.ts new file mode 100644 index 00000000000..6aabefbfc9d --- /dev/null +++ b/apps/files/src/plugins/search/folderSearch.ts @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' +import { emit } from '@nextcloud/event-bus' +import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { imagePath } from '@nextcloud/router' +import { translate as t } from '@nextcloud/l10n' +import logger from '../../logger' + +/** + * Initialize the unified search plugin. + */ +function init() { + const OCA = window.OCA + if (!OCA.UnifiedSearch) { + return + } + + logger.info('Initializing unified search plugin: folder search from files app') + OCA.UnifiedSearch.registerFilterAction({ + id: 'in-folder', + appId: 'files', + searchFrom: 'files', + label: t('files', 'In folder'), + icon: imagePath('files', 'app.svg'), + callback: (showFilePicker: boolean = true) => { + if (showFilePicker) { + const filepicker = getFilePickerBuilder('Pick plain text files') + .addMimeTypeFilter('httpd/unix-directory') + .allowDirectories(true) + .addButton({ + label: 'Pick', + callback: (nodes: Node[]) => { + logger.info('Folder picked', { folder: nodes[0] }) + const folder = nodes[0] + const filterUpdateText = (folder.root === '/files/' + folder.basename) + ? t('files', 'Search in all files') + : t('files', 'Search in folder: {folder}', { folder: folder.basename }) + emit('nextcloud:unified-search:add-filter', { + id: 'in-folder', + appId: 'files', + searchFrom: 'files', + payload: folder, + filterUpdateText, + filterParams: { path: folder.path }, + }) + }, + }) + .build() + filepicker.pick() + } else { + logger.debug('Folder search callback was handled without showing the file picker, it might already be open') + } + }, + }) +} + +document.addEventListener('DOMContentLoaded', init) diff --git a/apps/files/src/reference-files.ts b/apps/files/src/reference-files.ts new file mode 100644 index 00000000000..3d089fe93c4 --- /dev/null +++ b/apps/files/src/reference-files.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import { t } from '@nextcloud/l10n' + +import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from '@nextcloud/vue/components/NcRichText' + +import FileWidget from './views/ReferenceFileWidget.vue' +import FileReferencePickerElement from './views/FileReferencePickerElement.vue' + +Vue.mixin({ + methods: { + t, + }, +}) + +registerWidget('file', (el, { richObjectType, richObject, accessible, interactive }) => { + const Widget = Vue.extend(FileWidget) + new Widget({ + propsData: { + richObjectType, + richObject, + accessible, + interactive, + }, + }).$mount(el) +}, () => {}, { hasInteractiveView: true }) + +registerCustomPickerElement('files', (el, { providerId, accessible }) => { + const Element = Vue.extend(FileReferencePickerElement) + const vueElement = new Element({ + propsData: { + providerId, + accessible, + }, + }).$mount(el) + return new NcCustomPickerRenderResult(vueElement.$el, vueElement) +}, (el, renderResult) => { + renderResult.object.$destroy() +}) diff --git a/apps/files/src/router/router.js b/apps/files/src/router/router.js deleted file mode 100644 index cf5e5ec5ea8..00000000000 --- a/apps/files/src/router/router.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -import Vue from 'vue' -import Router from 'vue-router' -import { generateUrl } from '@nextcloud/router' -import { stringify } from 'query-string' - -Vue.use(Router) - -const router = new Router({ - mode: 'history', - - // if index.php is in the url AND we got this far, then it's working: - // let's keep using index.php in the url - base: generateUrl('/apps/files', ''), - linkActiveClass: 'active', - - routes: [ - { - path: '/', - // Pretending we're using the default view - alias: '/files', - }, - { - path: '/:view/:fileid?', - name: 'filelist', - props: true, - }, - ], - - // Custom stringifyQuery to prevent encoding of slashes in the url - stringifyQuery(query) { - const result = stringify(query).replace(/%2F/gmi, '/') - return result ? ('?' + result) : '' - }, -}) - -export default router diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts new file mode 100644 index 00000000000..fccb4a0a2b2 --- /dev/null +++ b/apps/files/src/router/router.ts @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { RawLocation, Route } from 'vue-router' + +import { generateUrl } from '@nextcloud/router' +import { relative } from 'path' +import queryString from 'query-string' +import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router' +import Vue from 'vue' + +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' +import { defaultView } from '../utils/filesViews.ts' +import logger from '../logger.ts' + +Vue.use(Router) + +// Prevent router from throwing errors when we're already on the page we're trying to go to +const originalPush = Router.prototype.push +Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) { + if (args.length > 1) { + return originalPush.call(this, ...args) + } + return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation) +}) as typeof originalPush + +const originalReplace = Router.prototype.replace +Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) { + if (args.length > 1) { + return originalReplace.call(this, ...args) + } + return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation) +}) as typeof originalReplace + +/** + * Ignore duplicated-navigation error but forward real exceptions + * @param error The thrown error + */ +function ignoreDuplicateNavigation(error: unknown): void { + if (isNavigationFailure(error, NavigationFailureType.duplicated)) { + logger.debug('Ignoring duplicated navigation from vue-router', { error }) + } else { + throw error + } +} + +const router = new Router({ + mode: 'history', + + // if index.php is in the url AND we got this far, then it's working: + // let's keep using index.php in the url + base: generateUrl('/apps/files'), + linkActiveClass: 'active', + + routes: [ + { + path: '/', + // Pretending we're using the default view + redirect: { name: 'filelist', params: { view: defaultView() } }, + }, + { + path: '/:view/:fileid(\\d+)?', + name: 'filelist', + props: true, + }, + ], + + // Custom stringifyQuery to prevent encoding of slashes in the url + stringifyQuery(query) { + const result = queryString.stringify(query).replace(/%2F/gmi, '/') + return result ? ('?' + result) : '' + }, +}) + +// Handle aborted navigation (NavigationGuards) gracefully +router.onError((error) => { + if (isNavigationFailure(error, NavigationFailureType.aborted)) { + logger.debug('Navigation was aboorted', { error }) + } else { + throw error + } +}) + +// If navigating back from a folder to a parent folder, +// we need to keep the current dir fileid so it's highlighted +// and scrolled into view. +router.beforeResolve((to, from, next) => { + if (to.params?.parentIntercept) { + delete to.params.parentIntercept + return next() + } + + if (to.params.view !== from.params.view) { + // skip if different views + return next() + } + + const fromDir = (from.query?.dir || '/') as string + const toDir = (to.query?.dir || '/') as string + + // We are going back to a parent directory + if (relative(fromDir, toDir) === '..') { + const { getNode } = useFilesStore() + const { getPath } = usePathsStore() + + if (!from.params.view) { + logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir }) + return next() + } + + // Get the previous parent's file id + const fromSource = getPath(from.params.view, fromDir) + if (!fromSource) { + logger.error('No source found for the parent directory', { fromDir, toDir }) + return next() + } + + const fileId = getNode(fromSource)?.fileid + if (!fileId) { + logger.error('No fileid found for the parent directory', { fromDir, toDir, fromSource }) + return next() + } + + logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId }) + return next({ + name: 'filelist', + query: to.query, + params: { + ...to.params, + fileid: String(fileId), + // Prevents the beforeEach from being called again + parentIntercept: 'true', + }, + // Replace the current history entry + replace: true, + }) + } + + // else, we just continue + next() +}) + +export default router diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts new file mode 100644 index 00000000000..1013baeda6c --- /dev/null +++ b/apps/files/src/services/DropService.ts @@ -0,0 +1,198 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Upload } from '@nextcloud/upload' +import type { RootDirectory } from './DropServiceUtils' + +import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files' +import { getUploader, hasConflict } from '@nextcloud/upload' +import { join } from 'path' +import { joinPaths } from '@nextcloud/paths' +import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import Vue from 'vue' + +import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils' +import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils' +import logger from '../logger.ts' + +/** + * This function converts a list of DataTransferItems to a file tree. + * It uses the Filesystem API if available, otherwise it falls back to the File API. + * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP). + * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems + * will be cleared after the first access to the props of one of the entries. + * + * @param items the list of DataTransferItems + */ +export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => { + // Check if the browser supports the Filesystem API + // We need to cache the entries to prevent Blink engine bug that clears + // the list (`data.items`) after first access props of one of the entries + const entries = items + .filter((item) => { + if (item.kind !== 'file') { + logger.debug('Skipping dropped item', { kind: item.kind, type: item.type }) + return false + } + return true + }).map((item) => { + // MDN recommends to try both, as it might be renamed in the future + return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.() + ?? item?.webkitGetAsEntry?.() + ?? item + }) as (FileSystemEntry | DataTransferItem)[] + + let warned = false + const fileTree = new Directory('root') as RootDirectory + + // Traverse the file tree + for (const entry of entries) { + // Handle browser issues if Filesystem API is not available. Fallback to File API + if (entry instanceof DataTransferItem) { + logger.warn('Could not get FilesystemEntry of item, falling back to file') + + const file = entry.getAsFile() + if (file === null) { + logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind }) + showError(t('files', 'One of the dropped files could not be processed')) + continue + } + + // Warn the user that the browser does not support the Filesystem API + // we therefore cannot upload directories recursively. + if (file.type === 'httpd/unix-directory' || !file.type) { + if (!warned) { + logger.warn('Browser does not support Filesystem API. Directories will not be uploaded') + showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded')) + warned = true + } + continue + } + + fileTree.contents.push(file) + continue + } + + // Use Filesystem API + try { + fileTree.contents.push(await traverseTree(entry)) + } catch (error) { + // Do not throw, as we want to continue with the other files + logger.error('Error while traversing file tree', { error }) + } + } + + return fileTree +} + +export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => { + const uploader = getUploader() + + // Check for conflicts on root elements + if (await hasConflict(root.contents, contents)) { + root.contents = await resolveConflict(root.contents, destination, contents) + } + + if (root.contents.length === 0) { + logger.info('No files to upload', { root }) + showInfo(t('files', 'No files to upload')) + return [] + } + + // Let's process the files + logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents }) + const queue = [] as Promise<Upload>[] + + const uploadDirectoryContents = async (directory: Directory, path: string) => { + for (const file of directory.contents) { + // This is the relative path to the resource + // from the current uploader destination + const relativePath = join(path, file.name) + + // If the file is a directory, we need to create it first + // then browse its tree and upload its contents. + if (file instanceof Directory) { + const absolutePath = joinPaths(davRootPath, destination.path, relativePath) + try { + console.debug('Processing directory', { relativePath }) + await createDirectoryIfNotExists(absolutePath) + await uploadDirectoryContents(file, relativePath) + } catch (error) { + showError(t('files', 'Unable to create the directory {directory}', { directory: file.name })) + logger.error('', { error, absolutePath, directory: file }) + } + continue + } + + // If we've reached a file, we can upload it + logger.debug('Uploading file to ' + join(destination.path, relativePath), { file }) + + // Overriding the root to avoid changing the current uploader context + queue.push(uploader.upload(relativePath, file, destination.source)) + } + } + + // Pause the uploader to prevent it from starting + // while we compute the queue + uploader.pause() + + // Upload the files. Using '/' as the starting point + // as we already adjusted the uploader destination + await uploadDirectoryContents(root, '/') + uploader.start() + + // Wait for all promises to settle + const results = await Promise.allSettled(queue) + + // Check for errors + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while uploading files', { errors }) + showError(t('files', 'Some files could not be uploaded')) + return [] + } + + logger.debug('Files uploaded successfully') + showSuccess(t('files', 'Files uploaded successfully')) + + return Promise.all(queue) +} + +export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => { + const queue = [] as Promise<void>[] + + // Check for conflicts on root elements + if (await hasConflict(nodes, contents)) { + nodes = await resolveConflict(nodes, destination, contents) + } + + if (nodes.length === 0) { + logger.info('No files to process', { nodes }) + showInfo(t('files', 'No files to process')) + return + } + + for (const node of nodes) { + Vue.set(node, 'status', NodeStatus.LOADING) + queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true)) + } + + // Wait for all promises to settle + const results = await Promise.allSettled(queue) + nodes.forEach(node => Vue.set(node, 'status', undefined)) + + // Check for errors + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while copying or moving files', { errors }) + showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved')) + return + } + + logger.debug('Files copy/move successful') + showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully')) +} diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts new file mode 100644 index 00000000000..5f4370c7894 --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.spec.ts @@ -0,0 +1,143 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils' +import { join } from 'node:path' +import { Directory, traverseTree } from './DropServiceUtils' +import { dataTransferToFileTree } from './DropService' +import logger from '../logger' + +const dataTree = { + 'file0.txt': ['Hello, world!', 1234567890], + dir1: { + 'file1.txt': ['Hello, world!', 4567891230], + 'file2.txt': ['Hello, world!', 7891234560], + }, + dir2: { + 'file3.txt': ['Hello, world!', 1234567890], + }, +} + +// This is mocking a file tree using the FileSystem API +const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => { + const entries = Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + return new FileSystemFileEntry(fullPath, contents[0], contents[1]) + } else { + return buildFileSystemDirectoryEntry(fullPath, contents) + } + }) + return new FileSystemDirectoryEntry(path, entries) +} + +const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => { + return Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1]) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + } + + const entry = buildFileSystemDirectoryEntry(fullPath, contents) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + }) +} + +describe('Filesystem API traverseTree', () => { + it('Should traverse a file tree from root', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('root') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(3) + expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!' + }) + + it('Should traverse a file tree from a subdirectory', async () => { + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2) + const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('dir2') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(1) + expect(tree.contents[0].name).toBe('file3.txt') + expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!' + }) + + it('Should properly compute the last modified', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(rootTree.lastModified).toBe(7891234560) + + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2) + const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + expect(dir2Tree.lastModified).toBe(1234567890) + }) +}) + +describe('DropService dataTransferToFileTree', () => { + + beforeAll(() => { + // @ts-expect-error jsdom doesn't have DataTransferItem + delete window.DataTransferItem + // DataTransferItem doesn't exists in jsdom, let's mock + // a dumb one so we can check the instanceof + // @ts-expect-error jsdom doesn't have DataTransferItem + window.DataTransferItem = DataTransferItemMock + }) + + it('Should return a RootDirectory with Filesystem API', async () => { + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree) + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(3) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[1]).toBeInstanceOf(Directory) + expect((fileTree.contents[1] as Directory).contents).toHaveLength(2) + expect(fileTree.contents[2]).toBeInstanceOf(Directory) + expect((fileTree.contents[2] as Directory).contents).toHaveLength(1) + + expect(logger.error).not.toBeCalled() + expect(logger.warn).not.toBeCalled() + }) + + it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => { + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree, false) + + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(1) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[0]).not.toBeInstanceOf(Directory) + expect((fileTree.contents[0].name)).toBe('file0.txt') + + expect(logger.error).not.toBeCalled() + expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded') + expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenCalledTimes(4) + }) +}) diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts new file mode 100644 index 00000000000..f10a09cfe27 --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.ts @@ -0,0 +1,178 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { emit } from '@nextcloud/event-bus' +import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files' +import { openConflictPicker } from '@nextcloud/upload' +import { showError, showInfo } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +import logger from '../logger.ts' + +/** + * This represents a Directory in the file tree + * We extend the File class to better handling uploading + * and stay as close as possible as the Filesystem API. + * This also allow us to hijack the size or lastModified + * properties to compute them dynamically. + */ +export class Directory extends File { + + /* eslint-disable no-use-before-define */ + _contents: (Directory|File)[] + + constructor(name, contents: (Directory|File)[] = []) { + super([], name, { type: 'httpd/unix-directory' }) + this._contents = contents + } + + set contents(contents: (Directory|File)[]) { + this._contents = contents + } + + get contents(): (Directory|File)[] { + return this._contents + } + + get size() { + return this._computeDirectorySize(this) + } + + get lastModified() { + if (this._contents.length === 0) { + return Date.now() + } + return this._computeDirectoryMtime(this) + } + + /** + * Get the last modification time of a file tree + * This is not perfect, but will get us a pretty good approximation + * @param directory the directory to traverse + */ + _computeDirectoryMtime(directory: Directory): number { + return directory.contents.reduce((acc, file) => { + return file.lastModified > acc + // If the file is a directory, the lastModified will + // also return the results of its _computeDirectoryMtime method + // Fancy recursion, huh? + ? file.lastModified + : acc + }, 0) + } + + /** + * Get the size of a file tree + * @param directory the directory to traverse + */ + _computeDirectorySize(directory: Directory): number { + return directory.contents.reduce((acc: number, entry: Directory|File) => { + // If the file is a directory, the size will + // also return the results of its _computeDirectorySize method + // Fancy recursion, huh? + return acc + entry.size + }, 0) + } + +} + +export type RootDirectory = Directory & { + name: 'root' +} + +/** + * Traverse a file tree using the Filesystem API + * @param entry the entry to traverse + */ +export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => { + // Handle file + if (entry.isFile) { + return new Promise<File>((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject) + }) + } + + // Handle directory + logger.debug('Handling recursive file tree', { entry: entry.name }) + const directory = entry as FileSystemDirectoryEntry + const entries = await readDirectory(directory) + const contents = (await Promise.all(entries.map(traverseTree))).flat() + return new Directory(directory.name, contents) +} + +/** + * Read a directory using Filesystem API + * @param directory the directory to read + */ +const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => { + const dirReader = directory.createReader() + + return new Promise<FileSystemEntry[]>((resolve, reject) => { + const entries = [] as FileSystemEntry[] + const getEntries = () => { + dirReader.readEntries((results) => { + if (results.length) { + entries.push(...results) + getEntries() + } else { + resolve(entries) + } + }, (error) => { + reject(error) + }) + } + + getEntries() + }) +} + +export const createDirectoryIfNotExists = async (absolutePath: string) => { + const davClient = davGetClient() + const dirExists = await davClient.exists(absolutePath) + if (!dirExists) { + logger.debug('Directory does not exist, creating it', { absolutePath }) + await davClient.createDirectory(absolutePath, { recursive: true }) + const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat> + emit('files:node:created', davResultToNode(stat.data)) + } +} + +export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => { + try { + // List all conflicting files + const conflicts = files.filter((file: File|Node) => { + return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename)) + }).filter(Boolean) as (File|Node)[] + + // List of incoming files that are NOT in conflict + const uploads = files.filter((file: File|Node) => { + return !conflicts.includes(file) + }) + + // Let the user choose what to do with the conflicting files + const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents) + + logger.debug('Conflict resolution', { uploads, selected, renamed }) + + // If the user selected nothing, we cancel the upload + if (selected.length === 0 && renamed.length === 0) { + // User skipped + showInfo(t('files', 'Conflicts resolution skipped')) + logger.info('User skipped the conflict resolution') + return [] + } + + // Update the list of files to upload + return [...uploads, ...selected, ...renamed] as (typeof files) + } catch (error) { + console.error(error) + // User cancelled + showError(t('files', 'Upload cancelled')) + logger.error('User cancelled the upload') + } + + return [] +} diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts new file mode 100644 index 00000000000..e156c92c511 --- /dev/null +++ b/apps/files/src/services/Favorites.ts @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ContentsWithRoot } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { Folder, Permission, davRemoteURL, davRootPath, getFavoriteNodes } from '@nextcloud/files' +import { CancelablePromise } from 'cancelable-promise' +import { getContents as filesContents } from './Files.ts' +import { client } from './WebdavClient.ts' + +export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => { + // We only filter root files for favorites, for subfolders we can simply reuse the files contents + if (path !== '/') { + return filesContents(path) + } + + return new CancelablePromise((resolve, reject, cancel) => { + const promise = getFavoriteNodes(client) + .catch(reject) + .then((contents) => { + if (!contents) { + reject() + return + } + resolve({ + contents, + folder: new Folder({ + id: 0, + source: `${davRemoteURL}${davRootPath}`, + root: davRootPath, + owner: getCurrentUser()?.uid || null, + permissions: Permission.READ, + }), + }) + }) + cancel(() => promise.cancel()) + }) +} diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js deleted file mode 100644 index c09af45f495..00000000000 --- a/apps/files/src/services/FileInfo.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import axios from '@nextcloud/axios' - -/** - * @param {any} url - - */ -export default async function(url) { - const response = await axios({ - method: 'PROPFIND', - url, - data: `<?xml version="1.0"?> - <d:propfind xmlns:d="DAV:" - xmlns:oc="http://owncloud.org/ns" - xmlns:nc="http://nextcloud.org/ns" - xmlns:ocs="http://open-collaboration-services.org/ns"> - <d:prop> - <d:getlastmodified /> - <d:getetag /> - <d:getcontenttype /> - <d:resourcetype /> - <oc:fileid /> - <oc:permissions /> - <oc:size /> - <d:getcontentlength /> - <nc:has-preview /> - <nc:mount-type /> - <nc:is-encrypted /> - <ocs:share-permissions /> - <nc:share-attributes /> - <oc:tags /> - <oc:favorite /> - <oc:comments-unread /> - <oc:owner-id /> - <oc:owner-display-name /> - <oc:share-types /> - </d:prop> - </d:propfind>`, - }) - - // TODO: create new parser or use cdav-lib when available - const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data) - // TODO: create new parser or use cdav-lib when available - const fileInfo = OCA.Files.App.fileList.filesClient._parseFileInfo(file[0]) - - // TODO remove when no more legacy backbone is used - fileInfo.get = (key) => fileInfo[key] - fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory' - - return fileInfo -} diff --git a/apps/files/src/services/FileInfo.ts b/apps/files/src/services/FileInfo.ts new file mode 100644 index 00000000000..318236f1677 --- /dev/null +++ b/apps/files/src/services/FileInfo.ts @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable jsdoc/require-jsdoc */ + +import type { Node } from '@nextcloud/files' + +export default function(node: Node) { + const fileInfo = new OC.Files.FileInfo({ + id: node.fileid, + path: node.dirname, + name: node.basename, + mtime: node.mtime?.getTime(), + etag: node.attributes.etag, + size: node.size, + hasPreview: node.attributes.hasPreview, + isEncrypted: node.attributes.isEncrypted === 1, + isFavourited: node.attributes.favorite === 1, + mimetype: node.mime, + permissions: node.permissions, + mountType: node.attributes['mount-type'], + sharePermissions: node.attributes['share-permissions'], + shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'), + type: node.type === 'file' ? 'file' : 'dir', + attributes: node.attributes, + }) + + // TODO remove when no more legacy backbone is used + fileInfo.get = (key) => fileInfo[key] + fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory' + fileInfo.canEdit = () => Boolean(fileInfo.permissions & OC.PERMISSION_UPDATE) + + return fileInfo +} diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts new file mode 100644 index 00000000000..080ce91e538 --- /dev/null +++ b/apps/files/src/services/Files.ts @@ -0,0 +1,110 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files' +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' +import { join } from 'path' +import { client } from './WebdavClient.ts' +import { searchNodes } from './WebDavSearch.ts' +import { getPinia } from '../store/index.ts' +import { useFilesStore } from '../store/files.ts' +import { useSearchStore } from '../store/search.ts' +import logger from '../logger.ts' +/** + * Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map` + * @param stat The result returned by the webdav library + */ +export const resultToNode = (stat: FileStat): Node => davResultToNode(stat) + +/** + * Get contents implementation for the files view. + * This also allows to fetch local search results when the user is currently filtering. + * + * @param path - The path to query + */ +export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> { + const controller = new AbortController() + const searchStore = useSearchStore(getPinia()) + + if (searchStore.query.length >= 3) { + return new CancelablePromise((resolve, reject, cancel) => { + cancel(() => controller.abort()) + getLocalSearch(path, searchStore.query, controller.signal) + .then(resolve) + .catch(reject) + }) + } else { + return defaultGetContents(path) + } +} + +/** + * Generic `getContents` implementation for the users files. + * + * @param path - The path to get the contents + */ +export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> { + path = join(defaultRootPath, path) + const controller = new AbortController() + const propfindPayload = getDefaultPropfind() + + return new CancelablePromise(async (resolve, reject, onCancel) => { + onCancel(() => controller.abort()) + + try { + const contentsResponse = await client.getDirectoryContents(path, { + details: true, + data: propfindPayload, + includeSelf: true, + signal: controller.signal, + }) as ResponseDataDetailed<FileStat[]> + + const root = contentsResponse.data[0] + const contents = contentsResponse.data.slice(1) + if (root.filename !== path && `${root.filename}/` !== path) { + logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`) + throw new Error('Root node does not match requested path') + } + + resolve({ + folder: resultToNode(root) as Folder, + contents: contents.map((result) => { + try { + return resultToNode(result) + } catch (error) { + logger.error(`Invalid node detected '${result.basename}'`, { error }) + return null + } + }).filter(Boolean) as File[], + }) + } catch (error) { + reject(error) + } + }) +} + +/** + * Get the local search results for the current folder. + * + * @param path - The path + * @param query - The current search query + * @param signal - The aboort signal + */ +async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> { + const filesStore = useFilesStore(getPinia()) + let folder = filesStore.getDirectoryByPath('files', path) + if (!folder) { + const rootPath = join(defaultRootPath, path) + const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat> + folder = resultToNode(stat.data) as Folder + } + const contents = await searchNodes(query, { dir: path, signal }) + return { + folder, + contents, + } +} diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts new file mode 100644 index 00000000000..82f0fb392e5 --- /dev/null +++ b/apps/files/src/services/FolderTree.ts @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ContentsWithRoot } from '@nextcloud/files' + +import { CancelablePromise } from 'cancelable-promise' +import { davRemoteURL } from '@nextcloud/files' +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { dirname, encodePath, joinPaths } from '@nextcloud/paths' +import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n' + +import { getContents as getFiles } from './Files.ts' + +// eslint-disable-next-line no-use-before-define +type Tree = TreeNodeData[] + +interface TreeNodeData { + id: number, + basename: string, + displayName?: string, + children: Tree, +} + +export interface TreeNode { + source: string, + encodedSource: string, + path: string, + fileid: number, + basename: string, + displayName?: string, +} + +export const folderTreeId = 'folders' + +export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}` + +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) + +const compareNodes = (a: TreeNodeData, b: TreeNodeData) => collator.compare(a.displayName ?? a.basename, b.displayName ?? b.basename) + +const getTreeNodes = (tree: Tree, currentPath: string = '/', nodes: TreeNode[] = []): TreeNode[] => { + const sortedTree = tree.toSorted(compareNodes) + for (const { id, basename, displayName, children } of sortedTree) { + const path = joinPaths(currentPath, basename) + const source = `${sourceRoot}${path}` + const node: TreeNode = { + source, + encodedSource: encodeSource(source), + path, + fileid: id, + basename, + } + if (displayName) { + node.displayName = displayName + } + nodes.push(node) + if (children.length > 0) { + getTreeNodes(children, path, nodes) + } + } + return nodes +} + +export const getFolderTreeNodes = async (path: string = '/', depth: number = 1): Promise<TreeNode[]> => { + const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'), { + params: new URLSearchParams({ path, depth: String(depth) }), + }) + const nodes = getTreeNodes(tree, path) + return nodes +} + +export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path) + +export const encodeSource = (source: string): string => { + const { origin } = new URL(source) + return origin + encodePath(source.slice(origin.length)) +} + +export const getSourceParent = (source: string): string => { + const parent = dirname(source) + if (parent === sourceRoot) { + return folderTreeId + } + return encodeSource(parent) +} diff --git a/apps/files/src/services/LivePhotos.ts b/apps/files/src/services/LivePhotos.ts new file mode 100644 index 00000000000..10be42444e2 --- /dev/null +++ b/apps/files/src/services/LivePhotos.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { Node, registerDavProperty } from '@nextcloud/files' + +/** + * + */ +export function initLivePhotos(): void { + registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' }) +} + +/** + * @param {Node} node - The node + */ +export function isLivePhoto(node: Node): boolean { + return node.attributes['metadata-files-live-photo'] !== undefined +} diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts deleted file mode 100644 index e3286c79a88..00000000000 --- a/apps/files/src/services/Navigation.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -import type Node from '@nextcloud/files/dist/files/node' -import isSvg from 'is-svg' - -import logger from '../logger' - -export interface Column { - /** Unique column ID */ - id: string - /** Translated column title */ - title: string - /** Property key from Node main or additional attributes. - Will be used if no custom sort function is provided. - Sorting will be done by localCompare */ - property: string - /** Special function used to sort Nodes between them */ - sortFunction?: (nodeA: Node, nodeB: Node) => number; - /** Custom summary of the column to display at the end of the list. - Will not be displayed if nothing is provided */ - summary?: (node: Node[]) => string -} - -export interface Navigation { - /** Unique view ID */ - id: string - /** Translated view name */ - name: string - /** Method return the content of the provided path */ - getFiles: (path: string) => Node[] - /** The view icon as an inline svg */ - icon: string - /** The view order */ - order: number - /** This view column(s). Name and actions are - by default always included */ - columns?: Column[] - /** The empty view element to render your empty content into */ - emptyView?: (div: HTMLDivElement) => void - /** The parent unique ID */ - parent?: string - /** This view is sticky (sent at the bottom) */ - sticky?: boolean - /** This view has children and is expanded or not */ - expanded?: boolean - - /** - * This view is sticky a legacy view. - * Here until all the views are migrated to Vue. - * @deprecated It will be removed in a near future - */ - legacy?: boolean - /** - * An icon class. - * @deprecated It will be removed in a near future - */ - iconClass?: string -} - -export default class { - - private _views: Navigation[] = [] - private _currentView: Navigation | null = null - - constructor() { - logger.debug('Navigation service initialized') - } - - register(view: Navigation) { - try { - isValidNavigation(view) - isUniqueNavigation(view, this._views) - } catch (e) { - if (e instanceof Error) { - logger.error(e.message, { view }) - } - throw e - } - - if (view.legacy) { - logger.warn('Legacy view detected, please migrate to Vue') - } - - if (view.iconClass) { - view.legacy = true - } - - this._views.push(view) - } - - get views(): Navigation[] { - return this._views - } - - setActive(view: Navigation | null) { - this._currentView = view - } - - get active(): Navigation | null { - return this._currentView - } - -} - -/** - * Make sure the given view is unique - * and not already registered. - */ -const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean { - if (views.find(search => search.id === view.id)) { - throw new Error(`Navigation id ${view.id} is already registered`) - } - return true -} - -/** - * Typescript cannot validate an interface. - * Please keep in sync with the Navigation interface requirements. - */ -const isValidNavigation = function(view: Navigation): boolean { - if (!view.id || typeof view.id !== 'string') { - throw new Error('Navigation id is required and must be a string') - } - - if (!view.name || typeof view.name !== 'string') { - throw new Error('Navigation name is required and must be a string') - } - - /** - * Legacy handle their content and icon differently - * TODO: remove when support for legacy views is removed - */ - if (!view.legacy) { - if (!view.getFiles || typeof view.getFiles !== 'function') { - throw new Error('Navigation getFiles is required and must be a function') - } - - if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { - throw new Error('Navigation icon is required and must be a valid svg string') - } - } - - if (!('order' in view) || typeof view.order !== 'number') { - throw new Error('Navigation order is required and must be a number') - } - - // Optional properties - if (view.columns) { - view.columns.forEach(isValidColumn) - } - - if (view.emptyView && typeof view.emptyView !== 'function') { - throw new Error('Navigation emptyView must be a function') - } - - if (view.parent && typeof view.parent !== 'string') { - throw new Error('Navigation parent must be a string') - } - - if ('sticky' in view && typeof view.sticky !== 'boolean') { - throw new Error('Navigation sticky must be a boolean') - } - - if ('expanded' in view && typeof view.expanded !== 'boolean') { - throw new Error('Navigation expanded must be a boolean') - } - - return true -} - -/** - * Typescript cannot validate an interface. - * Please keep in sync with the Column interface requirements. - */ -const isValidColumn = function(column: Column): boolean { - if (!column.id || typeof column.id !== 'string') { - throw new Error('Column id is required') - } - - if (!column.title || typeof column.title !== 'string') { - throw new Error('Column title is required') - } - - if (!column.property || typeof column.property !== 'string') { - throw new Error('Column property is required') - } - - // Optional properties - if (column.sortFunction && typeof column.sortFunction !== 'function') { - throw new Error('Column sortFunction must be a function') - } - - if (column.summary && typeof column.summary !== 'function') { - throw new Error('Column summary must be a function') - } - - return true -} diff --git a/apps/files/src/services/PersonalFiles.ts b/apps/files/src/services/PersonalFiles.ts new file mode 100644 index 00000000000..6d86bd3bae2 --- /dev/null +++ b/apps/files/src/services/PersonalFiles.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, ContentsWithRoot } from '@nextcloud/files' +import type { CancelablePromise } from 'cancelable-promise' +import { getCurrentUser } from '@nextcloud/auth' + +import { getContents as getFiles } from './Files' + +const currentUserId = getCurrentUser()?.uid + +/** + * Filters each file/folder on its shared status + * + * A personal file is considered a file that has all of the following properties: + * 1. the current user owns + * 2. the file is not shared with anyone + * 3. the file is not a group folder + * @todo Move to `@nextcloud/files` + * @param node The node to check + */ +export const isPersonalFile = function(node: Node): boolean { + // the type of mounts that determine whether the file is shared + const sharedMountTypes = ['group', 'shared'] + const mountType = node.attributes['mount-type'] + + return currentUserId === node.owner && !sharedMountTypes.includes(mountType) +} + +export const getContents = (path: string = '/'): CancelablePromise<ContentsWithRoot> => { + // get all the files from the current path as a cancellable promise + // then filter the files that the user does not own, or has shared / is a group folder + return getFiles(path) + .then((content) => { + content.contents = content.contents.filter(isPersonalFile) + return content + }) +} diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts new file mode 100644 index 00000000000..6dbb67f30b6 --- /dev/null +++ b/apps/files/src/services/PreviewService.ts @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// The preview service worker cache name (see webpack config) +const SWCacheName = 'previews' + +/** + * Check if the preview is already cached by the service worker + * @param previewUrl URL to check + */ +export async function isCachedPreview(previewUrl: string): Promise<boolean> { + if (!window?.caches?.open) { + return false + } + + const cache = await window.caches.open(SWCacheName) + const response = await cache.match(previewUrl) + return response !== undefined +} diff --git a/apps/files/src/services/Recent.ts b/apps/files/src/services/Recent.ts new file mode 100644 index 00000000000..d0ca285b05c --- /dev/null +++ b/apps/files/src/services/Recent.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ContentsWithRoot, Node } from '@nextcloud/files' +import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav' + +import { getCurrentUser } from '@nextcloud/auth' +import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL, davResultToNode } from '@nextcloud/files' +import { CancelablePromise } from 'cancelable-promise' +import { useUserConfigStore } from '../store/userconfig.ts' +import { getPinia } from '../store/index.ts' +import { client } from './WebdavClient.ts' +import { getBaseUrl } from '@nextcloud/router' + +const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 14)) + +/** + * Helper to map a WebDAV result to a Nextcloud node + * The search endpoint already includes the dav remote URL so we must not include it in the source + * + * @param stat the WebDAV result + */ +const resultToNode = (stat: FileStat) => davResultToNode(stat, davRootPath, getBaseUrl()) + +/** + * Get recently changed nodes + * + * This takes the users preference about hidden files into account. + * If hidden files are not shown, then also recently changed files *in* hidden directories are filtered. + * + * @param path Path to search for recent changes + */ +export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => { + const store = useUserConfigStore(getPinia()) + + /** + * Filter function that returns only the visible nodes - or hidden if explicitly configured + * @param node The node to check + */ + const filterHidden = (node: Node) => + path !== '/' // We need to hide files from hidden directories in the root if not configured to show + || store.userConfig.show_hidden // If configured to show hidden files we can early return + || !node.dirname.split('/').some((dir) => dir.startsWith('.')) // otherwise only include the file if non of the parent directories is hidden + + const controller = new AbortController() + const handler = async () => { + const contentsResponse = await client.search('/', { + signal: controller.signal, + details: true, + data: davGetRecentSearch(lastTwoWeeksTimestamp), + }) as ResponseDataDetailed<SearchResult> + + const contents = contentsResponse.data.results + .map(resultToNode) + .filter(filterHidden) + + return { + folder: new Folder({ + id: 0, + source: `${davRemoteURL}${davRootPath}`, + root: davRootPath, + owner: getCurrentUser()?.uid || null, + permissions: Permission.READ, + }), + contents, + } + } + + return new CancelablePromise(async (resolve, reject, cancel) => { + cancel(() => controller.abort()) + resolve(handler()) + }) +} diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts new file mode 100644 index 00000000000..4e2999b1d29 --- /dev/null +++ b/apps/files/src/services/RouterService.ts @@ -0,0 +1,75 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Route, Location } from 'vue-router' +import type VueRouter from 'vue-router' + +export default class RouterService { + + // typescript compiles this to `#router` to make it private even in JS, + // but in TS it needs to be called without the visibility specifier + private router: VueRouter + + constructor(router: VueRouter) { + this.router = router + } + + get name(): string | null | undefined { + return this.router.currentRoute.name + } + + get query(): Record<string, string | (string | null)[] | null | undefined> { + return this.router.currentRoute.query || {} + } + + get params(): Record<string, string> { + return this.router.currentRoute.params || {} + } + + /** + * This is a protected getter only for internal use + * @private + */ + get _router() { + return this.router + } + + /** + * Trigger a route change on the files app + * + * @param path the url path, eg: '/trashbin?dir=/Deleted' + * @param replace replace the current history + * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location + */ + goTo(path: string, replace = false): Promise<Route> { + return this.router.push({ + path, + replace, + }) + } + + /** + * Trigger a route change on the files App + * + * @param name the route name + * @param params the route parameters + * @param query the url query parameters + * @param replace replace the current history + * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location + */ + goToRoute( + name?: string, + params?: Record<string, string>, + query?: Record<string, string | (string | null)[] | null | undefined>, + replace?: boolean, + ): Promise<Route> { + return this.router.push({ + name, + query, + params, + replace, + } as Location) + } + +} diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts new file mode 100644 index 00000000000..c2840521a15 --- /dev/null +++ b/apps/files/src/services/Search.spec.ts @@ -0,0 +1,61 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia, setActivePinia } from 'pinia' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { getContents } from './Search.ts' +import { Folder, Permission } from '@nextcloud/files' + +const searchNodes = vi.hoisted(() => vi.fn()) +vi.mock('./WebDavSearch.ts', () => ({ searchNodes })) +vi.mock('@nextcloud/auth') + +describe('Search service', () => { + const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' }) + + beforeAll(() => { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router ??= { params: {}, query: {} } + vi.spyOn(window.OCP.Files.Router, 'params', 'get').mockReturnValue({ view: 'files' }) + }) + + beforeEach(() => { + vi.restoreAllMocks() + setActivePinia(createPinia()) + }) + + it('rejects on error', async () => { + searchNodes.mockImplementationOnce(() => { throw new Error('expected error') }) + expect(getContents).rejects.toThrow('expected error') + }) + + it('returns the search results and a fake root', async () => { + searchNodes.mockImplementationOnce(() => [fakeFolder]) + const { contents, folder } = await getContents() + + expect(searchNodes).toHaveBeenCalledOnce() + expect(contents).toHaveLength(1) + expect(contents).toEqual([fakeFolder]) + // read only root + expect(folder.permissions).toBe(Permission.READ) + }) + + it('can be cancelled', async () => { + const { promise, resolve } = Promise.withResolvers<Event>() + searchNodes.mockImplementationOnce(async (_, { signal }: { signal: AbortSignal}) => { + signal.addEventListener('abort', resolve) + await promise + return [] + }) + + const content = getContents() + content.cancel() + + // its cancelled thus the promise returns the event + const event = await promise + expect(event.type).toBe('abort') + }) +}) diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts new file mode 100644 index 00000000000..f1d7c30a94e --- /dev/null +++ b/apps/files/src/services/Search.ts @@ -0,0 +1,43 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ContentsWithRoot } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import { Folder, Permission } from '@nextcloud/files' +import { defaultRemoteURL } from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' +import { searchNodes } from './WebDavSearch.ts' +import logger from '../logger.ts' +import { useSearchStore } from '../store/search.ts' +import { getPinia } from '../store/index.ts' + +/** + * Get the contents for a search view + */ +export function getContents(): CancelablePromise<ContentsWithRoot> { + const controller = new AbortController() + + const searchStore = useSearchStore(getPinia()) + + return new CancelablePromise<ContentsWithRoot>(async (resolve, reject, cancel) => { + cancel(() => controller.abort()) + try { + const contents = await searchNodes(searchStore.query, { signal: controller.signal }) + resolve({ + contents, + folder: new Folder({ + id: 0, + source: `${defaultRemoteURL}#search`, + owner: getCurrentUser()!.uid, + permissions: Permission.READ, + }), + }) + } catch (error) { + logger.error('Failed to fetch search results', { error }) + reject(error) + } + }) +} diff --git a/apps/files/src/services/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js new file mode 100644 index 00000000000..cc13db44009 --- /dev/null +++ b/apps/files/src/services/ServiceWorker.js @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { generateUrl, getRootUrl } from '@nextcloud/router' +import logger from '../logger.ts' + +export default () => { + if ('serviceWorker' in navigator) { + // Use the window load event to keep the page load performant + window.addEventListener('load', async () => { + try { + const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true }) + let scope = getRootUrl() + // If the instance is not in a subfolder an empty string will be returned. + // The service worker registration will use the current path if it receives an empty string, + // which will result in a service worker registration for every single path the user visits. + if (scope === '') { + scope = '/' + } + + const registration = await navigator.serviceWorker.register(url, { scope }) + logger.debug('SW registered: ', { registration }) + } catch (error) { + logger.error('SW registration failed: ', { error }) + } + }) + } else { + logger.debug('Service Worker is not enabled on this browser.') + } +} diff --git a/apps/files/src/services/Settings.js b/apps/files/src/services/Settings.js index 83c2c850580..7f04aa82fda 100644 --- a/apps/files/src/services/Settings.js +++ b/apps/files/src/services/Settings.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - * - * @author Gary Kim <gary@garykim.dev> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class Settings { diff --git a/apps/files/src/services/Sidebar.js b/apps/files/src/services/Sidebar.js index e87ee71a4b1..0f5c275e532 100644 --- a/apps/files/src/services/Sidebar.js +++ b/apps/files/src/services/Sidebar.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class Sidebar { diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js index c242f9ae82d..d7f25846ceb 100644 --- a/apps/files/src/services/Templates.js +++ b/apps/files/src/services/Templates.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateOcsUrl } from '@nextcloud/router' @@ -28,18 +11,25 @@ export const getTemplates = async function() { return response.data.ocs.data } +export const getTemplateFields = async function(fileId) { + const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`)) + return response.data.ocs.data +} + /** * Create a new file from a specified template * * @param {string} filePath The new file destination path * @param {string} templatePath The template source path * @param {string} templateType The template type e.g 'user' + * @param {object} templateFields The template fields to fill in (if any) */ -export const createFromTemplate = async function(filePath, templatePath, templateType) { +export const createFromTemplate = async function(filePath, templatePath, templateType, templateFields) { const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), { filePath, templatePath, templateType, + templateFields, }) return response.data.ocs.data } diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts new file mode 100644 index 00000000000..feb7f30b357 --- /dev/null +++ b/apps/files/src/services/WebDavSearch.ts @@ -0,0 +1,83 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { ResponseDataDetailed, SearchResult } from 'webdav' + +import { getCurrentUser } from '@nextcloud/auth' +import { defaultRootPath, getDavNameSpaces, getDavProperties, resultToNode } from '@nextcloud/files/dav' +import { getBaseUrl } from '@nextcloud/router' +import { client } from './WebdavClient.ts' +import logger from '../logger.ts' + +export interface SearchNodesOptions { + dir?: string, + signal?: AbortSignal +} + +/** + * Search for nodes matching the given query. + * + * @param query - Search query + * @param options - Options + * @param options.dir - The base directory to scope the search to + * @param options.signal - Abort signal for the request + */ +export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise<INode[]> { + const user = getCurrentUser() + if (!user) { + // the search plugin only works for user roots + return [] + } + + query = query.trim() + if (query.length < 3) { + // the search plugin only works with queries of at least 3 characters + return [] + } + + if (dir && !dir.startsWith('/')) { + dir = `/${dir}` + } + + logger.debug('Searching for nodes', { query, dir }) + const { data } = await client.search('/', { + details: true, + signal, + data: ` +<d:searchrequest ${getDavNameSpaces()}> + <d:basicsearch> + <d:select> + <d:prop> + ${getDavProperties()} + </d:prop> + </d:select> + <d:from> + <d:scope> + <d:href>/files/${user.uid}${dir || ''}</d:href> + <d:depth>infinity</d:depth> + </d:scope> + </d:from> + <d:where> + <d:like> + <d:prop> + <d:displayname/> + </d:prop> + <d:literal>%${query.replace('%', '')}%</d:literal> + </d:like> + </d:where> + <d:orderby/> + </d:basicsearch> +</d:searchrequest>`, + }) as ResponseDataDetailed<SearchResult> + + // check if the request was aborted + if (signal?.aborted) { + return [] + } + + // otherwise return the result mapped to Nextcloud nodes + return data.results.map((result) => resultToNode(result, defaultRootPath, getBaseUrl())) +} diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts new file mode 100644 index 00000000000..2b92deba9b4 --- /dev/null +++ b/apps/files/src/services/WebdavClient.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { Node } from '@nextcloud/files' + +import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' + +export const client = getClient() + +export const fetchNode = async (path: string): Promise<Node> => { + const propfindPayload = getDefaultPropfind() + const result = await client.stat(`${getRootPath()}${path}`, { + details: true, + data: propfindPayload, + }) as ResponseDataDetailed<FileStat> + return resultToNode(result.data) +} diff --git a/apps/files/src/sidebar.js b/apps/files/src/sidebar.ts index 58b798ed0e7..35a379ad649 100644 --- a/apps/files/src/sidebar.js +++ b/apps/files/src/sidebar.ts @@ -1,31 +1,14 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' import { translate as t } from '@nextcloud/l10n' import SidebarView from './views/Sidebar.vue' -import Sidebar from './services/Sidebar' -import Tab from './models/Tab' +import Sidebar from './services/Sidebar.js' +import Tab from './models/Tab.js' Vue.prototype.t = t @@ -36,12 +19,12 @@ if (!window.OCA.Files) { Object.assign(window.OCA.Files, { Sidebar: new Sidebar() }) Object.assign(window.OCA.Files.Sidebar, { Tab }) -console.debug('OCA.Files.Sidebar initialized') - window.addEventListener('DOMContentLoaded', function() { const contentElement = document.querySelector('body > .content') || document.querySelector('body > #content') + let vueParent + // Make sure we have a proper layout if (contentElement) { // Make sure we have a mountpoint @@ -50,15 +33,22 @@ window.addEventListener('DOMContentLoaded', function() { sidebarElement.id = 'app-sidebar' contentElement.appendChild(sidebarElement) } + + // Helps with vue debug, as we mount the sidebar to the + // content element which is a vue instance itself + vueParent = contentElement.__vue__ as Vue } // Init vue app const View = Vue.extend(SidebarView) const AppSidebar = new View({ name: 'SidebarRoot', - }) - AppSidebar.$mount('#app-sidebar') + parent: vueParent, + }).$mount('#app-sidebar') + + // Expose Sidebar methods window.OCA.Files.Sidebar.open = AppSidebar.open window.OCA.Files.Sidebar.close = AppSidebar.close window.OCA.Files.Sidebar.setFullScreenMode = AppSidebar.setFullScreenMode + window.OCA.Files.Sidebar.setShowTagsDefault = AppSidebar.setShowTagsDefault }) diff --git a/apps/files/src/store/actionsmenu.ts b/apps/files/src/store/actionsmenu.ts new file mode 100644 index 00000000000..dc5ce8cb8b3 --- /dev/null +++ b/apps/files/src/store/actionsmenu.ts @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { defineStore } from 'pinia' +import type { ActionsMenuStore } from '../types' + +export const useActionsMenuStore = defineStore('actionsmenu', { + state: () => ({ + opened: null, + } as ActionsMenuStore), +}) diff --git a/apps/files/src/store/active.ts b/apps/files/src/store/active.ts new file mode 100644 index 00000000000..1303a157b08 --- /dev/null +++ b/apps/files/src/store/active.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { FileAction, View, Node, Folder } from '@nextcloud/files' + +import { subscribe } from '@nextcloud/event-bus' +import { getNavigation } from '@nextcloud/files' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import logger from '../logger.ts' + +export const useActiveStore = defineStore('active', () => { + /** + * The currently active action + */ + const activeAction = ref<FileAction>() + + /** + * The currently active folder + */ + const activeFolder = ref<Folder>() + + /** + * The current active node within the folder + */ + const activeNode = ref<Node>() + + /** + * The current active view + */ + const activeView = ref<View>() + + initialize() + + /** + * Unset the active node if deleted + * + * @param node - The node thats deleted + * @private + */ + function onDeletedNode(node: Node) { + if (activeNode.value && activeNode.value.source === node.source) { + activeNode.value = undefined + } + } + + /** + * Callback to update the current active view + * + * @param view - The new active view + * @private + */ + function onChangedView(view: View|null = null) { + logger.debug('Setting active view', { view }) + activeView.value = view ?? undefined + activeNode.value = undefined + } + + /** + * Initalize the store - connect all event listeners. + * @private + */ + function initialize() { + const navigation = getNavigation() + + // Make sure we only register the listeners once + subscribe('files:node:deleted', onDeletedNode) + + onChangedView(navigation.active) + + // Or you can react to changes of the current active view + navigation.addEventListener('updateActive', (event) => { + onChangedView(event.detail) + }) + } + + return { + activeAction, + activeFolder, + activeNode, + activeView, + } +}) diff --git a/apps/files/src/store/dragging.ts b/apps/files/src/store/dragging.ts new file mode 100644 index 00000000000..810f662149c --- /dev/null +++ b/apps/files/src/store/dragging.ts @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { DragAndDropStore, FileSource } from '../types' + +import { defineStore } from 'pinia' +import Vue from 'vue' + +export const useDragAndDropStore = defineStore('dragging', { + state: () => ({ + dragging: [], + } as DragAndDropStore), + + actions: { + /** + * Set the selection of files being dragged currently + * @param selection array of node sources + */ + set(selection = [] as FileSource[]) { + Vue.set(this, 'dragging', selection) + }, + + /** + * Reset the selection + */ + reset() { + Vue.set(this, 'dragging', []) + }, + }, +}) diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts new file mode 100644 index 00000000000..0bcf4ce9350 --- /dev/null +++ b/apps/files/src/store/files.ts @@ -0,0 +1,198 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { FilesStore, RootsStore, RootOptions, Service, FilesState, FileSource } from '../types' +import type { Folder, Node } from '@nextcloud/files' + +import { defineStore } from 'pinia' +import { subscribe } from '@nextcloud/event-bus' +import logger from '../logger' +import Vue from 'vue' + +import { fetchNode } from '../services/WebdavClient.ts' +import { usePathsStore } from './paths.ts' + +export const useFilesStore = function(...args) { + const store = defineStore('files', { + state: (): FilesState => ({ + files: {} as FilesStore, + roots: {} as RootsStore, + }), + + getters: { + /** + * Get a file or folder by its source + * @param state + */ + getNode: (state) => (source: FileSource): Node|undefined => state.files[source], + + /** + * Get a list of files or folders by their IDs + * Note: does not return undefined values + * @param state + */ + getNodes: (state) => (sources: FileSource[]): Node[] => sources + .map(source => state.files[source]) + .filter(Boolean), + + /** + * Get files or folders by their file ID + * Multiple nodes can have the same file ID but different sources + * (e.g. in a shared context) + * @param state + */ + getNodesById: (state) => (fileId: number): Node[] => Object.values(state.files).filter(node => node.fileid === fileId), + + /** + * Get the root folder of a service + * @param state + */ + getRoot: (state) => (service: Service): Folder|undefined => state.roots[service], + }, + + actions: { + /** + * Get cached directory matching a given path + * + * @param service - The service (files view) + * @param path - The path relative within the service + * @return The folder if found + */ + getDirectoryByPath(service: string, path?: string): Folder | undefined { + const pathsStore = usePathsStore() + let folder: Folder | undefined + + // Get the containing folder from path store + if (!path || path === '/') { + folder = this.getRoot(service) + } else { + const source = pathsStore.getPath(service, path) + if (source) { + folder = this.getNode(source) as Folder | undefined + } + } + + return folder + }, + + /** + * Get cached child nodes within a given path + * + * @param service - The service (files view) + * @param path - The path relative within the service + * @return Array of cached nodes within the path + */ + getNodesByPath(service: string, path?: string): Node[] { + const folder = this.getDirectoryByPath(service, path) + + // If we found a cache entry and the cache entry was already loaded (has children) then use it + return (folder?._children ?? []) + .map((source: string) => this.getNode(source)) + .filter(Boolean) + }, + + updateNodes(nodes: Node[]) { + // Update the store all at once + const files = nodes.reduce((acc, node) => { + if (!node.fileid) { + logger.error('Trying to update/set a node without fileid', { node }) + return acc + } + + acc[node.source] = node + return acc + }, {} as FilesStore) + + Vue.set(this, 'files', { ...this.files, ...files }) + }, + + deleteNodes(nodes: Node[]) { + nodes.forEach(node => { + if (node.source) { + Vue.delete(this.files, node.source) + } + }) + }, + + setRoot({ service, root }: RootOptions) { + Vue.set(this.roots, service, root) + }, + + onDeletedNode(node: Node) { + this.deleteNodes([node]) + }, + + onCreatedNode(node: Node) { + this.updateNodes([node]) + }, + + onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) { + if (!node.fileid) { + logger.error('Trying to update/set a node without fileid', { node }) + return + } + + // Update the path of the node + Vue.delete(this.files, oldSource) + this.updateNodes([node]) + }, + + async onUpdatedNode(node: Node) { + if (!node.fileid) { + logger.error('Trying to update/set a node without fileid', { node }) + return + } + + // If we have multiple nodes with the same file ID, we need to update all of them + const nodes = this.getNodesById(node.fileid) + if (nodes.length > 1) { + await Promise.all(nodes.map(node => fetchNode(node.path))).then(this.updateNodes) + logger.debug(nodes.length + ' nodes updated in store', { fileid: node.fileid }) + return + } + + // If we have only one node with the file ID, we can update it directly + if (nodes.length === 1 && node.source === nodes[0].source) { + this.updateNodes([node]) + return + } + + // Otherwise, it means we receive an event for a node that is not in the store + fetchNode(node.path).then(n => this.updateNodes([n])) + }, + + // Handlers for legacy sidebar (no real nodes support) + onAddFavorite(node: Node) { + const ourNode = this.getNode(node.source) + if (ourNode) { + Vue.set(ourNode.attributes, 'favorite', 1) + } + }, + + onRemoveFavorite(node: Node) { + const ourNode = this.getNode(node.source) + if (ourNode) { + Vue.set(ourNode.attributes, 'favorite', 0) + } + }, + }, + }) + + const fileStore = store(...args) + // Make sure we only register the listeners once + if (!fileStore._initialized) { + subscribe('files:node:created', fileStore.onCreatedNode) + subscribe('files:node:deleted', fileStore.onDeletedNode) + subscribe('files:node:updated', fileStore.onUpdatedNode) + subscribe('files:node:moved', fileStore.onMovedNode) + // legacy sidebar + subscribe('files:favorites:added', fileStore.onAddFavorite) + subscribe('files:favorites:removed', fileStore.onRemoveFavorite) + + fileStore._initialized = true + } + + return fileStore +} diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts new file mode 100644 index 00000000000..fd16ec5dc84 --- /dev/null +++ b/apps/files/src/store/filters.ts @@ -0,0 +1,133 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files' +import { emit, subscribe } from '@nextcloud/event-bus' +import { getFileListFilters } from '@nextcloud/files' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import logger from '../logger' + +/** + * Check if the given value is an instance file list filter with mount function + * @param value The filter to check + */ +function isFileListFilterWithUi(value: IFileListFilter): value is Required<IFileListFilter> { + return 'mount' in value +} + +export const useFiltersStore = defineStore('filters', () => { + const chips = ref<Record<string, IFileListFilterChip[]>>({}) + const filters = ref<IFileListFilter[]>([]) + + /** + * Currently active filter chips + */ + const activeChips = computed<IFileListFilterChip[]>( + () => Object.values(chips.value).flat(), + ) + + /** + * Filters sorted by order + */ + const sortedFilters = computed<IFileListFilter[]>( + () => filters.value.sort((a, b) => a.order - b.order), + ) + + /** + * All filters that provide a UI for visual controlling the filter state + */ + const filtersWithUI = computed<Required<IFileListFilter>[]>( + () => sortedFilters.value.filter(isFileListFilterWithUi), + ) + + /** + * Register a new filter on the store. + * This will subscribe the store to the filters events. + * + * @param filter The filter to add + */ + function addFilter(filter: IFileListFilter) { + filter.addEventListener('update:chips', onFilterUpdateChips) + filter.addEventListener('update:filter', onFilterUpdate) + + filters.value.push(filter) + logger.debug('New file list filter registered', { id: filter.id }) + } + + /** + * Unregister a filter from the store. + * This will remove the filter from the store and unsubscribe the store from the filer events. + * @param filterId Id of the filter to remove + */ + function removeFilter(filterId: string) { + const index = filters.value.findIndex(({ id }) => id === filterId) + if (index > -1) { + const [filter] = filters.value.splice(index, 1) + filter.removeEventListener('update:chips', onFilterUpdateChips) + filter.removeEventListener('update:filter', onFilterUpdate) + logger.debug('Files list filter unregistered', { id: filterId }) + } + } + + /** + * Event handler for filter update events + * @private + */ + function onFilterUpdate() { + emit('files:filters:changed') + } + + /** + * Event handler for filter chips updates + * @param event The update event + * @private + */ + function onFilterUpdateChips(event: FilterUpdateChipsEvent) { + const id = (event.target as IFileListFilter).id + chips.value = { + ...chips.value, + [id]: [...event.detail], + } + + logger.debug('File list filter chips updated', { filter: id, chips: event.detail }) + } + + /** + * Event handler that resets all filters if the file list view was changed. + * @private + */ + function onViewChanged() { + logger.debug('Reset all file list filters - view changed') + + for (const filter of filters.value) { + if (filter.reset !== undefined) { + filter.reset() + } + } + } + + // Initialize the store + subscribe('files:navigation:changed', onViewChanged) + subscribe('files:filter:added', addFilter) + subscribe('files:filter:removed', removeFilter) + for (const filter of getFileListFilters()) { + addFilter(filter) + } + + return { + // state + chips, + filters, + filtersWithUI, + + // getters / computed + activeChips, + sortedFilters, + + // actions / methods + addFilter, + removeFilter, + } +}) diff --git a/apps/files/src/store/index.ts b/apps/files/src/store/index.ts new file mode 100644 index 00000000000..3ba667ffd2f --- /dev/null +++ b/apps/files/src/store/index.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia } from 'pinia' + +export const getPinia = () => { + if (window._nc_files_pinia) { + return window._nc_files_pinia + } + + window._nc_files_pinia = createPinia() + return window._nc_files_pinia +} diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts new file mode 100644 index 00000000000..f2654933895 --- /dev/null +++ b/apps/files/src/store/keyboard.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { defineStore } from 'pinia' +import Vue from 'vue' + +/** + * Observe various events and save the current + * special keys states. Useful for checking the + * current status of a key when executing a method. + * @param {...any} args + */ +export const useKeyboardStore = function(...args) { + const store = defineStore('keyboard', { + state: () => ({ + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }), + + actions: { + onEvent(event: MouseEvent | KeyboardEvent) { + if (!event) { + event = window.event as MouseEvent | KeyboardEvent + } + Vue.set(this, 'altKey', !!event.altKey) + Vue.set(this, 'ctrlKey', !!event.ctrlKey) + Vue.set(this, 'metaKey', !!event.metaKey) + Vue.set(this, 'shiftKey', !!event.shiftKey) + }, + }, + }) + + const keyboardStore = store(...args) + // Make sure we only register the listeners once + if (!keyboardStore._initialized) { + window.addEventListener('keydown', keyboardStore.onEvent) + window.addEventListener('keyup', keyboardStore.onEvent) + window.addEventListener('mousemove', keyboardStore.onEvent) + + keyboardStore._initialized = true + } + + return keyboardStore +} diff --git a/apps/files/src/store/paths.spec.ts b/apps/files/src/store/paths.spec.ts new file mode 100644 index 00000000000..932e8b1a6a1 --- /dev/null +++ b/apps/files/src/store/paths.spec.ts @@ -0,0 +1,166 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, beforeEach, test, expect } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { usePathsStore } from './paths.ts' +import { emit } from '@nextcloud/event-bus' +import { File, Folder } from '@nextcloud/files' +import { useFilesStore } from './files.ts' + +describe('Path store', () => { + + let store: ReturnType<typeof usePathsStore> + let files: ReturnType<typeof useFilesStore> + let root: Folder & { _children?: string[] } + + beforeEach(() => { + setActivePinia(createPinia()) + + root = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/', id: 1 }) + files = useFilesStore() + files.setRoot({ service: 'files', root }) + + store = usePathsStore() + }) + + test('Folder is created', () => { + // no defined paths + expect(store.paths).toEqual({}) + + // create the folder + const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 }) + emit('files:node:created', node) + + // see that the path is added + expect(store.paths).toEqual({ files: { [node.path]: node.source } }) + + // see that the node is added + expect(root._children).toEqual([node.source]) + }) + + test('File is created', () => { + // no defined paths + expect(store.paths).toEqual({}) + + // create the file + const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' }) + emit('files:node:created', node) + + // see that there are still no paths + expect(store.paths).toEqual({}) + + // see that the node is added + expect(root._children).toEqual([node.source]) + }) + + test('Existing file is created', () => { + // no defined paths + expect(store.paths).toEqual({}) + + // create the file + const node1 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' }) + emit('files:node:created', node1) + + // see that there are still no paths + expect(store.paths).toEqual({}) + + // see that the node is added + expect(root._children).toEqual([node1.source]) + + // create the same named file again + const node2 = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' }) + emit('files:node:created', node2) + + // see that there are still no paths and the children are not duplicated + expect(store.paths).toEqual({}) + expect(root._children).toEqual([node1.source]) + + }) + + test('Existing folder is created', () => { + // no defined paths + expect(store.paths).toEqual({}) + + // create the file + const node1 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 }) + emit('files:node:created', node1) + + // see the path is added + expect(store.paths).toEqual({ files: { [node1.path]: node1.source } }) + + // see that the node is added + expect(root._children).toEqual([node1.source]) + + // create the same named file again + const node2 = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 }) + emit('files:node:created', node2) + + // see that there is still only one paths and the children are not duplicated + expect(store.paths).toEqual({ files: { [node1.path]: node1.source } }) + expect(root._children).toEqual([node1.source]) + }) + + test('Folder is deleted', () => { + const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 }) + emit('files:node:created', node) + // see that the path is added and the children are set-up + expect(store.paths).toEqual({ files: { [node.path]: node.source } }) + expect(root._children).toEqual([node.source]) + + emit('files:node:deleted', node) + // See the path is removed + expect(store.paths).toEqual({ files: {} }) + // See the child is removed + expect(root._children).toEqual([]) + }) + + test('File is deleted', () => { + const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' }) + emit('files:node:created', node) + // see that the children are set-up + expect(root._children).toEqual([node.source]) + + emit('files:node:deleted', node) + // See the child is removed + expect(root._children).toEqual([]) + }) + + test('Folder is moved', () => { + const node = new Folder({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/folder', id: 2 }) + emit('files:node:created', node) + // see that the path is added and the children are set-up + expect(store.paths).toEqual({ files: { [node.path]: node.source } }) + expect(root._children).toEqual([node.source]) + + const renamedNode = node.clone() + renamedNode.rename('new-folder') + + expect(renamedNode.path).toBe('/new-folder') + expect(renamedNode.source).toBe('http://example.com/remote.php/dav/files/test/new-folder') + + emit('files:node:moved', { node: renamedNode, oldSource: node.source }) + // See the path is updated + expect(store.paths).toEqual({ files: { [renamedNode.path]: renamedNode.source } }) + // See the child is updated + expect(root._children).toEqual([renamedNode.source]) + }) + + test('File is moved', () => { + const node = new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', id: 2, mime: 'text/plain' }) + emit('files:node:created', node) + // see that the children are set-up + expect(root._children).toEqual([node.source]) + expect(store.paths).toEqual({}) + + const renamedNode = node.clone() + renamedNode.rename('new-file.txt') + + emit('files:node:moved', { node: renamedNode, oldSource: node.source }) + // See the child is updated + expect(root._children).toEqual([renamedNode.source]) + expect(store.paths).toEqual({}) + }) +}) diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts new file mode 100644 index 00000000000..4a83cb51c83 --- /dev/null +++ b/apps/files/src/store/paths.ts @@ -0,0 +1,165 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileSource, PathsStore, PathOptions, ServicesState, Service } from '../types' +import { defineStore } from 'pinia' +import { dirname } from '@nextcloud/paths' +import { File, FileType, Folder, Node, getNavigation } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' +import Vue from 'vue' +import logger from '../logger' + +import { useFilesStore } from './files' + +export const usePathsStore = function(...args) { + const files = useFilesStore(...args) + + const store = defineStore('paths', { + state: () => ({ + paths: {} as ServicesState, + } as PathsStore), + + getters: { + getPath: (state) => { + return (service: string, path: string): FileSource|undefined => { + if (!state.paths[service]) { + return undefined + } + return state.paths[service][path] + } + }, + }, + + actions: { + addPath(payload: PathOptions) { + // If it doesn't exists, init the service state + if (!this.paths[payload.service]) { + Vue.set(this.paths, payload.service, {}) + } + + // Now we can set the provided path + Vue.set(this.paths[payload.service], payload.path, payload.source) + }, + + deletePath(service: Service, path: string) { + // skip if service does not exist + if (!this.paths[service]) { + return + } + + Vue.delete(this.paths[service], path) + }, + + onCreatedNode(node: Node) { + const service = getNavigation()?.active?.id || 'files' + if (!node.fileid) { + logger.error('Node has no fileid', { node }) + return + } + + // Only add path if it's a folder + if (node.type === FileType.Folder) { + this.addPath({ + service, + path: node.path, + source: node.source, + }) + } + + // Update parent folder children if exists + // If the folder is the root, get it and update it + this.addNodeToParentChildren(node) + }, + + onDeletedNode(node: Node) { + const service = getNavigation()?.active?.id || 'files' + + if (node.type === FileType.Folder) { + // Delete the path + this.deletePath( + service, + node.path, + ) + } + + this.deleteNodeFromParentChildren(node) + }, + + onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) { + const service = getNavigation()?.active?.id || 'files' + + // Update the path of the node + if (node.type === FileType.Folder) { + // Delete the old path if it exists + const oldPath = Object.entries(this.paths[service]).find(([, source]) => source === oldSource) + if (oldPath?.[0]) { + this.deletePath(service, oldPath[0]) + } + + // Add the new path + this.addPath({ + service, + path: node.path, + source: node.source, + }) + } + + // Dummy simple clone of the renamed node from a previous state + const oldNode = new File({ source: oldSource, owner: node.owner, mime: node.mime }) + + this.deleteNodeFromParentChildren(oldNode) + this.addNodeToParentChildren(node) + }, + + deleteNodeFromParentChildren(node: Node) { + const service = getNavigation()?.active?.id || 'files' + + // Update children of a root folder + const parentSource = dirname(node.source) + const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] } + if (folder) { + // ensure sources are unique + const children = new Set(folder._children ?? []) + children.delete(node.source) + Vue.set(folder, '_children', [...children.values()]) + logger.debug('Children updated', { parent: folder, node, children: folder._children }) + return + } + + logger.debug('Parent path does not exists, skipping children update', { node }) + }, + + addNodeToParentChildren(node: Node) { + const service = getNavigation()?.active?.id || 'files' + + // Update children of a root folder + const parentSource = dirname(node.source) + const folder = (node.dirname === '/' ? files.getRoot(service) : files.getNode(parentSource)) as Folder & { _children?: string[] } + if (folder) { + // ensure sources are unique + const children = new Set(folder._children ?? []) + children.add(node.source) + Vue.set(folder, '_children', [...children.values()]) + logger.debug('Children updated', { parent: folder, node, children: folder._children }) + return + } + + logger.debug('Parent path does not exists, skipping children update', { node }) + }, + + }, + }) + + const pathsStore = store(...args) + // Make sure we only register the listeners once + if (!pathsStore._initialized) { + subscribe('files:node:created', pathsStore.onCreatedNode) + subscribe('files:node:deleted', pathsStore.onDeletedNode) + subscribe('files:node:moved', pathsStore.onMovedNode) + + pathsStore._initialized = true + } + + return pathsStore +} diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts new file mode 100644 index 00000000000..fc61be3bd3b --- /dev/null +++ b/apps/files/src/store/renaming.ts @@ -0,0 +1,175 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node } from '@nextcloud/files' + +import axios, { isAxiosError } from '@nextcloud/axios' +import { emit, subscribe } from '@nextcloud/event-bus' +import { FileType, NodeStatus } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { spawnDialog } from '@nextcloud/vue/functions/dialog' +import { basename, dirname, extname } from 'path' +import { defineStore } from 'pinia' +import logger from '../logger' +import Vue, { defineAsyncComponent, ref } from 'vue' +import { useUserConfigStore } from './userconfig' +import { fetchNode } from '../services/WebdavClient' + +export const useRenamingStore = defineStore('renaming', () => { + /** + * The currently renamed node + */ + const renamingNode = ref<Node>() + /** + * The new name of the currently renamed node + */ + const newNodeName = ref('') + + /** + * Internal flag to only allow calling `rename` once. + */ + const isRenaming = ref(false) + + /** + * Execute the renaming. + * This will rename the node set as `renamingNode` to the configured new name `newName`. + * + * @return true if success, false if skipped (e.g. new and old name are the same) + * @throws Error if renaming fails, details are set in the error message + */ + async function rename(): Promise<boolean> { + if (renamingNode.value === undefined) { + throw new Error('No node is currently being renamed') + } + + // Only rename once so we use this as some kind of mutex + if (isRenaming.value) { + return false + } + isRenaming.value = true + + let node = renamingNode.value + Vue.set(node, 'status', NodeStatus.LOADING) + + const userConfig = useUserConfigStore() + + let newName = newNodeName.value.trim() + const oldName = node.basename + const oldExtension = extname(oldName) + const newExtension = extname(newName) + // Check for extension change for files + if (node.type === FileType.File + && oldExtension !== newExtension + && userConfig.userConfig.show_dialog_file_extension + && !(await showFileExtensionDialog(oldExtension, newExtension)) + ) { + // user selected to use the old extension + newName = basename(newName, newExtension) + oldExtension + } + + const oldEncodedSource = node.encodedSource + try { + if (oldName === newName) { + return false + } + + // rename the node + node.rename(newName) + logger.debug('Moving file to', { destination: node.encodedSource, oldEncodedSource }) + // create MOVE request + await axios({ + method: 'MOVE', + url: oldEncodedSource, + headers: { + Destination: node.encodedSource, + Overwrite: 'F', + }, + }) + + // Update mime type if extension changed + // as other related informations might have changed + // on the backend but it is really hard to know on the front + if (oldExtension !== newExtension) { + node = await fetchNode(node.path) + } + + // Success 🎉 + emit('files:node:updated', node) + emit('files:node:renamed', node) + emit('files:node:moved', { + node, + oldSource: `${dirname(node.source)}/${oldName}`, + }) + + // Reset the state not changed + if (renamingNode.value === node) { + $reset() + } + + return true + } catch (error) { + logger.error('Error while renaming file', { error }) + // Rename back as it failed + node.rename(oldName) + if (isAxiosError(error)) { + // TODO: 409 means current folder does not exist, redirect ? + if (error?.response?.status === 404) { + throw new Error(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) + } else if (error?.response?.status === 412) { + throw new Error(t( + 'files', + 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', + { + newName, + dir: basename(renamingNode.value!.dirname), + }, + )) + } + } + // Unknown error + throw new Error(t('files', 'Could not rename "{oldName}"', { oldName })) + } finally { + Vue.set(node, 'status', undefined) + isRenaming.value = false + } + } + + /** + * Reset the store state + */ + function $reset(): void { + newNodeName.value = '' + renamingNode.value = undefined + } + + // Make sure we only register the listeners once + subscribe('files:node:rename', (node: Node) => { + renamingNode.value = node + newNodeName.value = node.basename + }) + + return { + $reset, + + newNodeName, + rename, + renamingNode, + } +}) + +/** + * Show a dialog asking user for confirmation about changing the file extension. + * + * @param oldExtension the old file name extension + * @param newExtension the new file name extension + */ +async function showFileExtensionDialog(oldExtension: string, newExtension: string): Promise<boolean> { + const { promise, resolve } = Promise.withResolvers<boolean>() + spawnDialog( + defineAsyncComponent(() => import('../views/DialogConfirmFileExtension.vue')), + { oldExtension, newExtension }, + (useNewExtension: unknown) => resolve(Boolean(useNewExtension)), + ) + return await promise +} diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts new file mode 100644 index 00000000000..43e01f35b92 --- /dev/null +++ b/apps/files/src/store/search.ts @@ -0,0 +1,153 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { View } from '@nextcloud/files' +import type RouterService from '../services/RouterService.ts' +import type { SearchScope } from '../types.ts' + +import { emit, subscribe } from '@nextcloud/event-bus' +import debounce from 'debounce' +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import { VIEW_ID } from '../views/search.ts' +import logger from '../logger.ts' + +export const useSearchStore = defineStore('search', () => { + /** + * The current search query + */ + const query = ref('') + + /** + * Scope of the search. + * Scopes: + * - filter: only filter current file list + * - globally: search everywhere + */ + const scope = ref<SearchScope>('filter') + + // reset the base if query is cleared + watch(scope, updateSearch) + + watch(query, (old, current) => { + // skip if only whitespaces changed + if (old.trim() === current.trim()) { + return + } + + updateSearch() + }) + + // initialize the search store + initialize() + + /** + * Debounced update of the current route + * @private + */ + const updateRouter = debounce((isSearch: boolean) => { + const router = window.OCP.Files.Router as RouterService + router.goToRoute( + undefined, + { + view: VIEW_ID, + }, + { + query: query.value, + }, + isSearch, + ) + }) + + /** + * Handle updating the filter if needed. + * Also update the search view by updating the current route if needed. + * + * @private + */ + function updateSearch() { + // emit the search event to update the filter + emit('files:search:updated', { query: query.value, scope: scope.value }) + const router = window.OCP.Files.Router as RouterService + + // if we are on the search view and the query was unset or scope was set to 'filter' we need to move back to the files view + if (router.params.view === VIEW_ID && (query.value === '' || scope.value === 'filter')) { + scope.value = 'filter' + return router.goToRoute( + undefined, + { + view: 'files', + }, + { + ...router.query, + query: undefined, + }, + ) + } + + // for the filter scope we do not need to adjust the current route anymore + // also if the query is empty we do not need to do anything + if (scope.value === 'filter' || !query.value) { + return + } + + const isSearch = router.params.view === VIEW_ID + + logger.debug('Update route for updated search query', { query: query.value, isSearch }) + updateRouter(isSearch) + } + + /** + * Event handler that resets the store if the file list view was changed. + * + * @param view - The new view that is active + * @private + */ + function onViewChanged(view: View) { + if (view.id !== VIEW_ID) { + query.value = '' + scope.value = 'filter' + } + } + + /** + * Initialize the store from the router if needed + */ + function initialize() { + subscribe('files:navigation:changed', onViewChanged) + + const router = window.OCP.Files.Router as RouterService + // if we initially load the search view (e.g. hard page refresh) + // then we need to initialize the store from the router + if (router.params.view === VIEW_ID) { + query.value = [router.query.query].flat()[0] ?? '' + + if (query.value) { + scope.value = 'globally' + logger.debug('Directly navigated to search view', { query: query.value }) + } else { + // we do not have any query so we need to move to the files list + logger.info('Directly navigated to search view without any query, redirect to files view.') + router.goToRoute( + undefined, + { + ...router.params, + view: 'files', + }, + { + ...router.query, + query: undefined, + }, + true, + ) + } + } + } + + return { + query, + scope, + } +}) diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts new file mode 100644 index 00000000000..fa35d953406 --- /dev/null +++ b/apps/files/src/store/selection.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileSource, SelectionStore } from '../types' +import { defineStore } from 'pinia' +import Vue from 'vue' + +export const useSelectionStore = defineStore('selection', { + state: () => ({ + selected: [], + lastSelection: [], + lastSelectedIndex: null, + } as SelectionStore), + + actions: { + /** + * Set the selection of fileIds + * @param selection + */ + set(selection = [] as FileSource[]) { + Vue.set(this, 'selected', [...new Set(selection)]) + }, + + /** + * Set the last selected index + * @param lastSelectedIndex + */ + setLastIndex(lastSelectedIndex = null as number | null) { + // Update the last selection if we provided a new selection starting point + Vue.set(this, 'lastSelection', lastSelectedIndex ? this.selected : []) + Vue.set(this, 'lastSelectedIndex', lastSelectedIndex) + }, + + /** + * Reset the selection + */ + reset() { + Vue.set(this, 'selected', []) + Vue.set(this, 'lastSelection', []) + Vue.set(this, 'lastSelectedIndex', null) + }, + }, +}) diff --git a/apps/files/src/store/uploader.ts b/apps/files/src/store/uploader.ts new file mode 100644 index 00000000000..12c0f77cbf2 --- /dev/null +++ b/apps/files/src/store/uploader.ts @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Uploader } from '@nextcloud/upload' +import type { UploaderStore } from '../types' + +import { defineStore } from 'pinia' +import { getUploader } from '@nextcloud/upload' + +let uploader: Uploader + +export const useUploaderStore = function(...args) { + // Only init on runtime + uploader = getUploader() + + const store = defineStore('uploader', { + state: () => ({ + queue: uploader.queue, + } as UploaderStore), + }) + + return store(...args) +} diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts new file mode 100644 index 00000000000..48fe01d5134 --- /dev/null +++ b/apps/files/src/store/userconfig.ts @@ -0,0 +1,62 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { UserConfig } from '../types' +import { getCurrentUser } from '@nextcloud/auth' +import { emit, subscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' +import { ref, set } from 'vue' +import axios from '@nextcloud/axios' + +const initialUserConfig = loadState<UserConfig>('files', 'config', { + crop_image_previews: true, + default_view: 'files', + grid_view: false, + show_files_extensions: true, + show_hidden: false, + show_mime_column: true, + sort_favorites_first: true, + sort_folders_first: true, + + show_dialog_deletion: false, + show_dialog_file_extension: true, +}) + +export const useUserConfigStore = defineStore('userconfig', () => { + const userConfig = ref<UserConfig>({ ...initialUserConfig }) + + /** + * Update the user config local store + * @param key The config key + * @param value The new value + */ + function onUpdate(key: string, value: boolean): void { + set(userConfig.value, key, value) + } + + /** + * Update the user config local store AND on server side + * @param key The config key + * @param value The new value + */ + async function update(key: string, value: boolean): Promise<void> { + // only update if a user is logged in (not the case for public shares) + if (getCurrentUser() !== null) { + await axios.put(generateUrl('/apps/files/api/v1/config/{key}', { key }), { + value, + }) + } + emit('files:config:updated', { key, value }) + } + + // Register the event listener + subscribe('files:config:updated', ({ key, value }) => onUpdate(key, value)) + + return { + userConfig, + update, + } +}) diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts new file mode 100644 index 00000000000..a902cedb6fa --- /dev/null +++ b/apps/files/src/store/viewConfig.ts @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ViewConfigs, ViewId, ViewConfig } from '../types' + +import { getCurrentUser } from '@nextcloud/auth' +import { emit, subscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' +import { ref, set } from 'vue' +import axios from '@nextcloud/axios' + +const initialViewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs + +export const useViewConfigStore = defineStore('viewconfig', () => { + + const viewConfigs = ref({ ...initialViewConfig }) + + /** + * Get the config for a specific view + * @param viewid Id of the view to fet the config for + */ + function getConfig(viewid: ViewId): ViewConfig { + return viewConfigs.value[viewid] || {} + } + + /** + * Update the view config local store + * @param viewId The id of the view to update + * @param key The config key to update + * @param value The new value + */ + function onUpdate(viewId: ViewId, key: string, value: string | number | boolean): void { + if (!(viewId in viewConfigs.value)) { + set(viewConfigs.value, viewId, {}) + } + set(viewConfigs.value[viewId], key, value) + } + + /** + * Update the view config local store AND on server side + * @param view Id of the view to update + * @param key Config key to update + * @param value New value + */ + async function update(view: ViewId, key: string, value: string | number | boolean): Promise<void> { + if (getCurrentUser() !== null) { + await axios.put(generateUrl('/apps/files/api/v1/views'), { + value, + view, + key, + }) + } + + emit('files:view-config:updated', { view, key, value }) + } + + /** + * Set the sorting key AND sort by ASC + * The key param must be a valid key of a File object + * If not found, will be searched within the File attributes + * @param key Key to sort by + * @param view View to set the sorting key for + */ + function setSortingBy(key = 'basename', view = 'files'): void { + // Save new config + update(view, 'sorting_mode', key) + update(view, 'sorting_direction', 'asc') + } + + /** + * Toggle the sorting direction + * @param viewId id of the view to set the sorting order for + */ + function toggleSortingDirection(viewId = 'files'): void { + const config = viewConfigs.value[viewId] || { sorting_direction: 'asc' } + const newDirection = config.sorting_direction === 'asc' ? 'desc' : 'asc' + + // Save new config + update(viewId, 'sorting_direction', newDirection) + } + + // Initialize event listener + subscribe('files:view-config:updated', ({ view, key, value }) => onUpdate(view, key, value)) + + return { + viewConfigs, + + getConfig, + setSortingBy, + toggleSortingDirection, + update, + } +}) diff --git a/apps/files/src/templates.js b/apps/files/src/templates.js deleted file mode 100644 index 7f7ebbf2dcc..00000000000 --- a/apps/files/src/templates.js +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { getLoggerBuilder } from '@nextcloud/logger' -import { loadState } from '@nextcloud/initial-state' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import { generateOcsUrl } from '@nextcloud/router' -import { getCurrentDirectory } from './utils/davUtils' -import axios from '@nextcloud/axios' -import Vue from 'vue' - -import TemplatePickerView from './views/TemplatePicker' -import { showError } from '@nextcloud/dialogs' - -// Set up logger -const logger = getLoggerBuilder() - .setApp('files') - .detectUser() - .build() - -// Add translates functions -Vue.mixin({ - methods: { - t, - n, - }, -}) - -// Create document root -const TemplatePickerRoot = document.createElement('div') -TemplatePickerRoot.id = 'template-picker' -document.body.appendChild(TemplatePickerRoot) - -// Retrieve and init templates -let templates = loadState('files', 'templates', []) -let templatesPath = loadState('files', 'templates_path', false) -logger.debug('Templates providers', templates) -logger.debug('Templates folder', { templatesPath }) - -// Init vue app -const View = Vue.extend(TemplatePickerView) -const TemplatePicker = new View({ - name: 'TemplatePicker', - propsData: { - logger, - }, -}) -TemplatePicker.$mount('#template-picker') - -// Init template engine after load to make sure it's the last injected entry -window.addEventListener('DOMContentLoaded', function() { - if (!templatesPath) { - logger.debug('Templates folder not initialized') - const initTemplatesPlugin = { - attach(menu) { - // register the new menu entry - menu.addMenuEntry({ - id: 'template-init', - displayName: t('files', 'Set up templates folder'), - templateName: t('files', 'Templates'), - iconClass: 'icon-template-add', - fileType: 'file', - actionHandler(name) { - initTemplatesFolder(name) - menu.removeMenuEntry('template-init') - }, - }) - }, - } - OC.Plugins.register('OCA.Files.NewFileMenu', initTemplatesPlugin) - } -}) - -// Init template files menu -templates.forEach((provider, index) => { - const newTemplatePlugin = { - attach(menu) { - const fileList = menu.fileList - - // only attach to main file list, public view is not supported yet - if (fileList.id !== 'files' && fileList.id !== 'files.public') { - return - } - - // register the new menu entry - menu.addMenuEntry({ - id: `template-new-${provider.app}-${index}`, - displayName: provider.label, - templateName: provider.label + provider.extension, - iconClass: provider.iconClass || 'icon-file', - fileType: 'file', - actionHandler(name) { - TemplatePicker.open(name, provider) - }, - }) - }, - } - OC.Plugins.register('OCA.Files.NewFileMenu', newTemplatePlugin) -}) - -/** - * Init the template directory - * - * @param {string} name the templates folder name - */ -const initTemplatesFolder = async function(name) { - const templatePath = (getCurrentDirectory() + `/${name}`).replace('//', '/') - try { - logger.debug('Initializing the templates directory', { templatePath }) - const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), { - templatePath, - copySystemTemplates: true, - }) - - // Go to template directory - OCA.Files.App.currentFileList.changeDirectory(templatePath, true, true) - - templates = response.data.ocs.data.templates - templatesPath = response.data.ocs.data.template_path - } catch (error) { - logger.error('Unable to initialize the templates directory') - showError(t('files', 'Unable to initialize the templates directory')) - } -} diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts new file mode 100644 index 00000000000..0096ecc0fdb --- /dev/null +++ b/apps/files/src/types.ts @@ -0,0 +1,148 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileAction, Folder, Node, View } from '@nextcloud/files' +import type { Upload } from '@nextcloud/upload' + +// Global definitions +export type Service = string +export type FileSource = string +export type ViewId = string + +// Files store +export type FilesStore = { + [source: FileSource]: Node +} + +export type RootsStore = { + [service: Service]: Folder +} + +export type FilesState = { + files: FilesStore, + roots: RootsStore, +} + +export interface RootOptions { + root: Folder + service: Service +} + +// Paths store +export type PathConfig = { + [path: string]: FileSource +} + +export type ServicesState = { + [service: Service]: PathConfig +} + +export type PathsStore = { + paths: ServicesState +} + +export interface PathOptions { + service: Service + path: string + source: FileSource +} + +// User config store +export interface UserConfig { + [key: string]: boolean | string | undefined + + crop_image_previews: boolean + default_view: 'files' | 'personal' + grid_view: boolean + show_files_extensions: boolean + show_hidden: boolean + show_mime_column: boolean + sort_favorites_first: boolean + sort_folders_first: boolean + + show_dialog_deletion: boolean + show_dialog_file_extension: boolean, +} + +export interface UserConfigStore { + userConfig: UserConfig +} + +export interface SelectionStore { + selected: FileSource[] + lastSelection: FileSource[] + lastSelectedIndex: number | null +} + +// Actions menu store +export type GlobalActions = 'global' +export interface ActionsMenuStore { + opened: GlobalActions|string|null +} + +// View config store +export interface ViewConfig { + [key: string]: string|boolean +} +export interface ViewConfigs { + [viewId: ViewId]: ViewConfig +} +export interface ViewConfigStore { + viewConfig: ViewConfigs +} + +// Renaming store +export interface RenamingStore { + renamingNode?: Node + newName: string +} + +// Uploader store +export interface UploaderStore { + queue: Upload[] +} + +// Drag and drop store +export interface DragAndDropStore { + dragging: FileSource[] +} + +// Active node store +export interface ActiveStore { + activeAction: FileAction|null + activeFolder: Folder|null + activeNode: Node|null + activeView: View|null +} + +/** + * Search scope for the in-files-search + */ +export type SearchScope = 'filter'|'globally' + +export interface TemplateFile { + app: string + label: string + extension: string + iconClass?: string + iconSvgInline?: string + mimetypes: string[] + ratio?: number + templates?: Record<string, unknown>[] +} + +export type Capabilities = { + files: { + bigfilechunking: boolean + blacklisted_files: string[] + forbidden_filename_basenames: string[] + forbidden_filename_characters: string[] + forbidden_filename_extensions: string[] + forbidden_filenames: string[] + undelete: boolean + version_deletion: boolean + version_labeling: boolean + versioning: boolean + } +} diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts new file mode 100644 index 00000000000..adacf621b4c --- /dev/null +++ b/apps/files/src/utils/actionUtils.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileAction } from '@nextcloud/files' + +import { NodeStatus } from '@nextcloud/files' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import Vue from 'vue' + +import { getPinia } from '../store' +import { useActiveStore } from '../store/active' +import logger from '../logger' + +/** + * Execute an action on the current active node + * + * @param action The action to execute + */ +export const executeAction = async (action: FileAction) => { + const activeStore = useActiveStore(getPinia()) + const currentDir = (window?.OCP?.Files?.Router?.query?.dir || '/') as string + const currentNode = activeStore.activeNode + const currentView = activeStore.activeView + + if (!currentNode || !currentView) { + logger.error('No active node or view', { node: currentNode, view: currentView }) + return + } + + if (currentNode.status === NodeStatus.LOADING) { + logger.debug('Node is already loading', { node: currentNode }) + return + } + + if (!action.enabled!([currentNode], currentView)) { + logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView }) + return + } + + let displayName = action.id + try { + displayName = action.displayName([currentNode], currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + + try { + // Set the loading marker + Vue.set(currentNode, 'status', NodeStatus.LOADING) + activeStore.activeAction = action + + const success = await action.exec(currentNode, currentView, currentDir) + + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + // Reset the loading marker + Vue.set(currentNode, 'status', undefined) + activeStore.activeAction = undefined + } +} diff --git a/apps/files/src/utils/davUtils.js b/apps/files/src/utils/davUtils.js deleted file mode 100644 index 1bd63347518..00000000000 --- a/apps/files/src/utils/davUtils.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' - -export const getRootPath = function() { - if (getCurrentUser()) { - return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) - } else { - return generateRemoteUrl('webdav').replace('/remote.php', '/public.php') - } -} - -export const isPublic = function() { - return !getCurrentUser() -} - -export const getToken = function() { - return document.getElementById('sharingToken') && document.getElementById('sharingToken').value -} - -/** - * Return the current directory, fallback to root - * - * @return {string} - */ -export const getCurrentDirectory = function() { - const currentDirInfo = OCA?.Files?.App?.currentFileList?.dirInfo - || { path: '/', name: '' } - - // Make sure we don't have double slashes - return `${currentDirInfo.path}/${currentDirInfo.name}`.replace(/\/\//gi, '/') -} diff --git a/apps/files/src/utils/davUtils.ts b/apps/files/src/utils/davUtils.ts new file mode 100644 index 00000000000..54c1a6ea966 --- /dev/null +++ b/apps/files/src/utils/davUtils.ts @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' +import type { WebDAVClientError } from 'webdav' + +/** + * Whether error is a WebDAVClientError + * @param error - Any exception + * @return {boolean} - Whether error is a WebDAVClientError + */ +function isWebDAVClientError(error: unknown): error is WebDAVClientError { + return error instanceof Error && 'status' in error && 'response' in error +} + +/** + * Get a localized error message from webdav request + * @param error - An exception from webdav request + * @return {string} Localized error message for end user + */ +export function humanizeWebDAVError(error: unknown) { + if (error instanceof Error) { + if (isWebDAVClientError(error)) { + const status = error.status || error.response?.status || 0 + if ([400, 404, 405].includes(status)) { + return t('files', 'Folder not found') + } else if (status === 403) { + return t('files', 'This operation is forbidden') + } else if (status === 500) { + return t('files', 'This folder is unavailable, please try again later or contact the administration') + } else if (status === 503) { + return t('files', 'Storage is temporarily not available') + } + } + return t('files', 'Unexpected error: {error}', { error: error.message }) + } + + return t('files', 'Unknown error') +} diff --git a/apps/files/src/utils/dragUtils.ts b/apps/files/src/utils/dragUtils.ts new file mode 100644 index 00000000000..0722e313089 --- /dev/null +++ b/apps/files/src/utils/dragUtils.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node } from '@nextcloud/files' +import DragAndDropPreview from '../components/DragAndDropPreview.vue' +import Vue from 'vue' + +const Preview = Vue.extend(DragAndDropPreview) +let preview: Vue + +export const getDragAndDropPreview = async (nodes: Node[]): Promise<Element> => { + return new Promise((resolve) => { + if (!preview) { + preview = new Preview().$mount() + document.body.appendChild(preview.$el) + } + + preview.update(nodes) + preview.$on('loaded', () => { + resolve(preview.$el) + preview.$off('loaded') + }) + }) +} diff --git a/apps/files/src/utils/fileUtils.js b/apps/files/src/utils/fileUtils.js deleted file mode 100644 index 5ab88c6eb63..00000000000 --- a/apps/files/src/utils/fileUtils.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -const encodeFilePath = function(path) { - const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') - let relativePath = '' - pathSections.forEach((section) => { - if (section !== '') { - relativePath += '/' + encodeURIComponent(section) - } - }) - return relativePath -} - -/** - * Extract dir and name from file path - * - * @param {string} path the full path - * @return {string[]} [dirPath, fileName] - */ -const extractFilePaths = function(path) { - const pathSections = path.split('/') - const fileName = pathSections[pathSections.length - 1] - const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') - return [dirPath, fileName] -} - -export { encodeFilePath, extractFilePaths } diff --git a/apps/files/src/utils/fileUtils.ts b/apps/files/src/utils/fileUtils.ts new file mode 100644 index 00000000000..f0b974be21d --- /dev/null +++ b/apps/files/src/utils/fileUtils.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { FileType, type Node } from '@nextcloud/files' +import { n } from '@nextcloud/l10n' + +/** + * Extract dir and name from file path + * + * @param path - The full path + * @return [dirPath, fileName] + */ +export function extractFilePaths(path: string): [string, string] { + const pathSections = path.split('/') + const fileName = pathSections[pathSections.length - 1] + const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') + return [dirPath, fileName] +} + +/** + * Generate a translated summary of an array of nodes + * + * @param nodes - The nodes to summarize + * @param hidden - The number of hidden nodes + */ +export function getSummaryFor(nodes: Node[], hidden = 0): string { + const fileCount = nodes.filter(node => node.type === FileType.File).length + const folderCount = nodes.filter(node => node.type === FileType.Folder).length + + const summary: string[] = [] + if (fileCount > 0 || folderCount === 0) { + const fileSummary = n('files', '%n file', '%n files', fileCount) + summary.push(fileSummary) + } + if (folderCount > 0) { + const folderSummary = n('files', '%n folder', '%n folders', folderCount) + summary.push(folderSummary) + } + if (hidden > 0) { + // TRANSLATORS: This is the number of hidden files or folders + const hiddenSummary = n('files', '%n hidden', '%n hidden', hidden) + summary.push(hiddenSummary) + } + + return summary.join(' · ') +} diff --git a/apps/files/src/utils/filenameValidity.ts b/apps/files/src/utils/filenameValidity.ts new file mode 100644 index 00000000000..2666d530052 --- /dev/null +++ b/apps/files/src/utils/filenameValidity.ts @@ -0,0 +1,41 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +/** + * Get the validity of a filename (empty if valid). + * This can be used for `setCustomValidity` on input elements + * @param name The filename + * @param escape Escape the matched string in the error (only set when used in HTML) + */ +export function getFilenameValidity(name: string, escape = false): string { + if (name.trim() === '') { + return t('files', 'Filename must not be empty.') + } + + try { + validateFilename(name) + return '' + } catch (error) { + if (!(error instanceof InvalidFilenameError)) { + throw error + } + + switch (error.reason) { + case InvalidFilenameErrorReason.Character: + return t('files', '"{char}" is not allowed inside a filename.', { char: error.segment }, undefined, { escape }) + case InvalidFilenameErrorReason.ReservedName: + return t('files', '"{segment}" is a reserved name and not allowed for filenames.', { segment: error.segment }, undefined, { escape: false }) + case InvalidFilenameErrorReason.Extension: + if (error.segment.match(/\.[a-z]/i)) { + return t('files', '"{extension}" is not an allowed filetype.', { extension: error.segment }, undefined, { escape: false }) + } + return t('files', 'Filenames must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false }) + default: + return t('files', 'Invalid filename.') + } + } +} diff --git a/apps/files/src/utils/filesViews.spec.ts b/apps/files/src/utils/filesViews.spec.ts new file mode 100644 index 00000000000..03b0bb9aeb0 --- /dev/null +++ b/apps/files/src/utils/filesViews.spec.ts @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, test } from 'vitest' +import { defaultView, hasPersonalFilesView } from './filesViews.ts' + +describe('hasPersonalFilesView', () => { + beforeEach(() => removeInitialState()) + + test('enabled if user has unlimited quota', () => { + mockInitialState('files', 'storageStats', { quota: -1 }) + expect(hasPersonalFilesView()).toBe(true) + }) + + test('enabled if user has limited quota', () => { + mockInitialState('files', 'storageStats', { quota: 1234 }) + expect(hasPersonalFilesView()).toBe(true) + }) + + test('disabled if user has no quota', () => { + mockInitialState('files', 'storageStats', { quota: 0 }) + expect(hasPersonalFilesView()).toBe(false) + }) +}) + +describe('defaultView', () => { + beforeEach(removeInitialState) + + test('Returns files view if set', () => { + mockInitialState('files', 'config', { default_view: 'files' }) + expect(defaultView()).toBe('files') + }) + + test('Returns personal view if set and enabled', () => { + mockInitialState('files', 'config', { default_view: 'personal' }) + mockInitialState('files', 'storageStats', { quota: -1 }) + expect(defaultView()).toBe('personal') + }) + + test('Falls back to files if personal view is disabled', () => { + mockInitialState('files', 'config', { default_view: 'personal' }) + mockInitialState('files', 'storageStats', { quota: 0 }) + expect(defaultView()).toBe('files') + }) +}) + +/** + * Remove the mocked initial state + */ +function removeInitialState(): void { + document.querySelectorAll('input[type="hidden"]').forEach((el) => { + el.remove() + }) + // clear the cache + delete globalThis._nc_initial_state +} + +/** + * Helper to mock an initial state value + * @param app - The app + * @param key - The key + * @param value - The value + */ +function mockInitialState(app: string, key: string, value: unknown): void { + const el = document.createElement('input') + el.value = btoa(JSON.stringify(value)) + el.id = `initial-state-${app}-${key}` + el.type = 'hidden' + + document.head.appendChild(el) +} diff --git a/apps/files/src/utils/filesViews.ts b/apps/files/src/utils/filesViews.ts new file mode 100644 index 00000000000..9489c35cbde --- /dev/null +++ b/apps/files/src/utils/filesViews.ts @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { UserConfig } from '../types.ts' + +import { loadState } from '@nextcloud/initial-state' + +/** + * Check whether the personal files view can be shown + */ +export function hasPersonalFilesView(): boolean { + const storageStats = loadState('files', 'storageStats', { quota: -1 }) + // Don't show this view if the user has no storage quota + return storageStats.quota !== 0 +} + +/** + * Get the default files view + */ +export function defaultView() { + const { default_view: defaultView } = loadState<Partial<UserConfig>>('files', 'config', { default_view: 'files' }) + + // the default view - only use the personal one if it is enabled + if (defaultView !== 'personal' || hasPersonalFilesView()) { + return defaultView + } + return 'files' +} diff --git a/apps/files/src/utils/hashUtils.ts b/apps/files/src/utils/hashUtils.ts new file mode 100644 index 00000000000..2e1fadff067 --- /dev/null +++ b/apps/files/src/utils/hashUtils.ts @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Simple non-secure hashing function similar to Java's `hashCode` + * @param str The string to hash + * @return {number} a non secure hash of the string + */ +export const hashCode = function(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 + } + return (hash >>> 0) +} diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts new file mode 100644 index 00000000000..a81fa9f4e17 --- /dev/null +++ b/apps/files/src/utils/newNodeDialog.ts @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' +import { spawnDialog } from '@nextcloud/dialogs' +import NewNodeDialog from '../components/NewNodeDialog.vue' + +interface ILabels { + /** + * Dialog heading, defaults to "New folder name" + */ + name?: string + /** + * Label for input box, defaults to "New folder" + */ + label?: string +} + +/** + * Ask user for file or folder name + * @param defaultName Default name to use + * @param folderContent Nodes with in the current folder to check for unique name + * @param labels Labels to set on the dialog + * @return string if successful otherwise null if aborted + */ +export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) { + const contentNames = folderContent.map((node: Node) => node.basename) + + return new Promise<string|null>((resolve) => { + spawnDialog(NewNodeDialog, { + ...labels, + defaultName, + otherNames: contentNames, + }, (folderName) => { + resolve(folderName as string|null) + }) + }) +} diff --git a/apps/files/src/utils/permissions.ts b/apps/files/src/utils/permissions.ts new file mode 100644 index 00000000000..9b4c42bf49c --- /dev/null +++ b/apps/files/src/utils/permissions.ts @@ -0,0 +1,37 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node } from '@nextcloud/files' +import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts' + +import { Permission } from '@nextcloud/files' + +/** + * Check permissions on the node if it can be downloaded + * @param node The node to check + * @return True if downloadable, false otherwise + */ +export function isDownloadable(node: Node): boolean { + if ((node.permissions & Permission.READ) === 0) { + return false + } + + // check hide-download property of shares + if (node.attributes['hide-download'] === true + || node.attributes['hide-download'] === 'true' + ) { + return false + } + + // If the mount type is a share, ensure it got download permissions. + if (node.attributes['share-attributes']) { + const shareAttributes = JSON.parse(node.attributes['share-attributes'] || '[]') as Array<ShareAttribute> + const downloadAttribute = shareAttributes.find(({ scope, key }: ShareAttribute) => scope === 'permissions' && key === 'download') + if (downloadAttribute !== undefined) { + return downloadAttribute.value === true + } + } + + return true +} diff --git a/apps/files/src/views/DialogConfirmFileExtension.cy.ts b/apps/files/src/views/DialogConfirmFileExtension.cy.ts new file mode 100644 index 00000000000..460497dd91f --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.cy.ts @@ -0,0 +1,161 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createTestingPinia } from '@pinia/testing' +import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue' +import { useUserConfigStore } from '../store/userconfig' + +describe('DialogConfirmFileExtension', () => { + it('renders with both extensions', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('heading') + .should('contain.text', 'Change file extension') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .should('exist') + .and('not.be.checked') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without old extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep without extension' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without new extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Remove extension' }) + .should('be.visible') + }) + + it('emits correct value on keep old', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[false]])) + }) + + it('emits correct value on use new', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[true]])) + }) + + it('updates user config when checking the checkbox', () => { + const pinia = createTestingPinia({ + createSpy: cy.spy, + }) + + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [pinia], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .check({ force: true }) + + cy.wrap(useUserConfigStore()) + .its('update') + .should('have.been.calledWith', 'show_dialog_file_extension', false) + }) +}) diff --git a/apps/files/src/views/DialogConfirmFileExtension.vue b/apps/files/src/views/DialogConfirmFileExtension.vue new file mode 100644 index 00000000000..cc1ee363f98 --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.vue @@ -0,0 +1,92 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { IDialogButton } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { computed, ref } from 'vue' +import { useUserConfigStore } from '../store/userconfig.ts' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import svgIconCancel from '@mdi/svg/svg/cancel.svg?raw' +import svgIconCheck from '@mdi/svg/svg/check.svg?raw' + +const props = defineProps<{ + oldExtension?: string + newExtension?: string +}>() + +const emit = defineEmits<{ + (e: 'close', v: boolean): void +}>() + +const userConfigStore = useUserConfigStore() +const dontShowAgain = computed({ + get: () => !userConfigStore.userConfig.show_dialog_file_extension, + set: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value), +}) + +const buttons = computed<IDialogButton[]>(() => [ + { + label: props.oldExtension + ? t('files', 'Keep {old}', { old: props.oldExtension }) + : t('files', 'Keep without extension'), + icon: svgIconCancel, + type: 'secondary', + callback: () => closeDialog(false), + }, + { + label: props.newExtension + ? t('files', 'Use {new}', { new: props.newExtension }) + : t('files', 'Remove extension'), + icon: svgIconCheck, + type: 'primary', + callback: () => closeDialog(true), + }, +]) + +/** Open state of the dialog */ +const open = ref(true) + +/** + * Close the dialog and emit the response + * @param value User selected response + */ +function closeDialog(value: boolean) { + emit('close', value) + open.value = false +} +</script> + +<template> + <NcDialog :buttons="buttons" + :open="open" + :can-close="false" + :name="t('files', 'Change file extension')" + size="small"> + <p v-if="newExtension && oldExtension"> + {{ t('files', 'Changing the file extension from "{old}" to "{new}" may render the file unreadable.', { old: oldExtension, new: newExtension }) }} + </p> + <p v-else-if="oldExtension"> + {{ t('files', 'Removing the file extension "{old}" may render the file unreadable.', { old: oldExtension }) }} + </p> + <p v-else-if="newExtension"> + {{ t('files', 'Adding the file extension "{new}" may render the file unreadable.', { new: newExtension }) }} + </p> + + <NcCheckboxRadioSwitch v-model="dontShowAgain" + class="dialog-confirm-file-extension__checkbox" + type="checkbox"> + {{ t('files', 'Do not show this dialog again.') }} + </NcCheckboxRadioSwitch> + </NcDialog> +</template> + +<style scoped> +.dialog-confirm-file-extension__checkbox { + margin-top: 1rem; +} +</style> diff --git a/apps/files/src/views/FileReferencePickerElement.vue b/apps/files/src/views/FileReferencePickerElement.vue new file mode 100644 index 00000000000..b4d4bc54f14 --- /dev/null +++ b/apps/files/src/views/FileReferencePickerElement.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div :id="containerId"> + <FilePicker v-bind="filepickerOptions" @close="onClose" /> + </div> +</template> + +<script lang="ts"> +import type { Node as NcNode } from '@nextcloud/files' +import type { IFilePickerButton } from '@nextcloud/dialogs' + +import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + +export default defineComponent({ + name: 'FileReferencePickerElement', + components: { + FilePicker, + }, + props: { + providerId: { + type: String, + required: true, + }, + accessible: { + type: Boolean, + default: false, + }, + }, + computed: { + containerId() { + return `filepicker-${Math.random().toString(36).slice(7)}` + }, + filepickerOptions() { + return { + allowPickDirectory: true, + buttons: this.buttonFactory, + container: `#${this.containerId}`, + multiselect: false, + name: t('files', 'Select file or folder to link to'), + } + }, + }, + methods: { + t, + + buttonFactory(selected: NcNode[]): IFilePickerButton[] { + const buttons = [] as IFilePickerButton[] + if (selected.length === 0) { + return [] + } + const node = selected.at(0) + if (node.path === '/') { + return [] // Do not allow selecting the users root folder + } + buttons.push({ + label: t('files', 'Choose {file}', { file: node.displayname }), + type: 'primary', + callback: this.onClose, + }) + return buttons + }, + + onClose(nodes?: NcNode[]) { + if (nodes === undefined || nodes.length === 0) { + this.$emit('cancel') + } else { + this.onSubmit(nodes[0]) + } + }, + + onSubmit(node: NcNode) { + const url = new URL(window.location.href) + url.pathname = generateUrl('/f/{fileId}', { fileId: node.fileid! }) + url.search = '' + this.$emit('submit', url.href) + }, + }, +}) +</script> diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue new file mode 100644 index 00000000000..f9e517e92ee --- /dev/null +++ b/apps/files/src/views/FilesList.vue @@ -0,0 +1,909 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcAppContent :page-heading="pageHeading" data-cy-files-content> + <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }"> + <!-- Current folder breadcrumbs --> + <BreadCrumbs :path="directory" @reload="fetchContent"> + <template #actions> + <!-- Sharing button --> + <NcButton v-if="canShare && fileListWidth >= 512" + :aria-label="shareButtonLabel" + :class="{ 'files-list__header-share-button--shared': shareButtonType }" + :title="shareButtonLabel" + class="files-list__header-share-button" + type="tertiary" + @click="openSharingSidebar"> + <template #icon> + <LinkIcon v-if="shareButtonType === ShareType.Link" /> + <AccountPlusIcon v-else :size="20" /> + </template> + </NcButton> + + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + </template> + </BreadCrumbs> + + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + + <NcActions class="files-list__header-actions" + :inline="1" + type="tertiary" + force-name> + <NcActionButton v-for="action in enabledFileListActions" + :key="action.id" + :disabled="!!loadingAction" + :data-cy-files-list-action="action.id" + close-after-click + @click="execFileListAction(action)"> + <template #icon> + <NcLoadingIcon v-if="loadingAction === action.id" :size="18" /> + <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView" + :svg="action.iconSvgInline(currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + + <NcButton v-if="fileListWidth >= 512 && enableGridView" + :aria-label="gridViewButtonLabel" + :title="gridViewButtonLabel" + class="files-list__header-grid-button" + type="tertiary" + @click="toggleGridView"> + <template #icon> + <ListViewIcon v-if="userConfig.grid_view" /> + <ViewGridIcon v-else /> + </template> + </NcButton> + </div> + + <!-- Drag and drop notice --> + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- File list - always mounted --> + <FilesListVirtual v-else + ref="filesListVirtual" + :current-folder="currentFolder" + :current-view="currentView" + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> + </NcAppContent> +</template> + +<script lang="ts"> +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' +import type { Upload } from '@nextcloud/upload' +import type { CancelablePromise } from 'cancelable-promise' +import type { ComponentPublicInstance } from 'vue' +import type { Route } from 'vue-router' +import type { UserConfig } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import { translate as t } from '@nextcloud/l10n' +import { join, dirname, normalize, relative } from 'path' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { UploadPicker, UploadStatus } from '@nextcloud/upload' +import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' +import { defineComponent } from 'vue' + +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconReload from 'vue-material-design-icons/Reload.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' +import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' +import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' +import FilesListVirtual from '../components/FilesListVirtual.vue' +import filesSortingMixin from '../mixins/filesSorting.ts' +import logger from '../logger.ts' + +const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined + +export default defineComponent({ + name: 'FilesList', + + components: { + BreadCrumbs, + DragAndDropNotice, + FilesListVirtual, + LinkIcon, + ListViewIcon, + NcAppContent, + NcActions, + NcActionButton, + NcButton, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, + AccountPlusIcon, + UploadPicker, + ViewGridIcon, + IconAlertCircleOutline, + IconReload, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + isPublic: { + type: Boolean, + default: false, + }, + }, + + setup() { + const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() + const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() + const filesStore = useFilesStore() + const filtersStore = useFiltersStore() + const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const userConfigStore = useUserConfigStore() + const viewConfigStore = useViewConfigStore() + + const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) + + return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, + filesStore, + filtersStore, + pathsStore, + selectionStore, + uploaderStore, + userConfigStore, + viewConfigStore, + + // non reactive data + enableGridView, + forbiddenCharacters, + ShareType, + } + }, + + data() { + return { + loading: true, + loadingAction: null as string | null, + error: null as string | null, + promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, + + dirContentsFiltered: [] as INode[], + } + }, + + computed: { + /** + * Get a callback function for the uploader to fetch directory contents for conflict resolution + */ + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + + pageHeading(): string { + const title = this.currentView?.name ?? t('files', 'Files') + + if (this.currentFolder === undefined || this.directory === '/') { + return title + } + return `${this.currentFolder.displayname} - ${title}` + }, + + /** + * The current folder. + */ + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) + + if (!this.currentView?.id) { + return dummyFolder + } + + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder + }, + + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.filesStore.getNode) + .filter((node: Node) => !!node) + }, + + /** + * The current directory contents. + */ + dirContentsSorted(): INode[] { + if (!this.currentView) { + return [] + } + + const customColumn = (this.currentView?.columns || []) + .find(column => column.id === this.sortingMode) + + // Custom column must provide their own sorting methods + if (customColumn?.sort && typeof customColumn.sort === 'function') { + const results = [...this.dirContentsFiltered].sort(customColumn.sort) + return this.isAscSorting ? results : results.reverse() + } + + const nodes = sortNodes(this.dirContentsFiltered, { + sortFavoritesFirst: this.userConfig.sort_favorites_first, + sortFoldersFirst: this.userConfig.sort_folders_first, + sortingMode: this.sortingMode, + sortingOrder: this.isAscSorting ? 'asc' : 'desc', + }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 + } + return 1 + }) + } + + return nodes + }, + + /** + * The current directory is empty. + */ + isEmptyDir(): boolean { + return this.dirContents.length === 0 + }, + + /** + * We are refreshing the current directory. + * But we already have a cached version of it + * that is not empty. + */ + isRefreshing(): boolean { + return this.currentFolder !== undefined + && !this.isEmptyDir + && this.loading + }, + + /** + * Route to the previous directory. + */ + toPreviousDir(): Route { + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' + return { ...this.$route, query: { dir } } + }, + + shareTypesAttributes(): number[] | undefined { + if (!this.currentFolder?.attributes?.['share-types']) { + return undefined + } + return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] + }, + shareButtonLabel() { + if (!this.shareTypesAttributes) { + return t('files', 'Share') + } + + if (this.shareButtonType === ShareType.Link) { + return t('files', 'Shared by link') + } + return t('files', 'Shared') + }, + shareButtonType(): ShareType | null { + if (!this.shareTypesAttributes) { + return null + } + + // If all types are links, show the link icon + if (this.shareTypesAttributes.some(type => type === ShareType.Link)) { + return ShareType.Link + } + + return ShareType.User + }, + + gridViewButtonLabel() { + return this.userConfig.grid_view + ? t('files', 'Switch to list view') + : t('files', 'Switch to grid view') + }, + + /** + * Check if the current folder has create permissions + */ + canUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 + }, + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 + }, + + /** + * Check if current folder has share permissions + */ + canShare() { + return isSharingEnabled && !this.isPublic + && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 + }, + + showCustomEmptyView() { + return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined + }, + + enabledFileListActions() { + if (!this.currentView || !this.currentFolder) { + return [] + } + + const actions = getFileListActions() + const enabledActions = actions + .filter(action => { + if (action.enabled === undefined) { + return true + } + return action.enabled( + this.currentView!, + this.dirContents, + this.currentFolder as Folder, + ) + }) + .toSorted((a, b) => a.order - b.order) + return enabledActions + }, + + /** + * Using the filtered content if filters are active + */ + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) + }, + + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, + }, + + watch: { + /** + * Handle rendering the custom empty view + * @param show The current state if the custom empty view should be rendered + */ + showCustomEmptyView(show: boolean) { + if (show) { + this.$nextTick(() => { + const el = this.$refs.customEmptyView as HTMLDivElement + // We can cast here because "showCustomEmptyView" assets that current view is set + this.currentView!.emptyView!(el) + }) + } + }, + + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + + currentView(newView, oldView) { + if (newView?.id === oldView?.id) { + return + } + + logger.debug('View changed', { newView, oldView }) + this.selectionStore.reset() + this.fetchContent() + }, + + directory(newDir, oldDir) { + logger.debug('Directory changed', { newDir, oldDir }) + // TODO: preserve selection on browsing? + this.selectionStore.reset() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } + this.fetchContent() + + // Scroll to top, force virtual scroller to re-render + const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined + if (filesListVirtual?.$el) { + filesListVirtual.$el.scrollTop = 0 + } + }, + + dirContents(contents) { + logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) + emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() + }, + }, + + async mounted() { + subscribe('files:node:deleted', this.onNodeDeleted) + subscribe('files:node:updated', this.onUpdatedNode) + + // reload on settings change + subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } + }, + + unmounted() { + unsubscribe('files:node:deleted', this.onNodeDeleted) + unsubscribe('files:node:updated', this.onUpdatedNode) + unsubscribe('files:config:updated', this.fetchContent) + unsubscribe('files:filters:changed', this.filterDirContent) + unsubscribe('files:search:updated', this.onUpdateSearch) + }, + + methods: { + onUpdateSearch({ query, scope }) { + if (query && scope !== 'filter') { + this.debouncedFetchContent() + } + }, + + async fetchContent() { + this.loading = true + this.error = null + const dir = this.directory + const currentView = this.currentView + + if (!currentView) { + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) + return + } + + logger.debug('Fetching contents for directory', { dir, currentView }) + + // If we have a cancellable promise ongoing, cancel it + if (this.promise && 'cancel' in this.promise) { + this.promise.cancel() + logger.debug('Cancelled previous ongoing fetch') + } + + // Fetch the current dir contents + this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot> + try { + const { folder, contents } = await this.promise + logger.debug('Fetched contents', { dir, folder, contents }) + + // Update store + this.filesStore.updateNodes(contents) + + // Define current directory children + // TODO: make it more official + this.$set(folder, '_children', contents.map(node => node.source)) + + // If we're in the root dir, define the root + if (dir === '/') { + this.filesStore.setRoot({ service: currentView.id, root: folder }) + } else { + // Otherwise, add the folder to the store + if (folder.fileid) { + this.filesStore.updateNodes([folder]) + this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir }) + } else { + // If we're here, the view API messed up + logger.fatal('Invalid root folder returned', { dir, folder, currentView }) + } + } + + // Update paths store + const folders = contents.filter(node => node.type === 'folder') + folders.forEach((node) => { + this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) }) + }) + } catch (error) { + logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) + } finally { + this.loading = false + } + + }, + + /** + * Handle the node deleted event to reset open file + * @param node The deleted node + */ + onNodeDeleted(node: Node) { + if (node.fileid && node.fileid === this.fileId) { + if (node.fileid === this.currentFolder?.fileid) { + // Handle the edge case that the current directory is deleted + // in this case we need to keep the current view but move to the parent directory + window.OCP.Files.Router.goToRoute( + null, + { view: this.currentView!.id }, + { dir: this.currentFolder?.dirname ?? '/' }, + ) + } else { + // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: undefined }, + { ...this.$route.query, openfile: undefined }, + ) + } + } + }, + + /** + * The upload manager have finished handling the queue + * @param {Upload} upload the uploaded data + */ + onUpload(upload: Upload) { + // Let's only refresh the current Folder + // Navigating to a different folder will refresh it anyway + const needsRefresh = dirname(upload.source) === this.currentFolder!.source + + // TODO: fetch uploaded files data only + // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid + if (needsRefresh) { + // fetchContent will cancel the previous ongoing promise + this.fetchContent() + } + }, + + async onUploadFail(upload: Upload) { + const status = upload.response?.status || 0 + + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + + // Check known status codes + if (status === 507) { + showError(t('files', 'Not enough free space')) + return + } else if (status === 404 || status === 409) { + showError(t('files', 'Target folder does not exist any more')) + return + } else if (status === 403) { + showError(t('files', 'Operation is blocked by access control')) + return + } + + // Else we try to parse the response error message + if (typeof upload.response?.data === 'string') { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(upload.response.data, 'text/xml') + const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? '' + if (message.trim() !== '') { + // The server message is also translated + showError(t('files', 'Error during upload: {message}', { message })) + return + } + } catch (error) { + logger.error('Could not parse message', { error }) + } + } + + // Finally, check the status code if we have one + if (status !== 0) { + showError(t('files', 'Error during upload, status code {status}', { status })) + return + } + + showError(t('files', 'Unknown error during upload')) + }, + + /** + * Refreshes the current folder on update. + * + * @param node is the file/folder being updated. + */ + onUpdatedNode(node?: Node) { + if (node?.fileid === this.currentFolder?.fileid) { + this.fetchContent() + } + }, + + openSharingSidebar() { + if (!this.currentFolder) { + logger.debug('No current folder found for opening sharing sidebar') + return + } + + if (window?.OCA?.Files?.Sidebar?.setActiveTab) { + window.OCA.Files.Sidebar.setActiveTab('sharing') + } + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) + }, + + toggleGridView() { + this.userConfigStore.update('grid_view', !this.userConfig.grid_view) + }, + + filterDirContent() { + let nodes: INode[] = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, + + actionDisplayName(action: FileListAction): string { + let displayName = action.id + try { + displayName = action.displayName(this.currentView!) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + return displayName + }, + + async execFileListAction(action: FileListAction) { + this.loadingAction = action.id + + const displayName = this.actionDisplayName(action) + try { + const success = await action.exec(this.source, this.dirContents, this.currentDir) + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + this.loadingAction = null + } + }, + }, +}) +</script> + +<style scoped lang="scss"> +:global(.toast-loading-icon) { + // Reduce start margin (it was made for text but this is an icon) + margin-inline-start: -4px; + // 16px icon + 5px on both sides + min-width: 26px; +} + +.app-content { + // Virtual list needs to be full height and is scrollable + display: flex; + overflow: hidden; + flex-direction: column; + max-height: 100%; + position: relative !important; +} + +.files-list { + &__header { + display: flex; + align-items: center; + // Do not grow or shrink (vertically) + flex: 0 0; + max-width: 100%; + // Align with the navigation toggle icon + margin-block: var(--app-navigation-padding, 4px); + margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px); + + &--public { + // There is no navigation toggle on public shares + margin-inline: 0 var(--app-navigation-padding, 4px); + } + + >* { + // Do not grow or shrink (horizontally) + // Only the breadcrumbs shrinks + flex: 0 0; + } + + &-share-button { + color: var(--color-text-maxcontrast) !important; + + &--shared { + color: var(--color-main-text) !important; + } + } + + &-actions { + min-width: fit-content !important; + margin-inline: calc(var(--default-grid-baseline) * 2); + } + } + + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + + &__empty-view-wrapper { + display: flex; + height: 100%; + } + + &__refresh-icon { + flex: 0 0 var(--default-clickable-area); + width: var(--default-clickable-area); + height: var(--default-clickable-area); + } + + &__loading-icon { + margin: auto; + } +} +</style> diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index c8b0f07dea1..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -1,28 +1,61 @@ -import * as InitialState from '@nextcloud/initial-state' -import * as L10n from '@nextcloud/l10n' -import FolderSvg from '@mdi/svg/svg/folder.svg' -import ShareSvg from '@mdi/svg/svg/share-variant.svg' +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Navigation } from '@nextcloud/files' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import { createTestingPinia } from '@pinia/testing' -import NavigationService from '../services/Navigation' import NavigationView from './Navigation.vue' -import router from '../router/router.js' - -describe('Navigation renders', () => { - const Navigation = new NavigationService() +import { useViewConfigStore } from '../store/viewConfig' +import { Folder, View, getNavigation } from '@nextcloud/files' + +import router from '../router/router.ts' +import RouterService from '../services/RouterService' + +const resetNavigation = () => { + const nav = getNavigation() + ;[...nav.views].forEach(({ id }) => nav.remove(id)) + nav.setActive(null) +} + +const createView = (id: string, name: string, parent?: string) => new View({ + id, + name, + getContents: async () => ({ folder: {} as Folder, contents: [] }), + icon: FolderSvg, + order: 1, + parent, +}) - before(() => { - cy.stub(InitialState, 'loadState') - .returns({ - used: 1024 * 1024 * 1024, - quota: -1, - }) +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} +describe('Navigation renders', () => { + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) + + cy.mockInitialState('files', 'storageStats', { + used: 1000 * 1000 * 1000, + quota: -1, + }) }) + after(() => cy.unmockInitialState()) + it('renders', () => { cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -33,22 +66,31 @@ describe('Navigation renders', () => { }) describe('Navigation API', () => { - const Navigation = new NavigationService() + let Navigation: Navigation + + before(async () => { + delete window._nc_navigation + Navigation = getNavigation() + mockWindow() + + await router.replace({ name: 'filelist', params: { view: 'files' } }) + }) + + beforeEach(() => resetNavigation()) it('Check API entries rendering', () => { - Navigation.register({ - id: 'files', - name: 'Files', - getFiles: () => [], - icon: FolderSvg, - order: 1, - }) + Navigation.register(createView('files', 'Files')) + console.warn(Navigation.views) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [ + createTestingPinia({ + createSpy: cy.spy, + }), + ], + }, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -58,19 +100,16 @@ describe('Navigation API', () => { }) it('Adds a new entry and render', () => { - Navigation.register({ - id: 'sharing', - name: 'Sharing', - getFiles: () => [], - icon: ShareSvg, - order: 2, - }) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -80,76 +119,67 @@ describe('Navigation API', () => { }) it('Adds a new children, render and open menu', () => { - Navigation.register({ - id: 'sharingin', - name: 'Shared with me', - getFiles: () => [], - parent: 'sharing', - icon: ShareSvg, - order: 1, - }) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) + Navigation.register(createView('sharingin', 'Shared with me', 'sharing')) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, }) + cy.wrap(useViewConfigStore()).as('viewConfigStore') + cy.get('[data-cy-files-navigation]').should('be.visible') cy.get('[data-cy-files-navigation-item]').should('have.length', 3) - // Intercept collapse preference request - cy.intercept('POST', '*/apps/files/api/v1/toggleShowFolder/*', { - statusCode: 200, - }).as('toggleShowFolder') - // Toggle the sharing entry children cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist') cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true }) - cy.wait('@toggleShowFolder') + + // Expect store update to be called + cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true) // Validate children cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible') cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me') + // Toggle the sharing entry children 🇦again + cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true }) + cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible') + + // Expect store update to be called + cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false) }) it('Throws when adding a duplicate entry', () => { - expect(() => { - Navigation.register({ - id: 'files', - name: 'Files', - getFiles: () => [], - icon: FolderSvg, - order: 1, - }) - }).to.throw('Navigation id files is already registered') + Navigation.register(createView('files', 'Files')) + expect(() => Navigation.register(createView('files', 'Files'))) + .to.throw('View id files is already registered') }) }) describe('Quota rendering', () => { - const Navigation = new NavigationService() - - beforeEach(() => { - // TODO: remove when @nextcloud/l10n 2.0 is released - // https://github.com/nextcloud/nextcloud-l10n/pull/542 - cy.stub(L10n, 'translate', (app, text, vars = {}, number) => { - cy.log({app, text, vars, number}) - return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => { - return vars[key] - }) - }) + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) - it('Unknown quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns(undefined) + afterEach(() => cy.unmockInitialState()) + it('Unknown quota', () => { cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -157,16 +187,18 @@ describe('Quota rendering', () => { }) it('Unlimited quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 1024 * 1024 * 1024, - quota: -1, - }) + cy.mockInitialState('files', 'storageStats', { + used: 1024 * 1024 * 1024, + quota: -1, + total: 50 * 1024 * 1024 * 1024, + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -176,44 +208,50 @@ describe('Quota rendering', () => { }) it('Non-reached quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 1024 * 1024 * 1024, - quota: 5 * 1024 * 1024 * 1024, - relative: 20, // percent - }) + cy.mockInitialState('files', 'storageStats', { + used: 1024 * 1024 * 1024, + quota: 5 * 1024 * 1024 * 1024, + total: 5 * 1024 * 1024 * 1024, + relative: 20, // percent + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20') + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '20') }) it('Reached quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 5 * 1024 * 1024 * 1024, - quota: 1024 * 1024 * 1024, - relative: 500, // percent - }) + cy.mockInitialState('files', 'storageStats', { + used: 5 * 1024 * 1024 * 1024, + quota: 1024 * 1024 * 1024, + total: 1024 * 1024 * 1024, + relative: 500, // percent + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100 + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '100') // progress max is 100 }) }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 040e1482e32..0f3c3647c6e 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -1,45 +1,24 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcAppNavigation data-cy-files-navigation> - <template #list> - <NcAppNavigationItem v-for="view in parentViews" - :key="view.id" - :allow-collapse="true" - :data-cy-files-navigation-item="view.id" - :icon="view.iconClass" - :open="view.expanded" - :pinned="view.sticky" - :title="view.name" - :to="generateToNavigation(view)" - @update:open="onToggleExpand(view)"> - <NcAppNavigationItem v-for="child in childViews[view.id]" - :key="child.id" - :data-cy-files-navigation-item="child.id" - :exact="true" - :icon="child.iconClass" - :title="child.name" - :to="generateToNavigation(child)" /> - </NcAppNavigationItem> + <NcAppNavigation data-cy-files-navigation + class="files-navigation" + :aria-label="t('files', 'Files')"> + <template #search> + <FilesNavigationSearch /> + </template> + <template #default> + <NcAppNavigationList class="files-navigation__list" + :aria-label="t('files', 'Views')"> + <FilesNavigationItem :views="viewMap" /> + </NcAppNavigationList> + + <!-- Settings modal--> + <SettingsModal :open.sync="settingsOpened" + data-cy-files-navigation-settings + @close="onSettingsClose" /> </template> <!-- Non-scrollable navigation bottom elements --> @@ -49,54 +28,75 @@ <NavigationQuota /> <!-- Files settings modal toggle--> - <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')" - :title="t('files', 'Files settings')" + <NcAppNavigationItem :name="t('files', 'Files settings')" data-cy-files-navigation-settings-button @click.prevent.stop="openSettings"> - <Cog slot="icon" :size="20" /> + <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> - - <!-- Settings modal--> - <SettingsModal :open="settingsOpened" - data-cy-files-navigation-settings - @close="onSettingsClose" /> </NcAppNavigation> </template> -<script> -import { emit, subscribe } from '@nextcloud/event-bus' -import { generateUrl } from '@nextcloud/router' -import { translate } from '@nextcloud/l10n' - -import axios from '@nextcloud/axios' -import Cog from 'vue-material-design-icons/Cog.vue' -import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' +<script lang="ts"> +import type { View } from '@nextcloud/files' +import type { ViewConfig } from '../types.ts' -import logger from '../logger.js' -import Navigation from '../services/Navigation.ts' +import { emit, subscribe } from '@nextcloud/event-bus' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import IconCog from 'vue-material-design-icons/CogOutline.vue' +import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' +import FilesNavigationItem from '../components/FilesNavigationItem.vue' +import FilesNavigationSearch from '../components/FilesNavigationSearch.vue' + +import { useNavigation } from '../composables/useNavigation' +import { useFiltersStore } from '../store/filters.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import logger from '../logger.ts' + +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) -export default { +export default defineComponent({ name: 'Navigation', components: { - Cog, + IconCog, + FilesNavigationItem, + FilesNavigationSearch, + + NavigationQuota, NcAppNavigation, NcAppNavigationItem, + NcAppNavigationList, SettingsModal, - NavigationQuota, }, - props: { - // eslint-disable-next-line vue/prop-name-casing - Navigation: { - type: Navigation, - required: true, - }, + setup() { + const filtersStore = useFiltersStore() + const viewConfigStore = useViewConfigStore() + const { currentView, views } = useNavigation() + + return { + currentView, + t, + views, + + filtersStore, + viewConfigStore, + } }, data() { @@ -106,134 +106,77 @@ export default { }, computed: { + /** + * The current view ID from the route params + */ currentViewId() { return this.$route?.params?.view || 'files' }, - /** @return {Navigation} */ - currentView() { - return this.views.find(view => view.id === this.currentViewId) - }, - - /** @return {Navigation[]} */ - views() { - return this.Navigation.views - }, - - /** @return {Navigation[]} */ - parentViews() { - return this.views - // filter child views - .filter(view => !view.parent) - // sort views by order - .sort((a, b) => { - return a.order - b.order - }) - }, - - /** @return {Navigation[]} */ - childViews() { + /** + * Map of parent ids to views + */ + viewMap(): Record<string, View[]> { return this.views - // filter parent views - .filter(view => !!view.parent) - // create a map of parents and their children - .reduce((list, view) => { - list[view.parent] = [...(list[view.parent] || []), view] - // Sort children by order - list[view.parent].sort((a, b) => { - return a.order - b.order + .reduce((map, view) => { + map[view.parent!] = [...(map[view.parent!] || []), view] + map[view.parent!].sort((a, b) => { + if (typeof a.order === 'number' || typeof b.order === 'number') { + return (a.order ?? 0) - (b.order ?? 0) + } + return collator.compare(a.name, b.name) }) - return list - }, {}) + return map + }, {} as Record<string, View[]>) }, }, watch: { - currentView(view, oldView) { - logger.debug('View changed', { id: view.id, view }) - this.showView(view, oldView) + currentViewId(newView, oldView) { + if (this.currentViewId !== this.currentView?.id) { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + // The new view as active + this.showView(view) + logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view }) + } }, }, - beforeMount() { - if (this.currentView) { - logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) - this.showView(this.currentView) - } + created() { + subscribe('files:folder-tree:initialized', this.loadExpandedViews) + subscribe('files:folder-tree:expanded', this.loadExpandedViews) + }, - subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged) + beforeMount() { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + this.showView(view) + logger.debug('Navigation mounted. Showing requested view', { view }) }, methods: { - /** - * @param {Navigation} view the new active view - * @param {Navigation} oldView the old active view - */ - showView(view, oldView) { - // Closing any opened sidebar - window?.OCA?.Files?.Sidebar?.close?.() - - if (view.legacy) { - const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer') - document.querySelectorAll('#app-content .viewcontainer').forEach(el => { - el.classList.add('hidden') - }) - newAppContent.classList.remove('hidden') - - // Triggering legacy navigation events - const { dir = '/' } = OC.Util.History.parseUrlQuery() - const params = { itemId: view.id, dir } - - logger.debug('Triggering legacy navigation event', params) - window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params)) - window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params)) - - } - - this.Navigation.setActive(view) - emit('files:navigation:changed', view) - }, - - /** - * Coming from the legacy files app. - * TODO: remove when all views are migrated. - * - * @param {Navigation} view the new active view - */ - onLegacyNavigationChanged({ id } = { id: 'files' }) { - const view = this.Navigation.views.find(view => view.id === id) - if (view && view.legacy && view.id !== this.currentView.id) { - // Force update the current route as the request comes - // from the legacy files app router - this.$router.replace({ ...this.$route, params: { view: view.id } }) - this.Navigation.setActive(view) - this.showView(view) + async loadExpandedViews() { + const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>) + .filter(([, config]) => config.expanded === true) + .map(([viewId]) => this.views.find(view => view.id === viewId)) + // eslint-disable-next-line no-use-before-define + .filter(Boolean as unknown as ((u: unknown) => u is View)) + .filter((view) => view.loadChildViews && !view.loaded) + for (const view of viewsToLoad) { + await view.loadChildViews(view) } }, /** - * Expand/collapse a a view with children and permanently - * save this setting in the server. - * - * @param {Navigation} view the view to toggle - */ - onToggleExpand(view) { - // Invert state - view.expanded = !view.expanded - axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded }) - }, - - /** - * Generate the route to a view - * - * @param {Navigation} view the view to toggle + * Set the view as active on the navigation and handle internal state + * @param view View to set active */ - generateToNavigation(view) { - if (view.params) { - const { dir, fileid } = view.params - return { name: 'filelist', params: view.params, query: { dir, fileid } } - } - return { name: 'filelist', params: { view: view.id } } + showView(view: View) { + // Closing any opened sidebar + window.OCA?.Files?.Sidebar?.close?.() + getNavigation().setActive(view) + emit('files:navigation:changed', view) }, /** @@ -249,22 +192,20 @@ export default { onSettingsClose() { this.settingsOpened = false }, - - t: translate, }, -} +}) </script> <style scoped lang="scss"> -// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in -.app-navigation::v-deep .app-navigation-entry-icon { - background-repeat: no-repeat; - background-position: center; -} - -.app-navigation > ul.app-navigation__list { - // Use flex gap value for more elegant spacing - padding-bottom: var(--default-grid-baseline, 4px); +.app-navigation { + :deep(.app-navigation-entry.active .button-vue.icon-collapse:not(:hover)) { + color: var(--color-primary-element-text); + } + + > ul.app-navigation__list { + // Use flex gap value for more elegant spacing + padding-bottom: var(--default-grid-baseline, 4px); + } } .app-navigation-entry__settings { @@ -274,4 +215,14 @@ export default { // Prevent shrinking or growing flex: 0 0 auto; } + +.files-navigation { + &__list { + height: 100%; // Fill all available space for sticky views + } + + :deep(.app-navigation__content > ul.app-navigation__list) { + will-change: scroll-position; + } +} </style> diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue new file mode 100644 index 00000000000..9db346ea35d --- /dev/null +++ b/apps/files/src/views/ReferenceFileWidget.vue @@ -0,0 +1,306 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div v-if="!accessible" class="widget-file widget-file--no-access"> + <span class="widget-file__image widget-file__image--icon"> + <FolderIcon v-if="isFolder" :size="88" /> + <FileIcon v-else :size="88" /> + </span> + <span class="widget-file__details"> + <p class="widget-file__title"> + {{ t('files', 'File cannot be accessed') }} + </p> + <p class="widget-file__description"> + {{ t('files', 'The file could not be found or you do not have permissions to view it. Ask the sender to share it.') }} + </p> + </span> + </div> + + <!-- Live preview if a handler is available --> + <component :is="viewerHandler.component" + v-else-if="interactive && viewerHandler && !failedViewer" + :active="false /* prevent video from autoplaying */" + :can-swipe="false" + :can-zoom="false" + :is-embedded="true" + v-bind="viewerFile" + :file-list="[viewerFile]" + :is-full-screen="false" + :is-sidebar-shown="false" + class="widget-file widget-file--interactive" + @error="failedViewer = true" /> + + <!-- The file is accessible --> + <a v-else + class="widget-file widget-file--link" + :href="richObject.link" + target="_blank" + @click="navigate"> + <span class="widget-file__image" :class="filePreviewClass" :style="filePreviewStyle"> + <template v-if="!previewUrl"> + <FolderIcon v-if="isFolder" :size="88" fill-color="var(--color-primary-element)" /> + <FileIcon v-else :size="88" /> + </template> + </span> + <span class="widget-file__details"> + <p class="widget-file__title">{{ richObject.name }}</p> + <p class="widget-file__description">{{ fileSize }}<br>{{ fileMtime }}</p> + <p class="widget-file__link">{{ filePath }}</p> + </span> + </a> +</template> + +<script lang="ts"> +import { defineComponent, type Component, type PropType } from 'vue' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { Node } from '@nextcloud/files' +import FileIcon from 'vue-material-design-icons/File.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' +import path from 'path' + +// see lib/private/Collaboration/Reference/File/FileReferenceProvider.php +type Ressource = { + id: number + name: string + size: number + path: string + link: string + mimetype: string + mtime: number // as unix timestamp + 'preview-available': boolean +} + +type ViewerHandler = { + id: string + group: string + mimes: string[] + component: Component +} + +/** + * Minimal mock of the legacy Viewer FileInfo + * TODO: replace by Node object + */ +type ViewerFile = { + filename: string // the path to the root folder + basename: string // the file name + lastmod: Date // the last modification date + size: number // the file size in bytes + type: string + mime: string + fileid: number + failed: boolean + loaded: boolean + davPath: string + source: string +} + +export default defineComponent({ + name: 'ReferenceFileWidget', + components: { + FolderIcon, + FileIcon, + }, + props: { + richObject: { + type: Object as PropType<Ressource>, + required: true, + }, + accessible: { + type: Boolean, + default: true, + }, + interactive: { + type: Boolean, + default: true, + }, + }, + + data() { + return { + previewUrl: null as string | null, + failedViewer: false, + } + }, + + computed: { + availableViewerHandlers(): ViewerHandler[] { + return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[] + }, + viewerHandler(): ViewerHandler | undefined { + return this.availableViewerHandlers + .find(handler => handler.mimes.includes(this.richObject.mimetype)) + }, + viewerFile(): ViewerFile { + const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`) + .replace(/\/\/$/, '/') + return { + filename: this.richObject.path, + basename: this.richObject.name, + lastmod: new Date(this.richObject.mtime * 1000), + size: this.richObject.size, + type: 'file', + mime: this.richObject.mimetype, + fileid: this.richObject.id, + failed: false, + loaded: true, + davPath: davSource, + source: davSource, + } + }, + + fileSize() { + return window.OC.Util.humanFileSize(this.richObject.size) + }, + fileMtime() { + return window.OC.Util.relativeModifiedDate(this.richObject.mtime * 1000) + }, + filePath() { + return path.dirname(this.richObject.path) + }, + filePreviewStyle() { + if (this.previewUrl) { + return { + backgroundImage: 'url(' + this.previewUrl + ')', + } + } + return {} + }, + filePreviewClass() { + if (this.previewUrl) { + return 'widget-file__image--preview' + } + return 'widget-file__image--icon' + + }, + isFolder() { + return this.richObject.mimetype === 'httpd/unix-directory' + }, + }, + + mounted() { + if (this.richObject['preview-available']) { + const previewUrl = generateUrl('/core/preview?fileId={fileId}&x=250&y=250', { + fileId: this.richObject.id, + }) + const img = new Image() + img.onload = () => { + this.previewUrl = previewUrl + } + img.onerror = err => { + console.error('could not load recommendation preview', err) + } + img.src = previewUrl + } + }, + methods: { + navigate(event) { + if (this.isFolder) { + event.stopPropagation() + event.preventDefault() + this.openFilePicker() + } else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) { + event.stopPropagation() + event.preventDefault() + window?.OCA?.Viewer?.open({ path: this.richObject.path }) + } + }, + + openFilePicker() { + const picker = getFilePickerBuilder(t('settings', 'Your files')) + .allowDirectories(true) + .setMultiSelect(false) + .addButton({ + id: 'open', + label: this.t('settings', 'Open in files'), + callback(nodes: Node[]) { + if (nodes[0]) { + window.open(generateUrl('/f/{fileid}', { + fileid: nodes[0].fileid, + })) + } + }, + type: 'primary', + }) + .disableNavigation() + .startAt(this.richObject.path) + .build() + picker.pick() + }, + }, +}) +</script> + +<style lang="scss" scoped> +.widget-file { + display: flex; + flex-grow: 1; + color: var(--color-main-text) !important; + text-decoration: none !important; + padding: 0 !important; + + &__image { + width: 30%; + min-width: 160px; + max-width: 320px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + + &--icon { + min-width: 88px; + max-width: 88px; + padding: 12px; + padding-inline-end: 0; + display: flex; + align-items: center; + justify-content: center; + } + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; + } + + &__details { + padding: 12px; + flex-grow: 1; + display: flex; + flex-direction: column; + + p { + margin: 0; + padding: 0; + } + } + + &__description { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + } + + // No preview, standard link to ressource + &--link { + color: var(--color-text-maxcontrast); + } + + &--interactive { + position: relative; + height: 400px; + max-height: 50vh; + margin: 0; + } +} +</style> diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue new file mode 100644 index 00000000000..904e1b0831d --- /dev/null +++ b/apps/files/src/views/SearchEmptyView.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnifyClose } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import debounce from 'debounce' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import { getPinia } from '../store/index.ts' +import { useSearchStore } from '../store/search.ts' + +const searchStore = useSearchStore(getPinia()) +const debouncedUpdate = debounce((value: string) => { + searchStore.query = value +}, 500) +</script> + +<template> + <NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnifyClose" /> + </template> + <template #action> + <div class="search-empty-view__wrapper"> + <NcInputField class="search-empty-view__input" + :label="t('files', 'Search for files')" + :model-value="searchStore.query" + type="search" + @update:model-value="debouncedUpdate" /> + </div> + </template> + </NcEmptyContent> +</template> + +<style scoped lang="scss"> +.search-empty-view { + &__input { + flex: 0 1; + min-width: min(400px, 50vw); + } + + &__wrapper { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + } +} +</style> diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index 9a63fea4924..bfac8e0b3d6 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -1,36 +1,70 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSettingsDialog :open="open" :show-navigation="true" - :title="t('files', 'Files settings')" + :name="t('files', 'Files settings')" @update:open="onClose"> <!-- Settings API--> - <NcAppSettingsSection id="settings" :title="t('files', 'Files settings')"> - <NcCheckboxRadioSwitch :checked.sync="show_hidden" + <NcAppSettingsSection id="settings" :name="t('files', 'General')"> + <fieldset class="files-settings__default-view" + data-cy-files-settings-setting="default_view"> + <legend> + {{ t('files', 'Default view') }} + </legend> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="files" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'All files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="personal" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'Personal files') }} + </NcCheckboxRadioSwitch> + </fieldset> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first" + :checked="userConfig.sort_favorites_first" + @update:checked="setConfig('sort_favorites_first', $event)"> + {{ t('files', 'Sort favorites first') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_folders_first" + :checked="userConfig.sort_folders_first" + @update:checked="setConfig('sort_folders_first', $event)"> + {{ t('files', 'Sort folders before files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" + :checked="userConfig.folder_tree" + @update:checked="setConfig('folder_tree', $event)"> + {{ t('files', 'Folder tree') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <!-- Appearance --> + <NcAppSettingsSection id="settings" :name="t('files', 'Appearance')"> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden" + :checked="userConfig.show_hidden" @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch :checked.sync="crop_image_previews" + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column" + :checked="userConfig.show_mime_column" + @update:checked="setConfig('show_mime_column', $event)"> + {{ t('files', 'Show file type column') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_files_extensions" + :checked="userConfig.show_files_extensions" + @update:checked="setConfig('show_files_extensions', $event)"> + {{ t('files', 'Show file extensions') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews" + :checked="userConfig.crop_image_previews" @update:checked="setConfig('crop_image_previews', $event)"> {{ t('files', 'Crop image previews') }} </NcCheckboxRadioSwitch> @@ -39,19 +73,21 @@ <!-- Settings API--> <NcAppSettingsSection v-if="settings.length !== 0" id="more-settings" - :title="t('files', 'Additional settings')"> + :name="t('files', 'Additional settings')"> <template v-for="setting in settings"> <Setting :key="setting.name" :el="setting.el" /> </template> </NcAppSettingsSection> <!-- Webdav URL--> - <NcAppSettingsSection id="webdav" :title="t('files', 'Webdav')"> + <NcAppSettingsSection id="webdav" :name="t('files', 'WebDAV')"> <NcInputField id="webdav-url-input" + :label="t('files', 'WebDAV URL')" :show-trailing-button="true" :success="webdavUrlCopied" - :trailing-button-label="t('files', 'Copy to clipboard')" + :trailing-button-label="t('files', 'Copy')" :value="webdavUrl" + class="webdav-url-input" readonly="readonly" type="url" @focus="$event.target.select()" @@ -61,34 +97,209 @@ </template> </NcInputField> <em> - <a :href="webdavDocs" target="_blank" rel="noreferrer noopener"> - {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗ + <a class="setting-link" + :href="webdavDocs" + target="_blank" + rel="noreferrer noopener"> + {{ t('files', 'How to access files using WebDAV') }} ↗ </a> </em> + <br> + <em v-if="isTwoFactorEnabled"> + <a class="setting-link" :href="appPasswordUrl"> + {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗ + </a> + </em> + </NcAppSettingsSection> + + <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')"> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_file_extension" + @update:checked="setConfig('show_dialog_file_extension', $event)"> + {{ t('files', 'Warn before changing a file extension') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_deletion" + @update:checked="setConfig('show_dialog_deletion', $event)"> + {{ t('files', 'Warn before deleting files') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <NcAppSettingsSection id="shortcuts" + :name="t('files', 'Keyboard shortcuts')"> + + <h3>{{ t('files', 'Actions') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>a</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'File actions') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>F2</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Rename') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Del</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Delete') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>s</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Add or remove favorite') }} + </dd> + </div> + <div v-if="isSystemtagsEnabled"> + <dt class="shortcut-key"> + <kbd>t</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Manage tags') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Selection') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>A</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select all files') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>ESC</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Deselect all') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Space</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select or deselect') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Shift</kbd> <span>+ <kbd>Space</kbd></span> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select a range') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Navigation') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Alt</kbd> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to parent folder') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file above') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↓</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file below') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>←</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go left in grid') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>→</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go right in grid') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'View') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>V</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Toggle grid view') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>D</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Open file sidebar') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>?</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Show those shortcuts') }} + </dd> + </div> + </dl> </NcAppSettingsSection> </NcAppSettingsDialog> </template> <script> -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import Clipboard from 'vue-material-design-icons/Clipboard.vue' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField' -import Setting from '../components/Setting.vue' - -import { emit } from '@nextcloud/event-bus' -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' -import { loadState } from '@nextcloud/initial-state' +import { getCapabilities } from '@nextcloud/capabilities' import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate } from '@nextcloud/l10n' -import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -const userConfig = loadState('files', 'config', { - show_hidden: false, - crop_image_previews: true, -}) +import Clipboard from 'vue-material-design-icons/ContentCopy.vue' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcInputField from '@nextcloud/vue/components/NcInputField' + +import { useUserConfigStore } from '../store/userconfig.ts' +import Setting from '../components/Setting.vue' export default { name: 'Settings', @@ -108,21 +319,55 @@ export default { }, }, - data() { + setup() { + const userConfigStore = useUserConfigStore() + const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true return { + isSystemtagsEnabled, + userConfigStore, + t, + } + }, - ...userConfig, - + data() { + return { // Settings API settings: window.OCA?.Files?.Settings?.settings || [], // Webdav infos webdavUrl: generateRemoteUrl('dav/files/' + encodeURIComponent(getCurrentUser()?.uid)), webdavDocs: 'https://docs.nextcloud.com/server/stable/go.php?to=user-webdav', + appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'), webdavUrlCopied: false, + enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true), + isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)), } }, + computed: { + userConfig() { + return this.userConfigStore.userConfig + }, + + sortedSettings() { + // Sort settings by name + return [...this.settings].sort((a, b) => { + if (a.order && b.order) { + return a.order - b.order + } + return a.name.localeCompare(b.name) + }) + }, + }, + + created() { + // ? opens the settings dialog on the keyboard shortcuts section + useHotKey('?', this.showKeyboardShortcuts, { + stop: true, + prevent: true, + }) + }, + beforeMount() { // Update the settings API entries state this.settings.forEach(setting => setting.open()) @@ -139,10 +384,7 @@ export default { }, setConfig(key, value) { - emit('files:config:updated', { key, value }) - axios.post(generateUrl('/apps/files/api/v1/config/' + key), { - value, - }) + this.userConfigStore.update(key, value) }, async copyCloudId() { @@ -156,17 +398,47 @@ export default { await navigator.clipboard.writeText(this.webdavUrl) this.webdavUrlCopied = true - showSuccess(t('files', 'Webdav URL copied to clipboard')) + showSuccess(t('files', 'WebDAV URL copied')) setTimeout(() => { this.webdavUrlCopied = false }, 5000) }, - t: translate, + async showKeyboardShortcuts() { + this.$emit('update:open', true) + + await this.$nextTick() + document.getElementById('settings-section_shortcuts').scrollIntoView({ + behavior: 'smooth', + inline: 'nearest', + }) + }, }, } </script> <style lang="scss" scoped> +.files-settings { + &__default-view { + margin-bottom: 0.5rem; + } +} + +.setting-link:hover { + text-decoration: underline; +} +.shortcut-key { + width: 160px; + // some shortcuts are too long to fit in one line + white-space: normal; + span { + // force portion of a shortcut on a new line for nicer display + white-space: nowrap; + } +} + +.webdav-url-input { + margin-block-end: 0.5rem; +} </style> diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index c97fb304c32..40a16d42b42 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -1,49 +1,60 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebar v-if="file" ref="sidebar" + data-cy-sidebar v-bind="appSidebar" :force-menu="true" - tabindex="0" @close="close" @update:active="setActiveTab" - @update:starred="toggleStarred" @[defaultActionListener].stop.prevent="onDefaultAction" @opening="handleOpening" @opened="handleOpened" @closing="handleClosing" @closed="handleClosed"> + <template v-if="fileInfo" #subname> + <div class="sidebar__subname"> + <NcIconSvgWrapper v-if="fileInfo.isFavourited" + :path="mdiStar" + :name="t('files', 'Favorite')" + inline /> + <span>{{ size }}</span> + <span class="sidebar__subname-separator">•</span> + <NcDateTime :timestamp="fileInfo.mtime" /> + <span class="sidebar__subname-separator">•</span> + <span>{{ t('files', 'Owner') }}</span> + <NcUserBubble :user="ownerId" + :display-name="nodeOwnerLabel" /> + </div> + </template> + <!-- TODO: create a standard to allow multiple elements here? --> <template v-if="fileInfo" #description> - <LegacyView v-for="view in views" - :key="view.cid" - :component="view" - :file-info="fileInfo" /> + <div class="sidebar__description"> + <SystemTags v-if="isSystemTagsEnabled && showTagsDefault" + v-show="showTags" + :disabled="!fileInfo?.canEdit()" + :file-id="fileInfo.id" /> + <LegacyView v-for="view in views" + :key="view.cid" + :component="view" + :file-info="fileInfo" /> + </div> </template> <!-- Actions menu --> <template v-if="fileInfo" #secondary-actions> + <NcActionButton :close-after-click="true" + @click="toggleStarred(!fileInfo.isFavourited)"> + <template #icon> + <NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStarOutline : mdiStar" /> + </template> + {{ fileInfo.isFavourited ? t('files', 'Remove from favorites') : t('files', 'Add to favorites') }} + </NcActionButton> <!-- TODO: create proper api for apps to register actions And inject themselves here. --> <NcActionButton v-if="isSystemTagsEnabled" @@ -81,41 +92,71 @@ </template> </NcAppSidebar> </template> -<script> +<script lang="ts"> +import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files' +import { defineComponent } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { encodePath } from '@nextcloud/paths' +import { fetchNode } from '../services/WebdavClient.ts' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { getCurrentUser } from '@nextcloud/auth' +import { mdiStar, mdiStarOutline } from '@mdi/js' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' import $ from 'jquery' import axios from '@nextcloud/axios' -import { emit } from '@nextcloud/event-bus' -import moment from '@nextcloud/moment' -import { Type as ShareTypes } from '@nextcloud/sharing' -import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' +import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' -import FileInfo from '../services/FileInfo' -import SidebarTab from '../components/SidebarTab' -import LegacyView from '../components/LegacyView' +import FileInfo from '../services/FileInfo.js' +import LegacyView from '../components/LegacyView.vue' +import SidebarTab from '../components/SidebarTab.vue' +import SystemTags from '../../../systemtags/src/components/SystemTags.vue' +import logger from '../logger.ts' -export default { +export default defineComponent({ name: 'Sidebar', components: { + LegacyView, NcActionButton, NcAppSidebar, + NcDateTime, NcEmptyContent, - LegacyView, + NcIconSvgWrapper, SidebarTab, + SystemTags, + NcUserBubble, + }, + + setup() { + const currentUser = getCurrentUser() + + // Non reactive properties + return { + currentUser, + + mdiStar, + mdiStarOutline, + } }, data() { return { // reactive state Sidebar: OCA.Files.Sidebar.state, + showTags: false, + showTagsDefault: true, error: null, loading: true, fileInfo: null, - starLoading: false, + node: null, isFullScreen: false, hasLowHeight: false, } @@ -157,14 +198,12 @@ export default { * @return {string} */ davPath() { - const user = OC.getCurrentUser().uid - return OC.linkToRemote(`dav/files/${user}${encodePath(this.file)}`) + return `${davRemoteURL}${davRootPath}${encodePath(this.file)}` }, /** * Current active tab handler * - * @param {string} id the tab id to set as active * @return {string} the current active tab */ activeTab() { @@ -172,39 +211,12 @@ export default { }, /** - * Sidebar subtitle - * - * @return {string} - */ - subtitle() { - return `${this.size}, ${this.time}` - }, - - /** - * File last modified formatted string - * - * @return {string} - */ - time() { - return OC.Util.relativeModifiedDate(this.fileInfo.mtime) - }, - - /** - * File last modified full string - * - * @return {string} - */ - fullTime() { - return moment(this.fileInfo.mtime).format('LLL') - }, - - /** * File size formatted string * * @return {string} */ size() { - return OC.Util.humanFileSize(this.fileInfo.size) + return formatFileSize(this.fileInfo?.size) }, /** @@ -225,7 +237,6 @@ export default { if (this.fileInfo) { return { 'data-mimetype': this.fileInfo.mimetype, - 'star-loading': this.starLoading, active: this.activeTab, background: this.background, class: { @@ -234,24 +245,27 @@ export default { }, compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen, loading: this.loading, - starred: this.fileInfo.isFavourited, - subtitle: this.subtitle, - subtitleTooltip: this.fullTime, - title: this.fileInfo.name, - titleTooltip: this.fileInfo.name, + name: this.node?.displayname ?? this.fileInfo.name, + title: this.node?.displayname ?? this.fileInfo.name, } } else if (this.error) { return { key: 'error', // force key to re-render - subtitle: '', - title: '', + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, } } // no fileInfo yet, showing empty data return { loading: this.loading, - subtitle: '', - title: '', + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, } }, @@ -282,14 +296,36 @@ export default { }, isSystemTagsEnabled() { - return OCA && 'SystemTags' in OCA + return getCapabilities()?.systemtags?.enabled === true + }, + ownerId() { + return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid + }, + currentUserIsOwner() { + return this.ownerId === this.currentUser.uid + }, + nodeOwnerLabel() { + let ownerDisplayName = this.node?.attributes?.['owner-display-name'] + if (this.currentUserIsOwner) { + ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})` + } + return ownerDisplayName + }, + sharedMultipleTimes() { + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return t('files', 'Shared multiple times with different people') + } + return null }, }, created() { + subscribe('files:node:deleted', this.onNodeDeleted) + window.addEventListener('resize', this.handleWindowResize) this.handleWindowResize() }, beforeDestroy() { + unsubscribe('file:node:deleted', this.onNodeDeleted) window.removeEventListener('resize', this.handleWindowResize) }, @@ -314,8 +350,9 @@ export default { }, getPreviewIfAny(fileInfo) { - if (fileInfo.hasPreview && !this.isFullScreen) { - return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`) + if (fileInfo?.hasPreview && !this.isFullScreen) { + const etag = fileInfo?.etag || '' + return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`) } return this.getIconUrl(fileInfo) }, @@ -328,7 +365,7 @@ export default { * @return {string} Url to the icon for mimeType */ getIconUrl(fileInfo) { - const mimeType = fileInfo.mimetype || 'application/octet-stream' + const mimeType = fileInfo?.mimetype || 'application/octet-stream' if (mimeType === 'httpd/unix-directory') { // use default folder icon if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') { @@ -338,8 +375,8 @@ export default { } else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') { return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType) } else if (fileInfo.shareTypes && ( - fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) > -1 - || fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_EMAIL) > -1) + fileInfo.shareTypes.indexOf(ShareType.Link) > -1 + || fileInfo.shareTypes.indexOf(ShareType.Email) > -1) ) { return OC.MimeType.getIconUrl('dir-public') } else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) { @@ -357,17 +394,23 @@ export default { */ setActiveTab(id) { OCA.Files.Sidebar.setActiveTab(id) + this.tabs.forEach(tab => { + try { + tab.setIsActive(id === tab.id) + } catch (error) { + logger.error('Error while setting tab active state', { error, id: tab.id, tab }) + } + }) }, /** - * Toggle favourite state + * Toggle favorite state * TODO: better implementation * - * @param {boolean} state favourited or not + * @param {boolean} state is favorite or not */ async toggleStarred(state) { try { - this.starLoading = true await axios({ method: 'PROPPATCH', url: this.davPath, @@ -381,17 +424,28 @@ export default { </d:propertyupdate>`, }) - // TODO: Obliterate as soon as possible and use events with new files app - // Terrible fallback for legacy files: toggle filelist as well - if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) { - OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList) - } + /** + * TODO: adjust this when the Sidebar is finally using File/Folder classes + * @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115 + */ + const isDir = this.fileInfo.type === 'dir' + const Node = isDir ? Folder : File + const node = new Node({ + fileid: this.fileInfo.id, + source: `${davRemoteURL}${davRootPath}${this.file}`, + root: davRootPath, + mime: isDir ? undefined : this.fileInfo.mimetype, + attributes: { + favorite: 1, + }, + }) + emit(state ? 'files:favorites:added' : 'files:favorites:removed', node) + this.fileInfo.isFavourited = state } catch (error) { - OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file')) - console.error('Unable to change favourite state', error) + showError(t('files', 'Unable to change the favorite state of the file')) + logger.error('Unable to change favorite state', { error }) } - this.starLoading = false }, onDefaultAction() { @@ -410,9 +464,10 @@ export default { * Toggle the tags selector */ toggleTags() { - if (OCA.SystemTags && OCA.SystemTags.View) { - OCA.SystemTags.View.toggle() - } + // toggle + this.showTags = !this.showTags + // save the new state + this.setShowTagsDefault(this.showTags) }, /** @@ -423,38 +478,50 @@ export default { * @throws {Error} loading failure */ async open(path) { + if (!path || path.trim() === '') { + throw new Error(`Invalid path '${path}'`) + } + + // Only focus the tab when the selected file/tab is changed in already opened sidebar + // Focusing the sidebar on first file open is handled by NcAppSidebar + const focusTabAfterLoad = !!this.Sidebar.file + // update current opened file this.Sidebar.file = path - if (path && path.trim() !== '') { - // reset data, keep old fileInfo to not reload all tabs and just hide them - this.error = null - this.loading = true + // reset data, keep old fileInfo to not reload all tabs and just hide them + this.error = null + this.loading = true - try { - this.fileInfo = await FileInfo(this.davPath) - // adding this as fallback because other apps expect it - this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') - - // DEPRECATED legacy views - // TODO: remove - this.views.forEach(view => { - view.setFileInfo(this.fileInfo) - }) - - this.$nextTick(() => { - if (this.$refs.tabs) { - this.$refs.tabs.updateTabs() - } - }) - } catch (error) { - this.error = t('files', 'Error while loading the file data') - console.error('Error while loading the file data', error) + try { + this.node = await fetchNode(this.file) + this.fileInfo = FileInfo(this.node) + // adding this as fallback because other apps expect it + this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') + + // DEPRECATED legacy views + // TODO: remove + this.views.forEach(view => { + view.setFileInfo(this.fileInfo) + }) + + await this.$nextTick() - throw new Error(error) - } finally { - this.loading = false + this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id) + + this.loading = false + + await this.$nextTick() + + if (focusTabAfterLoad && this.$refs.sidebar) { + this.$refs.sidebar.focusActiveTabContent() } + } catch (error) { + this.loading = false + this.error = t('files', 'Error while loading the file data') + console.error('Error while loading the file data', error) + + throw new Error(error) } }, @@ -463,10 +530,21 @@ export default { */ close() { this.Sidebar.file = '' + this.showTags = false this.resetData() }, /** + * Handle if the current node was deleted + * @param {import('@nextcloud/files').Node} node The deleted node + */ + onNodeDeleted(node) { + if (this.fileInfo && node && this.fileInfo.id === node.fileid) { + this.close() + } + }, + + /** * Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar * * @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen. @@ -483,6 +561,15 @@ export default { }, /** + * Allow to set whether tags should be shown by default from OCA.Files.Sidebar + * + * @param {boolean} showTagsDefault - Whether or not to show the tags by default. + */ + setShowTagsDefault(showTagsDefault) { + this.showTagsDefault = showTagsDefault + }, + + /** * Emit SideBar events. */ handleOpening() { @@ -501,11 +588,11 @@ export default { this.hasLowHeight = document.documentElement.clientHeight < 1024 }, }, -} +}) </script> <style lang="scss" scoped> .app-sidebar { - &--has-preview::v-deep { + &--has-preview:deep { .app-sidebar-header__figure { background-size: cover; } @@ -525,12 +612,40 @@ export default { height: 100% !important; } + :deep { + .app-sidebar-header__description { + margin: 0 16px 4px 16px !important; + } + } + .svg-icon { - ::v-deep svg { + :deep(svg) { width: 20px; height: 20px; fill: currentColor; } } } + +.sidebar__subname { + display: flex; + align-items: center; + gap: 0 8px; + + &-separator { + display: inline-block; + font-weight: bold !important; + } + + .user-bubble__wrapper { + display: inline-flex; + } +} + +.sidebar__description { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px 0; + } </style> diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 33b925aa2ed..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -1,30 +1,13 @@ <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcModal v-if="opened" :clear-view-delay="-1" class="templates-picker" - size="normal" + size="large" @close="close"> <form class="templates-picker__form" :style="style" @@ -34,7 +17,9 @@ <!-- Templates list --> <ul class="templates-picker__list"> <TemplatePreview v-bind="emptyTemplate" + ref="emptyTemplatePreview" :checked="checked === emptyTemplate.fileid" + @confirm-click="onConfirmClick" @check="onCheck" /> <TemplatePreview v-for="template in provider.templates" @@ -42,14 +27,12 @@ v-bind="template" :checked="checked === template.fileid" :ratio="provider.ratio" + @confirm-click="onConfirmClick" @check="onCheck" /> </ul> <!-- Cancel and submit --> <div class="templates-picker__buttons"> - <button @click="close"> - {{ t('files', 'Cancel') }} - </button> <input type="submit" class="primary" :value="t('files', 'Create')" @@ -63,21 +46,29 @@ </NcModal> </template> -<script> -import { normalize } from 'path' -import { showError } from '@nextcloud/dialogs' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' -import NcModal from '@nextcloud/vue/dist/Components/NcModal' - -import { getCurrentDirectory } from '../utils/davUtils' -import { createFromTemplate, getTemplates } from '../services/Templates' -import TemplatePreview from '../components/TemplatePreview' +<script lang="ts"> +import type { TemplateFile } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { showError, spawnDialog } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { File } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { generateRemoteUrl } from '@nextcloud/router' +import { normalize, extname, join } from 'path' +import { defineComponent } from 'vue' +import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js' + +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcModal from '@nextcloud/vue/components/NcModal' +import TemplatePreview from '../components/TemplatePreview.vue' +import TemplateFiller from '../components/TemplateFiller.vue' +import logger from '../logger.ts' const border = 2 const margin = 8 -const width = margin * 20 -export default { +export default defineComponent({ name: 'TemplatePicker', components: { @@ -87,9 +78,12 @@ export default { }, props: { - logger: { + /** + * The parent folder where to create the node + */ + parent: { type: Object, - required: true, + default: () => null, }, }, @@ -98,44 +92,57 @@ export default { // Check empty template by default checked: -1, loading: false, - name: null, + name: null as string|null, opened: false, - provider: null, + provider: null as TemplateFile|null, } }, computed: { - /** - * Strip away extension from name - * - * @return {string} - */ + extension() { + return extname(this.name ?? '') + }, + nameWithoutExt() { - return this.name.indexOf('.') > -1 - ? this.name.split('.').slice(0, -1).join('.') - : this.name + // Strip extension from name if defined + return !this.extension + ? this.name! + : this.name!.slice(0, 0 - this.extension.length) }, emptyTemplate() { return { basename: t('files', 'Blank'), fileid: -1, - filename: this.t('files', 'Blank'), + filename: t('files', 'Blank'), hasPreview: false, mime: this.provider?.mimetypes[0] || this.provider?.mimetypes, } }, selectedTemplate() { - return this.provider.templates.find(template => template.fileid === this.checked) + if (!this.provider) { + return null + } + + return this.provider.templates!.find((template) => template.fileid === this.checked) }, /** - * Style css vars bin,d + * Style css vars bind * * @return {object} */ style() { + if (!this.provider) { + return {} + } + + // Fallback to 16:9 landscape ratio + const ratio = this.provider.ratio ? this.provider.ratio : 1.77 + // Landscape templates should be wider than tall ones + // We fit 3 templates per row at max for landscape and 4 for portrait + const width = ratio > 1 ? margin * 30 : margin * 20 return { '--margin': margin + 'px', '--width': width + 'px', @@ -147,14 +154,15 @@ export default { }, methods: { + t, + /** * Open the picker * * @param {string} name the file name to create * @param {object} provider the template provider picked */ - async open(name, provider) { - + async open(name: string, provider) { this.checked = this.emptyTemplate.fileid this.name = name this.provider = provider @@ -174,6 +182,11 @@ export default { // Else, open the picker this.opened = true + + // Set initial focus to the empty template preview + this.$nextTick(() => { + this.$refs.emptyTemplatePreview?.focus() + }) }, /** @@ -190,60 +203,98 @@ export default { /** * Manages the radio template picker change * - * @param {number} fileid the selected template file id + * @param fileid the selected template file id */ - onCheck(fileid) { + onCheck(fileid: number) { this.checked = fileid }, - async onSubmit() { - this.loading = true - const currentDirectory = getCurrentDirectory() - const fileList = OCA?.Files?.App?.currentFileList + onConfirmClick(fileid: number) { + if (fileid === this.checked) { + this.onSubmit() + } + }, + + async createFile(templateFields = []) { + const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/' // If the file doesn't have an extension, add the default one if (this.nameWithoutExt === this.name) { - this.logger.debug('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) - this.name = this.name + this.provider?.extension + logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) + this.name = `${this.name}${this.provider?.extension ?? ''}` } try { const fileInfo = await createFromTemplate( normalize(`${currentDirectory}/${this.name}`), - this.selectedTemplate?.filename, - this.selectedTemplate?.templateType, + this.selectedTemplate?.filename as string ?? '', + this.selectedTemplate?.templateType as string ?? '', + templateFields, ) - this.logger.debug('Created new file', fileInfo) - - // Fetch FileInfo and model - const data = await fileList?.addAndFetchFileInfo(this.name).then((status, data) => data) - const model = new OCA.Files.FileInfoModel(data, { - filesClient: fileList?.filesClient, + logger.debug('Created new file', fileInfo) + + const owner = getCurrentUser()?.uid || null + const node = new File({ + id: fileInfo.fileid, + source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)), + root: `/files/${owner}`, + mime: fileInfo.mime, + mtime: new Date(fileInfo.lastmod * 1000), + owner, + size: fileInfo.size, + permissions: fileInfo.permissions, + attributes: { + // Inherit some attributes from parent folder like the mount type and real owner + 'mount-type': this.parent?.attributes?.['mount-type'], + 'owner-id': this.parent?.attributes?.['owner-id'], + 'owner-display-name': this.parent?.attributes?.['owner-display-name'], + ...fileInfo, + 'has-preview': fileInfo.hasPreview, + }, }) - // Run default action - const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL) - if (fileAction) { - fileAction.action(fileInfo.basename, { - $file: fileList?.findFileEl(this.name), - dir: currentDirectory, - fileList, - fileActions: fileList?.fileActions, - fileInfoModel: model, - }) - } + // Update files list + emit('files:node:created', node) + + // Open the new file + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: node.fileid }, + { dir: node.dirname, openfile: 'true' }, + ) + // Close the picker this.close() } catch (error) { - this.logger.error('Error while creating the new file from template') - console.error(error) - showError(this.t('files', 'Unable to create new file from template')) + logger.error('Error while creating the new file from template', { error }) + showError(t('files', 'Unable to create new file from template')) } finally { this.loading = false } }, + + async onSubmit() { + const fileId = this.selectedTemplate?.fileid + + // Only request field extraction if there is a valid template + // selected and it's not the blank template + let fields = [] + if (fileId && fileId !== this.emptyTemplate.fileid) { + fields = await getTemplateFields(fileId) + } + + if (fields.length > 0) { + spawnDialog(TemplateFiller, { + fields, + onSubmit: this.createFile, + }) + } else { + this.loading = true + await this.createFile() + } + }, }, -} +}) </script> <style lang="scss" scoped> @@ -275,11 +326,11 @@ export default { &__buttons { display: flex; - justify-content: space-between; + justify-content: end; padding: calc(var(--margin) * 2) var(--margin); position: sticky; bottom: 0; - background-image: linear-gradient(0, var(--gradient-main-background)); + background-image: linear-gradient(0deg, var(--gradient-main-background)); button, input[type='submit'] { height: 44px; @@ -287,14 +338,14 @@ export default { } // Make sure we're relative for the loading emptycontent on top - ::v-deep .modal-container { + :deep(.modal-container) { position: relative; } &__loading { position: absolute; top: 0; - left: 0; + inset-inline-start: 0; justify-content: center; width: 100%; height: 100%; diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts new file mode 100644 index 00000000000..f793eb9f54c --- /dev/null +++ b/apps/files/src/views/favorites.spec.ts @@ -0,0 +1,261 @@ +/* eslint-disable import/no-named-as-default-member */ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Folder as CFolder, Navigation } from '@nextcloud/files' + +import * as filesUtils from '@nextcloud/files' +import * as filesDavUtils from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' +import { basename } from 'path' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import * as eventBus from '@nextcloud/event-bus' + +import { action } from '../actions/favoriteAction' +import * as favoritesService from '../services/Favorites' +import { registerFavoritesView } from './favorites' + +// eslint-disable-next-line import/namespace +const { Folder, getNavigation } = filesUtils + +vi.mock('@nextcloud/axios') + +window.OC = { + ...window.OC, + TAG_FAVORITE: '_$!<Favorite>!$_', +} + +declare global { + interface Window { + _nc_navigation?: Navigation + } +} + +describe('Favorites view definition', () => { + let Navigation + beforeEach(() => { + vi.resetAllMocks() + + delete window._nc_navigation + Navigation = getNavigation() + expect(window._nc_navigation).toBeDefined() + }) + + test('Default empty favorite view', async () => { + vi.spyOn(eventBus, 'subscribe') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + expect(eventBus.subscribe).toHaveBeenCalledTimes(3) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything()) + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + expect(favoritesView?.id).toBe('favorites') + expect(favoritesView?.name).toBe('Favorites') + expect(favoritesView?.caption).toBeDefined() + expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/) + expect(favoritesView?.order).toBe(15) + expect(favoritesView?.columns).toStrictEqual([]) + expect(favoritesView?.getContents).toBeDefined() + }) + + test('Default with favorites', async () => { + const favoriteFolders = [ + new Folder({ + id: 1, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo', + owner: 'admin', + }), + new Folder({ + id: 2, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/bar', + owner: 'admin', + }), + new Folder({ + id: 3, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar', + owner: 'admin', + }), + new Folder({ + id: 4, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar/yabadaba', + owner: 'admin', + }), + ] + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders)) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and 3 children + expect(Navigation.views.length).toBe(5) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(4) + + // Sorted by basename: bar, bar, foo + const expectedOrder = [2, 0, 1, 3] + + favoriteFolders.forEach((folder, index) => { + const favoriteView = favoriteFoldersViews[index] + expect(favoriteView).toBeDefined() + expect(favoriteView?.id).toBeDefined() + expect(favoriteView?.name).toBe(basename(folder.path)) + expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/) + expect(favoriteView?.order).toBe(expectedOrder[index]) + expect(favoriteView?.params).toStrictEqual({ + dir: folder.path, + fileid: String(folder.fileid), + view: 'favorites', + }) + expect(favoriteView?.parent).toBe('favorites') + expect(favoriteView?.columns).toStrictEqual([]) + expect(favoriteView?.getContents).toBeDefined() + }) + }) +}) + +describe('Dynamic update of favorite folders', () => { + let Navigation + beforeEach(() => { + vi.restoreAllMocks() + + delete window._nc_navigation + Navigation = getNavigation() + }) + + test('Add a favorite folder creates a new entry in the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder) + }) + + test('Remove a favorite folder remove the entry from the navigation column', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([ + new Folder({ + id: 42, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }), + ])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + let favoritesView = Navigation.views.find(view => view.id === 'favorites') + let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(2) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(1) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + root: '/files/admin', + attributes: { + favorite: 1, + }, + }) + + const fo = vi.fn() + eventBus.subscribe('files:favorites:removed', fo) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder) + expect(fo).toHaveBeenCalled() + + favoritesView = Navigation.views.find(view => view.id === 'favorites') + favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + }) + + test('Renaming a favorite folder updates the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // expect(eventBus.emit).toHaveBeenCalledTimes(2) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder) + + // Create a folder with the same id but renamed + const renamedFolder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed', + owner: 'admin', + }) + + // Exec the rename action + eventBus.emit('files:node:renamed', renamedFolder) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder) + }) +}) diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts new file mode 100644 index 00000000000..cac776507ef --- /dev/null +++ b/apps/files/src/views/favorites.ts @@ -0,0 +1,183 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Folder, Node } from '@nextcloud/files' + +import { FileType, View, getNavigation } from '@nextcloud/files' +import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { getFavoriteNodes } from '@nextcloud/files/dav' +import { subscribe } from '@nextcloud/event-bus' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import StarSvg from '@mdi/svg/svg/star-outline.svg?raw' + +import { client } from '../services/WebdavClient.ts' +import { getContents } from '../services/Favorites' +import { hashCode } from '../utils/hashUtils' +import logger from '../logger' + +const generateFavoriteFolderView = function(folder: Folder, index = 0): View { + return new View({ + id: generateIdFromPath(folder.path), + name: folder.displayname, + + icon: FolderSvg, + order: index, + + params: { + dir: folder.path, + fileid: String(folder.fileid), + view: 'favorites', + }, + + parent: 'favorites', + + columns: [], + + getContents, + }) +} + +const generateIdFromPath = function(path: string): string { + return `favorite-${hashCode(path)}` +} + +export const registerFavoritesView = async () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: 'favorites', + name: t('files', 'Favorites'), + caption: t('files', 'List of favorite files and folders.'), + + emptyTitle: t('files', 'No favorites yet'), + emptyCaption: t('files', 'Files and folders you mark as favorite will show up here'), + + icon: StarSvg, + order: 15, + + columns: [], + + getContents, + })) + + const favoriteFolders = (await getFavoriteNodes(client)).filter(node => node.type === FileType.Folder) as Folder[] + const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[] + logger.debug('Generating favorites view', { favoriteFolders }) + favoriteFoldersViews.forEach(view => Navigation.register(view)) + + /** + * Update favorites navigation when a new folder is added + */ + subscribe('files:favorites:added', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + addToFavorites(node as Folder) + }) + + /** + * Remove favorites navigation when a folder is removed + */ + subscribe('files:favorites:removed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + removePathFromFavorites(node.path) + }) + + /** + * Update favorites navigation when a folder is renamed + */ + subscribe('files:node:renamed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + if (node.attributes.favorite !== 1) { + return + } + + updateNodeFromFavorites(node as Folder) + }) + + /** + * Sort the favorites paths array and + * update the order property of the existing views + */ + const updateAndSortViews = function() { + favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' })) + favoriteFolders.forEach((folder, index) => { + const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path)) + if (view) { + view.order = index + } + }) + } + + // Add a folder to the favorites paths array and update the views + const addToFavorites = function(node: Folder) { + const view = generateFavoriteFolderView(node) + + // Skip if already exists + if (favoriteFolders.find((folder) => folder.path === node.path)) { + return + } + + // Update arrays + favoriteFolders.push(node) + favoriteFoldersViews.push(view) + + // Update and sort views + updateAndSortViews() + Navigation.register(view) + } + + // Remove a folder from the favorites paths array and update the views + const removePathFromFavorites = function(path: string) { + const id = generateIdFromPath(path) + const index = favoriteFolders.findIndex((folder) => folder.path === path) + + // Skip if not exists + if (index === -1) { + return + } + + // Update arrays + favoriteFolders.splice(index, 1) + favoriteFoldersViews.splice(index, 1) + + // Update and sort views + Navigation.remove(id) + updateAndSortViews() + } + + // Update a folder from the favorites paths array and update the views + const updateNodeFromFavorites = function(node: Folder) { + const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid) + + // Skip if it does not exists + if (favoriteFolder === undefined) { + return + } + + removePathFromFavorites(favoriteFolder.path) + addToFavorites(node) + } + + updateAndSortViews() +} diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts new file mode 100644 index 00000000000..a94aab0f14b --- /dev/null +++ b/apps/files/src/views/files.ts @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit, subscribe } from '@nextcloud/event-bus' +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Files.ts' +import { useActiveStore } from '../store/active.ts' +import { defaultView } from '../utils/filesViews.ts' + +import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw' + +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { + // we cache the query to allow more performant search (see below in event listener) + let oldQuery = '' + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'All files'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderSvg, + // if this is the default view we set it at the top of the list - otherwise below it + order: defaultView() === VIEW_ID ? 0 : 5, + + getContents, + })) + + // when the search is updated + // and we are in the files view + // and there is already a folder fetched + // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered + subscribe('files:search:updated', ({ scope, query }) => { + if (scope === 'globally') { + return + } + + if (Navigation.active?.id !== VIEW_ID) { + return + } + + // If neither the old query nor the new query is longer than the search minimum + // then we do not need to trigger a new PROPFIND / SEARCH + // so we skip unneccessary requests here + if (oldQuery.length < 3 && query.length < 3) { + return + } + + const store = useActiveStore() + if (!store.activeFolder) { + return + } + + oldQuery = query + emit('files:node:updated', store.activeFolder) + }) +} diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts new file mode 100644 index 00000000000..2ce4e501e6f --- /dev/null +++ b/apps/files/src/views/folderTree.ts @@ -0,0 +1,176 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { TreeNode } from '../services/FolderTree.ts' + +import PQueue from 'p-queue' +import { FileType, Folder, Node, View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { emit, subscribe } from '@nextcloud/event-bus' +import { isSamePath } from '@nextcloud/paths' +import { loadState } from '@nextcloud/initial-state' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' + +import { + folderTreeId, + getContents, + getFolderTreeNodes, + getSourceParent, + sourceRoot, +} from '../services/FolderTree.ts' + +const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree + +let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden + +const Navigation = getNavigation() + +const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerTreeChildren = async (path: string = '/') => { + await queue.add(async () => { + const nodes = await getFolderTreeNodes(path) + const promises = nodes.map(node => registerQueue.add(() => registerNodeView(node))) + await Promise.allSettled(promises) + }) +} + +const getLoadChildViews = (node: TreeNode | Folder) => { + return async (view: View): Promise<void> => { + // @ts-expect-error Custom property on View instance + if (view.loading || view.loaded) { + return + } + // @ts-expect-error Custom property + view.loading = true + await registerTreeChildren(node.path) + // @ts-expect-error Custom property + view.loading = false + // @ts-expect-error Custom property + view.loaded = true + // @ts-expect-error No payload + emit('files:navigation:updated') + // @ts-expect-error No payload + emit('files:folder-tree:expanded') + } +} + +const registerNodeView = (node: TreeNode | Folder) => { + const registeredView = Navigation.views.find(view => view.id === node.encodedSource) + if (registeredView) { + Navigation.remove(registeredView.id) + } + if (!showHiddenFiles && node.basename.startsWith('.')) { + return + } + Navigation.register(new View({ + id: node.encodedSource, + parent: getSourceParent(node.source), + + // @ts-expect-error Casing differences + name: node.displayName ?? node.displayname ?? node.basename, + + icon: FolderSvg, + + getContents, + loadChildViews: getLoadChildViews(node), + + params: { + view: folderTreeId, + fileid: String(node.fileid), // Needed for matching exact routes + dir: node.path, + }, + })) +} + +const removeFolderView = (folder: Folder) => { + const viewId = folder.encodedSource + Navigation.remove(viewId) +} + +const removeFolderViewSource = (source: string) => { + Navigation.remove(source) +} + +const onCreateNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + registerNodeView(node) +} + +const onDeleteNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderView(node) +} + +const onMoveNode = ({ node, oldSource }) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderViewSource(oldSource) + registerNodeView(node) + + const newPath = node.source.replace(sourceRoot, '') + const oldPath = oldSource.replace(sourceRoot, '') + const childViews = Navigation.views.filter(view => { + if (!view.params?.dir) { + return false + } + if (isSamePath(view.params.dir, oldPath)) { + return false + } + return view.params.dir.startsWith(oldPath) + }) + for (const view of childViews) { + // @ts-expect-error FIXME Allow setting parent + view.parent = getSourceParent(node.source) + // @ts-expect-error dir param is defined + view.params.dir = view.params.dir.replace(oldPath, newPath) + } +} + +const onUserConfigUpdated = async ({ key, value }) => { + if (key === 'show_hidden') { + showHiddenFiles = value + await registerTreeChildren() + // @ts-expect-error No payload + emit('files:folder-tree:initialized') + } +} + +const registerTreeRoot = () => { + Navigation.register(new View({ + id: folderTreeId, + + name: t('files', 'Folder tree'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderMultipleSvg, + order: 50, // Below all other views + + getContents, + })) +} + +export const registerFolderTreeView = async () => { + if (!isFolderTreeEnabled) { + return + } + registerTreeRoot() + await registerTreeChildren() + subscribe('files:node:created', onCreateNode) + subscribe('files:node:deleted', onDeleteNode) + subscribe('files:node:moved', onMoveNode) + subscribe('files:config:updated', onUserConfigUpdated) + // @ts-expect-error No payload + emit('files:folder-tree:initialized') +} diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts new file mode 100644 index 00000000000..241582057d1 --- /dev/null +++ b/apps/files/src/views/personal-files.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' +import { View, getNavigation } from '@nextcloud/files' +import { getContents } from '../services/PersonalFiles.ts' +import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts' + +import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw' + +export const VIEW_ID = 'personal' + +/** + * Register the personal files view if allowed + */ +export function registerPersonalFilesView(): void { + if (!hasPersonalFilesView()) { + return + } + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Personal files'), + caption: t('files', 'List of your files and folders that are not shared.'), + + emptyTitle: t('files', 'No personal files found'), + emptyCaption: t('files', 'Files that are not shared will show up here.'), + + icon: AccountIcon, + // if this is the default view we set it at the top of the list - otherwise default position of fifth + order: defaultView() === VIEW_ID ? 0 : 5, + + getContents, + })) +} diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts new file mode 100644 index 00000000000..fda1d99e13d --- /dev/null +++ b/apps/files/src/views/recent.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import HistorySvg from '@mdi/svg/svg/history.svg?raw' + +import { getContents } from '../services/Recent' + +export default () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: 'recent', + name: t('files', 'Recent'), + caption: t('files', 'List of recently modified files and folders.'), + + emptyTitle: t('files', 'No recently modified files'), + emptyCaption: t('files', 'Files and folders you recently modified will show up here.'), + + icon: HistorySvg, + order: 10, + + defaultSortKey: 'mtime', + + getContents, + })) +} diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts new file mode 100644 index 00000000000..a30f732163c --- /dev/null +++ b/apps/files/src/views/search.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance' + +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Search.ts' +import { VIEW_ID as FILES_VIEW_ID } from './files.ts' +import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw' +import Vue from 'vue' + +export const VIEW_ID = 'search' + +/** + * Register the search-in-files view + */ +export function registerSearchView() { + let instance: Vue + let view: ComponentPublicInstanceConstructor + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Search'), + caption: t('files', 'Search results within your files.'), + + async emptyView(el) { + if (!view) { + view = (await import('./SearchEmptyView.vue')).default + } else { + instance.$destroy() + } + instance = new Vue(view) + instance.$mount(el) + }, + + icon: MagnifySvg, + order: 10, + + parent: FILES_VIEW_ID, + // it should be shown expanded + expanded: true, + // this view is hidden by default and only shown when active + hidden: true, + + getContents, + })) +} |