diff options
Diffstat (limited to 'apps/files/src')
52 files changed, 1757 insertions, 459 deletions
diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts index e732270d4c0..3ccd15fa2d2 100644 --- a/apps/files/src/actions/openInFilesAction.spec.ts +++ b/apps/files/src/actions/openInFilesAction.spec.ts @@ -19,7 +19,7 @@ const recentView = { describe('Open in files action conditions tests', () => { test('Default values', () => { expect(action).toBeInstanceOf(FileAction) - expect(action.id).toBe('open-in-files-recent') + expect(action.id).toBe('open-in-files') expect(action.displayName([], recentView)).toBe('Open in Files') expect(action.iconSvgInline([], recentView)).toBe('') expect(action.default).toBe(DefaultType.HIDDEN) diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts index 10e19e7eace..9e10b1ac74e 100644 --- a/apps/files/src/actions/openInFilesAction.ts +++ b/apps/files/src/actions/openInFilesAction.ts @@ -2,19 +2,21 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files' -/** - * TODO: Move away from a redirect and handle - * navigation straight out of the recent view - */ +import type { Node } from '@nextcloud/files' + +import { t } from '@nextcloud/l10n' +import { FileType, FileAction, DefaultType } from '@nextcloud/files' +import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search' + export const action = new FileAction({ - id: 'open-in-files-recent', + id: 'open-in-files', displayName: () => t('files', 'Open in Files'), iconSvgInline: () => '', - enabled: (nodes, view) => view.id === 'recent', + enabled(nodes, view) { + return view.id === 'recent' || view.id === SEARCH_VIEW_ID + }, async exec(node: Node) { let dir = node.dirname diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index a80cf0cbeed..986b304210c 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -13,71 +13,6 @@ import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import IconWeb from '@mdi/svg/svg/web.svg?raw' import { isPublicShare } from '@nextcloud/sharing/public' -const confirmLocalEditDialog = ( - localEditCallback: (openingLocally: boolean) => void = () => {}, -) => { - let callbackCalled = false - - return (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: () => { - callbackCalled = true - localEditCallback(true) - }, - }, - { - label: t('files', 'Open online'), - icon: IconWeb, - type: 'primary', - callback: () => { - callbackCalled = true - localEditCallback(false) - }, - }, - ]) - .build() - .show() - .then(() => { - // Ensure the callback is called even if the dialog is dismissed in other ways - if (!callbackCalled) { - localEditCallback(false) - } - }) -} - -const attemptOpenLocalClient = async (path: string) => { - openLocalClient(path) - confirmLocalEditDialog( - (openLocally: boolean) => { - if (!openLocally) { - window.OCA.Viewer.open({ path }) - return - } - openLocalClient(path) - }, - ) -} - -const openLocalClient = async function(path: string) { - const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' - - try { - const result = await axios.post(link, { path }) - const uid = getCurrentUser()?.uid - let url = `nc://open/${uid}@` + window.location.host + encodePath(path) - url += '?token=' + result.data.ocs.data.token - - window.open(url, '_self') - } catch (error) { - showError(t('files', 'Failed to redirect to client')) - } -} - export const action = new FileAction({ id: 'edit-locally', displayName: () => t('files', 'Open locally'), @@ -99,9 +34,81 @@ export const action = new FileAction({ }, async exec(node: Node) { - attemptOpenLocalClient(node.path) + await attemptOpenLocalClient(node.path) return null }, order: 25, }) + +/** + * Try to open the path in the Nextcloud client. + * + * If this fails a dialog is shown with 3 options: + * 1. Retry: If it fails no further dialog is shown. + * 2. Open online: The viewer is used to open the file. + * 3. Close the dialog and nothing happens (abort). + * + * @param path - The path to open + */ +async function attemptOpenLocalClient(path: string) { + await openLocalClient(path) + const result = await confirmLocalEditDialog() + if (result === 'local') { + await openLocalClient(path) + } else if (result === 'online') { + window.OCA.Viewer.open({ path }) + } +} + +/** + * Try to open a file in the Nextcloud client. + * There is no way to get notified if this action was successfull. + * + * @param path - Path to open + */ +async function openLocalClient(path: string): Promise<void> { + const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' + + try { + const result = await axios.post(link, { path }) + const uid = getCurrentUser()?.uid + let url = `nc://open/${uid}@` + window.location.host + encodePath(path) + url += '?token=' + result.data.ocs.data.token + + window.open(url, '_self') + } catch (error) { + showError(t('files', 'Failed to redirect to client')) + } +} + +/** + * Open the confirmation dialog. + */ +async function confirmLocalEditDialog(): Promise<'online'|'local'|false> { + let result: 'online'|'local'|false = false + const dialog = (new DialogBuilder()) + .setName(t('files', 'Open file locally')) + .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) + .setButtons([ + { + label: t('files', 'Retry and close'), + type: 'secondary', + callback: () => { + result = 'local' + }, + }, + { + label: t('files', 'Open online'), + icon: IconWeb, + type: 'primary', + callback: () => { + result = 'online' + }, + }, + ]) + .build() + + await dialog.show() + return result +} diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts index 954eca5820f..1f9c9209d41 100644 --- a/apps/files/src/actions/renameAction.spec.ts +++ b/apps/files/src/actions/renameAction.spec.ts @@ -3,15 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { action } from './renameAction' -import { File, Permission, View, FileAction } from '@nextcloud/files' +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' import * as eventBus from '@nextcloud/event-bus' -import { describe, expect, test, vi } from 'vitest' +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) @@ -26,7 +34,7 @@ describe('Rename action conditions tests', () => { describe('Rename action enabled tests', () => { test('Enabled for node with UPDATE permission', () => { const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', @@ -39,7 +47,7 @@ describe('Rename action enabled tests', () => { test('Disabled for node without DELETE permission', () => { const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', @@ -54,13 +62,13 @@ describe('Rename action enabled tests', () => { window.OCA = { Files: { Sidebar: {} } } const file1 = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', owner: 'admin', mime: 'text/plain', }) const file2 = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', owner: 'admin', mime: 'text/plain', @@ -76,7 +84,7 @@ describe('Rename action exec tests', () => { vi.spyOn(eventBus, 'emit') const file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts index e0ea784c291..d421d18c473 100644 --- a/apps/files/src/actions/renameAction.ts +++ b/apps/files/src/actions/renameAction.ts @@ -6,6 +6,9 @@ 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.svg?raw' +import { getPinia } from '../store' +import { useFilesStore } from '../store/files' +import { dirname } from 'path' export const ACTION_RENAME = 'rename' @@ -18,12 +21,23 @@ export const action = new FileAction({ if (nodes.length === 0) { return false } + // Disable for single file shares if (view.id === 'public-file-share') { return false } - // Only enable if all nodes have the delete permission - return nodes.every((node) => Boolean(node.permissions & Permission.DELETE)) + + const node = nodes[0] + const filesStore = useFilesStore(getPinia()) + const parentNode = node.dirname === '/' + ? filesStore.getRoot(view.id) + : filesStore.getNode(dirname(node.source)) + const parentPermissions = parentNode?.permissions || Permission.NONE + + // Only enable if the node have the delete permission + // and if the parent folder allows creating files + return Boolean(node.permissions & Permission.DELETE) + && Boolean(parentPermissions & Permission.CREATE) }, async exec(node: Node) { diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 9642c4709d8..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -49,6 +49,15 @@ :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" @@ -85,9 +94,10 @@ </template> <script lang="ts"> -import { formatFileSize } from '@nextcloud/files' +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' @@ -123,6 +133,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, @@ -186,6 +200,36 @@ export default defineComponent({ 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) { diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index c130ab49c0a..ec111a1235d 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -281,7 +281,7 @@ export default defineComponent({ } // Make sure we set the node as active - this.activeStore.setActiveNode(this.source) + this.activeStore.activeNode = this.source // Execute the action await executeAction(action) diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 2d5844f851f..506677b49af 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -21,6 +21,7 @@ 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" @@ -147,6 +148,17 @@ export default defineComponent({ 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 diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index 589073e7b9a..735490c45b3 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -356,7 +356,7 @@ export default defineComponent({ // 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 || Boolean(event.button & 4) + 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)) { @@ -368,7 +368,9 @@ export default defineComponent({ : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() event.stopPropagation() - window.open(url, metaKeyPressed ? '_self' : undefined) + + // 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 } 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/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index cc8dafe344e..31458398028 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -12,6 +12,10 @@ 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 @@ -34,6 +38,14 @@ export default { 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 @@ -44,15 +56,45 @@ export default { if (!enabled) { return } - this.header.updated(this.currentFolder, this.currentView) + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) }, - currentFolder() { - this.header.updated(this.currentFolder, this.currentView) + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) }, }, + mounted() { - console.debug('Mounted', this.header.id) - this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, }, } </script> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 63d692c100d..9e8cdc159ee 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -21,6 +21,10 @@ <!-- Actions --> <td class="files-list__row-actions" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + <!-- Size --> <td v-if="isSizeAvailable" class="files-list__column files-list__row-size"> @@ -60,6 +64,10 @@ export default defineComponent({ type: View, required: true, }, + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 341d5e1347d..23e631199eb 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -24,6 +24,14 @@ <!-- Actions --> <th class="files-list__row-actions" /> + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + <!-- Size --> <th v-if="isSizeAvailable" class="files-list__column files-list__row-size" @@ -83,6 +91,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 4d2f2f361e6..53b7e7ef21b 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -6,6 +6,7 @@ <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" @@ -123,6 +124,8 @@ export default defineComponent({ const fileListWidth = useFileListWidth() const { directory } = useRouteParameters() + const boundariesElement = document.getElementById('app-content-vue') + return { directory, fileListWidth, @@ -130,6 +133,8 @@ export default defineComponent({ actionsMenuStore, filesStore, selectionStore, + + boundariesElement, } }, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 93f567f25a4..04acbd302f5 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -9,6 +9,7 @@ :data-sources="nodes" :grid-mode="userConfig.grid_view" :extra-props="{ + isMimeAvailable, isMtimeAvailable, isSizeAvailable, nodes, @@ -20,7 +21,9 @@ </template> <template v-if="!isNoneSelected" #header-overlay> - <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> <FilesListTableHeaderActions :current-view="currentView" :selected-nodes="selectedNodes" /> </template> @@ -39,15 +42,22 @@ <!-- 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" @@ -60,12 +70,11 @@ import type { UserConfig } from '../types' import type { Node as NcNode } from '@nextcloud/files' import type { ComponentPublicInstance, PropType } from 'vue' -import type { Location } from 'vue-router' import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' +import { n, t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' @@ -76,6 +85,7 @@ 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' @@ -85,7 +95,6 @@ import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import VirtualList from './VirtualList.vue' -import logger from '../logger.ts' export default defineComponent({ name: 'FilesListVirtual', @@ -137,6 +146,7 @@ export default defineComponent({ selectionStore, userConfigStore, + n, t, } }, @@ -146,7 +156,6 @@ export default defineComponent({ FileEntry, FileEntryGrid, scrollToIndex: 0, - openFileId: null as number|null, } }, @@ -155,6 +164,16 @@ export default defineComponent({ 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) { @@ -201,39 +220,26 @@ export default defineComponent({ isNoneSelected() { return this.selectedNodes.length === 0 }, + + isEmpty() { + return this.nodes.length === 0 + }, }, watch: { - fileId: { - handler(fileId) { - this.scrollToFile(fileId, false) - }, - immediate: true, + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() }, - - openFile: { - handler(openFile) { - if (!openFile || !this.fileId) { - return - } - - this.handleOpenFile(this.fileId) - }, - immediate: true, + fileId() { + this.handleOpenQueries() }, - - openDetails: { - handler(openDetails) { - // wait for scrolling and updating the actions to settle - this.$nextTick(() => { - if (!openDetails || !this.fileId) { - return - } - - this.openSidebarForFile(this.fileId) - }) - }, - immediate: true, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() }, }, @@ -263,6 +269,33 @@ export default defineComponent({ }, 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. @@ -272,7 +305,7 @@ export default defineComponent({ sidebarAction.exec(node, this.currentView, this.currentFolder.path) return } - logger.error(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) + logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) }, scrollToFile(fileId: number|null, warn = true) { @@ -288,6 +321,7 @@ export default defineComponent({ } this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) } }, @@ -299,7 +333,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.clearActiveNode() + this.activeStore.activeNode = undefined window.OCP.Files.Router.goToRoute( null, { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, @@ -352,15 +386,13 @@ export default defineComponent({ } // 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 - const query = this.$route.query - delete query.openfile - query.opendetails = '' - logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node }) - await this.$router.replace({ - ...(this.$route as Location), - query, - }) + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + { ...this.$route.query, openfile: undefined, opendetails: '' }, + true, // silent update of the URL + ) }, onDragOver(event: DragEvent) { @@ -433,7 +465,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.setActiveNode(node) + this.activeStore.activeNode = node // Silent update of the URL window.OCP.Files.Router.goToRoute( @@ -458,6 +490,8 @@ export default defineComponent({ --icon-preview-size: 32px; --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; @@ -505,6 +539,13 @@ export default defineComponent({ // 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 { @@ -536,6 +577,7 @@ export default defineComponent({ 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, @@ -554,6 +596,16 @@ export default defineComponent({ 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; @@ -829,10 +881,12 @@ export default defineComponent({ margin-inline-end: 7px; } + .files-list__row-mime, .files-list__row-mtime, .files-list__row-size { color: var(--color-text-maxcontrast); } + .files-list__row-size { width: calc(var(--row-height) * 1.5); // Right align content/text @@ -843,6 +897,10 @@ export default defineComponent({ width: calc(var(--row-height) * 2); } + .files-list__row-mime { + width: calc(var(--row-height) * 2.5); + } + .files-list__row-column-custom { width: calc(var(--row-height) * 2); } diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue index 372a83e1441..c29bc00c67f 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -89,7 +89,7 @@ export default defineComponent({ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) } - return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids + return this.filterVisible(this.views[this.parent.id] ?? []) }, style() { @@ -103,11 +103,15 @@ export default defineComponent({ }, 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.views[view.id]?.length > 0 + return this.filterVisible(this.views[view.id] ?? []).length > 0 }, /** diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..e34d4bf0971 --- /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 globally by filename …') + } + return t('files', 'Search here by filename …') +}) +</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', 'Filter and search from this location') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search globally') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index f1d2738e81d..fd10af1c495 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -58,7 +58,7 @@ export default { computed: { storageStatsTitle() { const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) - const quotaByte = formatFileSize(this.storageStats?.quota, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 5ae8220d594..4f9d8096580 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -20,7 +20,18 @@ <slot name="header-overlay" /> </div> - <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -309,7 +320,7 @@ export default defineComponent({ methods: { scrollTo(index: number) { - if (!this.$el) { + if (!this.$el || this.index === index) { return } 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/useFilenameFilter.ts b/apps/files/src/composables/useFilenameFilter.ts deleted file mode 100644 index 54c16f35384..00000000000 --- a/apps/files/src/composables/useFilenameFilter.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files' -import { watchThrottled } from '@vueuse/core' -import { onMounted, onUnmounted, ref } from 'vue' -import { FilenameFilter } from '../filters/FilenameFilter' - -/** - * This is for the `Navigation` component to provide a filename filter - */ -export function useFilenameFilter() { - const searchQuery = ref('') - const filenameFilter = new FilenameFilter() - - /** - * Updating the search query ref from the filter - * @param event The update:query event - */ - function updateQuery(event: CustomEvent) { - if (event.type === 'update:query') { - searchQuery.value = event.detail - event.stopPropagation() - } - } - - onMounted(() => { - filenameFilter.addEventListener('update:query', updateQuery) - registerFileListFilter(filenameFilter) - }) - onUnmounted(() => { - filenameFilter.removeEventListener('update:query', updateQuery) - unregisterFileListFilter(filenameFilter.id) - }) - - // Update the query on the filter, but throttle to max. every 800ms - // This will debounce the filter refresh - watchThrottled(searchQuery, () => { - filenameFilter.updateQuery(searchQuery.value) - }, { throttle: 800 }) - - return { - searchQuery, - } -} diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index fb61b4a6d03..ab8dbb63dfc 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -2,7 +2,9 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileListFilter, Node } from '@nextcloud/files' + +import type { IFileListFilter, Node, View } from '@nextcloud/files' +import type { SearchScope } from './types' declare module '@nextcloud/event-bus' { export interface NextcloudEvents { @@ -13,8 +15,13 @@ declare module '@nextcloud/event-bus' { '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 @@ -22,8 +29,7 @@ declare module '@nextcloud/event-bus' { 'files:node:renamed': Node 'files:node:moved': { node: Node, oldSource: string } - 'files:filter:added': IFileListFilter - 'files:filter:removed': string + 'files:search:updated': { query: string, scope: SearchScope } } } diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts index 5019ca42d83..f86269ccd99 100644 --- a/apps/files/src/filters/FilenameFilter.ts +++ b/apps/files/src/filters/FilenameFilter.ts @@ -4,17 +4,33 @@ */ import type { IFileListFilterChip, INode } from '@nextcloud/files' -import { FileListFilter } 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 */ -export class FilenameFilter extends FileListFilter { +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[] { @@ -45,10 +61,14 @@ export class FilenameFilter extends FileListFilter { 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) - // Emit the new query as it might have come not from the Navigation - this.dispatchTypedEvent('update:query', new CustomEvent('update:query', { detail: query })) } } 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/init.ts b/apps/files/src/init.ts index 492ffbb1915..74eca0969b4 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -25,14 +25,18 @@ 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 { 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() @@ -56,8 +60,9 @@ registerTemplateEntries() if (isPublicShare() === false) { registerFavoritesView() registerFilesView() - registerRecentView() registerPersonalFilesView() + registerRecentView() + registerSearchView() registerFolderTreeView() } @@ -65,6 +70,8 @@ if (isPublicShare() === false) { registerHiddenFilesFilter() registerTypeFilter() registerModifiedFilter() +registerFilenameFilter() +registerFilterToSearchToggle() // Register preview service worker registerPreviewServiceWorker() diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts index 13e74c26451..fccb4a0a2b2 100644 --- a/apps/files/src/router/router.ts +++ b/apps/files/src/router/router.ts @@ -3,11 +3,17 @@ * 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 logger from '../logger' + +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) @@ -52,7 +58,7 @@ const router = new Router({ { path: '/', // Pretending we're using the default view - redirect: { name: 'filelist', params: { view: 'files' } }, + redirect: { name: 'filelist', params: { view: defaultView() } }, }, { path: '/:view/:fileid(\\d+)?', @@ -68,4 +74,72 @@ const router = new Router({ }, }) +// Handle aborted navigation (NavigationGuards) gracefully +router.onError((error) => { + if (isNavigationFailure(error, NavigationFailureType.aborted)) { + logger.debug('Navigation was aboorted', { error }) + } else { + throw error + } +}) + +// If navigating back from a folder to a parent folder, +// we need to keep the current dir fileid so it's highlighted +// and scrolled into view. +router.beforeResolve((to, from, next) => { + if (to.params?.parentIntercept) { + delete to.params.parentIntercept + return next() + } + + if (to.params.view !== from.params.view) { + // skip if different views + return next() + } + + const fromDir = (from.query?.dir || '/') as string + const toDir = (to.query?.dir || '/') as string + + // We are going back to a parent directory + if (relative(fromDir, toDir) === '..') { + const { getNode } = useFilesStore() + const { getPath } = usePathsStore() + + if (!from.params.view) { + logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir }) + return next() + } + + // Get the previous parent's file id + const fromSource = getPath(from.params.view, fromDir) + if (!fromSource) { + logger.error('No source found for the parent directory', { fromDir, toDir }) + return next() + } + + const fileId = getNode(fromSource)?.fileid + if (!fileId) { + logger.error('No fileid found for the parent directory', { fromDir, toDir, fromSource }) + return next() + } + + logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId }) + return next({ + name: 'filelist', + query: to.query, + params: { + ...to.params, + fileid: String(fileId), + // Prevents the beforeEach from being called again + parentIntercept: 'true', + }, + // Replace the current history entry + replace: true, + }) + } + + // else, we just continue + next() +}) + export default router diff --git a/apps/files/src/services/FileInfo.ts b/apps/files/src/services/FileInfo.ts index 18629845cca..318236f1677 100644 --- a/apps/files/src/services/FileInfo.ts +++ b/apps/files/src/services/FileInfo.ts @@ -24,6 +24,7 @@ export default function(node: Node) { 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 diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index f02b48f64f3..080ce91e538 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -2,25 +2,55 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ContentsWithRoot, File, Folder } from '@nextcloud/files' +import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed } from 'webdav' -import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files' +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): File | Folder => davResultToNode(stat) +export const resultToNode = (stat: FileStat): Node => davResultToNode(stat) -export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => { - path = join(davRootPath, path) +/** + * Get contents implementation for the files view. + * This also allows to fetch local search results when the user is currently filtering. + * + * @param path - The path to query + */ +export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> { const controller = new AbortController() - const propfindPayload = davGetDefaultPropfind() + const searchStore = useSearchStore(getPinia()) + + 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()) @@ -56,3 +86,25 @@ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => } }) } + +/** + * Get the local search results for the current folder. + * + * @param path - The path + * @param query - The current search query + * @param signal - The aboort signal + */ +async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> { + const filesStore = useFilesStore(getPinia()) + let folder = filesStore.getDirectoryByPath('files', path) + if (!folder) { + const rootPath = join(defaultRootPath, path) + const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat> + folder = resultToNode(stat.data) as Folder + } + const contents = await searchNodes(query, { dir: path, signal }) + return { + folder, + contents, + } +} diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts index c732c728ce5..92430c8e6ad 100644 --- a/apps/files/src/services/HotKeysService.spec.ts +++ b/apps/files/src/services/HotKeysService.spec.ts @@ -2,13 +2,14 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { File, Permission, View } from '@nextcloud/files' +import { File, Folder, Permission, View } from '@nextcloud/files' import { describe, it, vi, expect, beforeEach, beforeAll, afterEach } from 'vitest' import { 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' @@ -49,18 +50,23 @@ describe('HotKeysService testing', () => { // Make sure the file is reset before each test file = new File({ - id: 1, + id: 2, source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', owner: 'admin', mime: 'text/plain', permissions: Permission.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.onChangedView(view) - activeStore.setActiveNode(file) + activeStore.activeView = view + activeStore.activeNode = file window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } + // We only mock what needed, we do not need Files.Router.goTo or Files.Navigation window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } } initialState = document.createElement('input') @@ -73,26 +79,26 @@ describe('HotKeysService testing', () => { }) it('Pressing d should open the sidebar once', () => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD' })) + dispatchEvent({ key: 'd', code: 'KeyD' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', metaKey: true })) + 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', () => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2' })) + dispatchEvent({ key: 'F2', code: 'F2' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', metaKey: true })) + 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() @@ -100,29 +106,29 @@ describe('HotKeysService testing', () => { it('Pressing s should toggle favorite', () => { vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS' })) + dispatchEvent({ key: 's', code: 'KeyS' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true })) + 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 mocking private field + // @ts-expect-error unit testing vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete' })) + dispatchEvent({ key: 'Delete', code: 'Delete' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true })) + 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() @@ -132,7 +138,7 @@ describe('HotKeysService testing', () => { expect(goToRouteMock).toHaveBeenCalledTimes(0) window.OCP.Files.Router.query = { dir: '/foo/bar' } - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', altKey: true })) + dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true }) expect(goToRouteMock).toHaveBeenCalledOnce() expect(goToRouteMock.mock.calls[0][2].dir).toBe('/foo') @@ -145,9 +151,7 @@ describe('HotKeysService testing', () => { userConfigStore.userConfig.grid_view = false expect(userConfigStore.userConfig.grid_view).toBe(false) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV' })) - await nextTick() - + dispatchEvent({ key: 'v', code: 'KeyV' }) expect(userConfigStore.userConfig.grid_view).toBe(true) }) @@ -164,9 +168,19 @@ describe('HotKeysService testing', () => { userConfigStore.userConfig.grid_view = false expect(userConfigStore.userConfig.grid_view).toBe(false) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true })) + 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/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/Templates.js b/apps/files/src/services/Templates.js index 3a0a0fdb809..d7f25846ceb 100644 --- a/apps/files/src/services/Templates.js +++ b/apps/files/src/services/Templates.js @@ -11,6 +11,11 @@ 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 * 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/store/active.ts b/apps/files/src/store/active.ts index e261e817f3d..1303a157b08 100644 --- a/apps/files/src/store/active.ts +++ b/apps/files/src/store/active.ts @@ -3,74 +3,84 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ActiveStore } from '../types.ts' -import type { FileAction, Node, View } from '@nextcloud/files' +import type { FileAction, View, Node, Folder } from '@nextcloud/files' -import { defineStore } from 'pinia' -import { getNavigation } 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 = function(...args) { - const store = defineStore('active', { - state: () => ({ - _initialized: false, - activeNode: null, - activeView: null, - activeAction: null, - } as ActiveStore), +export const useActiveStore = defineStore('active', () => { + /** + * The currently active action + */ + const activeAction = ref<FileAction>() - actions: { - setActiveNode(node: Node) { - if (!node) { - throw new Error('Use clearActiveNode to clear the active node') - } - logger.debug('Setting active node', { node }) - this.activeNode = node - }, + /** + * The currently active folder + */ + const activeFolder = ref<Folder>() - clearActiveNode() { - this.activeNode = null - }, + /** + * The current active node within the folder + */ + const activeNode = ref<Node>() - onDeletedNode(node: Node) { - if (this.activeNode && this.activeNode.source === node.source) { - this.clearActiveNode() - } - }, + /** + * The current active view + */ + const activeView = ref<View>() - setActiveAction(action: FileAction) { - this.activeAction = action - }, + initialize() - clearActiveAction() { - this.activeAction = null - }, + /** + * 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 + } + } - onChangedView(view: View|null = null) { - logger.debug('Setting active view', { view }) - this.activeView = view - this.clearActiveNode() - }, - }, - }) + /** + * 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 + } - const activeStore = store(...args) - const navigation = getNavigation() + /** + * Initalize the store - connect all event listeners. + * @private + */ + function initialize() { + const navigation = getNavigation() - // Make sure we only register the listeners once - if (!activeStore._initialized) { - subscribe('files:node:deleted', activeStore.onDeletedNode) + // Make sure we only register the listeners once + subscribe('files:node:deleted', onDeletedNode) - activeStore._initialized = true - activeStore.onChangedView(navigation.active) + onChangedView(navigation.active) // Or you can react to changes of the current active view navigation.addEventListener('updateActive', (event) => { - activeStore.onChangedView(event.detail) + onChangedView(event.detail) }) } - return activeStore -} + return { + activeAction, + activeFolder, + activeNode, + activeView, + } +}) diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 295704c880b..0bcf4ce9350 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -54,13 +54,13 @@ export const useFilesStore = function(...args) { actions: { /** - * Get cached child nodes within a given path + * Get cached directory matching 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 + * @param service - The service (files view) + * @param path - The path relative within the service + * @return The folder if found */ - getNodesByPath(service: string, path?: string): Node[] { + getDirectoryByPath(service: string, path?: string): Folder | undefined { const pathsStore = usePathsStore() let folder: Folder | undefined @@ -74,6 +74,19 @@ export const useFilesStore = function(...args) { } } + 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)) @@ -141,7 +154,7 @@ export const useFilesStore = function(...args) { } // If we have only one node with the file ID, we can update it directly - if (node.source === nodes[0].source) { + if (nodes.length === 1 && node.source === nodes[0].source) { this.updateNodes([node]) return } diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts index 2ac9e06ba16..fc61be3bd3b 100644 --- a/apps/files/src/store/renaming.ts +++ b/apps/files/src/store/renaming.ts @@ -14,6 +14,7 @@ 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', () => { /** @@ -48,7 +49,7 @@ export const useRenamingStore = defineStore('renaming', () => { } isRenaming.value = true - const node = renamingNode.value + let node = renamingNode.value Vue.set(node, 'status', NodeStatus.LOADING) const userConfig = useUserConfigStore() @@ -86,6 +87,13 @@ export const useRenamingStore = defineStore('renaming', () => { }, }) + // 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) 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/userconfig.ts b/apps/files/src/store/userconfig.ts index d84a5e0d935..a901ab9c593 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -12,11 +12,13 @@ import { ref, set } from 'vue' import axios from '@nextcloud/axios' const initialUserConfig = loadState<UserConfig>('files', 'config', { - show_hidden: false, crop_image_previews: true, + default_view: 'files', + grid_view: false, + show_hidden: false, + show_mime_column: true, sort_favorites_first: true, sort_folders_first: true, - grid_view: false, show_dialog_file_extension: true, }) diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index 4bf8a557f49..d2d1fe41648 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -50,15 +50,18 @@ export interface PathOptions { // User config store export interface UserConfig { - [key: string]: boolean|undefined + [key: string]: boolean | string | undefined + crop_image_previews: boolean + default_view: 'files' | 'personal' + grid_view: boolean show_dialog_file_extension: boolean, show_hidden: boolean - crop_image_previews: boolean + show_mime_column: boolean sort_favorites_first: boolean sort_folders_first: boolean - grid_view: boolean } + export interface UserConfigStore { userConfig: UserConfig } @@ -104,12 +107,17 @@ export interface DragAndDropStore { // Active node store export interface ActiveStore { - _initialized: boolean + activeAction: FileAction|null + activeFolder: Folder|null activeNode: Node|null activeView: View|null - activeAction: FileAction|null } +/** + * Search scope for the in-files-search + */ +export type SearchScope = 'filter'|'globally' + export interface TemplateFile { app: string label: string diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts index 730a1149229..f6d43727c29 100644 --- a/apps/files/src/utils/actionUtils.ts +++ b/apps/files/src/utils/actionUtils.ts @@ -49,7 +49,7 @@ export const executeAction = async (action: FileAction) => { try { // Set the loading marker Vue.set(currentNode, 'status', NodeStatus.LOADING) - activeStore.setActiveAction(action) + activeStore.activeAction = action const success = await action.exec(currentNode, currentView, currentDir) @@ -69,6 +69,6 @@ export const executeAction = async (action: FileAction) => { } finally { // Reset the loading marker Vue.set(currentNode, 'status', undefined) - activeStore.clearActiveAction() + activeStore.activeAction = undefined } } diff --git a/apps/files/src/utils/fileUtils.ts b/apps/files/src/utils/fileUtils.ts index 421b7d02376..f0b974be21d 100644 --- a/apps/files/src/utils/fileUtils.ts +++ b/apps/files/src/utils/fileUtils.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { FileType, type Node } from '@nextcloud/files' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { n } from '@nextcloud/l10n' /** * Extract dir and name from file path * - * @param {string} path the full path - * @return {string[]} [dirPath, fileName] + * @param path - The full path + * @return [dirPath, fileName] */ -export const extractFilePaths = function(path) { +export function extractFilePaths(path: string): [string, string] { const pathSections = path.split('/') const fileName = pathSections[pathSections.length - 1] const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') @@ -20,32 +20,28 @@ export const extractFilePaths = function(path) { /** * Generate a translated summary of an array of nodes - * @param {Node[]} nodes the nodes to summarize - * @param {number} hidden the number of hidden nodes - * @return {string} + * + * @param nodes - The nodes to summarize + * @param hidden - The number of hidden nodes */ -export const getSummaryFor = (nodes: Node[], hidden = 0): string => { +export function getSummaryFor(nodes: Node[], hidden = 0): string { const fileCount = nodes.filter(node => node.type === FileType.File).length const folderCount = nodes.filter(node => node.type === FileType.Folder).length - let summary = '' - - if (fileCount === 0) { - summary = n('files', '{folderCount} folder', '{folderCount} folders', folderCount, { folderCount }) - } else if (folderCount === 0) { - summary = n('files', '{fileCount} file', '{fileCount} files', fileCount, { fileCount }) - } else if (fileCount === 1) { - summary = n('files', '1 file and {folderCount} folder', '1 file and {folderCount} folders', folderCount, { folderCount }) - } else if (folderCount === 1) { - summary = n('files', '{fileCount} file and 1 folder', '{fileCount} files and 1 folder', fileCount, { fileCount }) - } else { - summary = t('files', '{fileCount} files and {folderCount} folders', { fileCount, folderCount }) + 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 a summary of files and folders, where {hiddenFilesAndFolders} is the number of hidden files and folders - summary += ' ' + n('files', '(%n hidden)', ' (%n hidden)', hidden) + // TRANSLATORS: This is the number of hidden files or folders + const hiddenSummary = n('files', '%n hidden', '%n hidden', hidden) + summary.push(hiddenSummary) } - return summary + return summary.join(' · ') } diff --git a/apps/files/src/utils/filesViews.spec.ts b/apps/files/src/utils/filesViews.spec.ts new file mode 100644 index 00000000000..e8c2ab3a6c1 --- /dev/null +++ b/apps/files/src/utils/filesViews.spec.ts @@ -0,0 +1,75 @@ +/** + * 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(() => { + document.querySelectorAll('input[type="hidden"]').forEach((el) => { + el.remove() + }) + }) + + 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() + }) +} + +/** + * 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/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 0aa3da144c2..15a7f93ddf0 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -73,93 +73,99 @@ <!-- Drag and drop notice --> <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> - <!-- Initial loading --> - <NcLoadingIcon v-if="loading && !isRefreshing" + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" class="files-list__loading-icon" :size="38" :name="t('files', 'Loading current folder')" /> - <!-- Empty content placeholder --> - <template v-else-if="!loading && isEmptyDir && currentFolder && currentView"> - <div class="files-list__before"> - <!-- Headers --> - <FilesListHeader v-for="header in headers" - :key="header.id" - :current-folder="currentFolder" - :current-view="currentView" - :header="header" /> - </div> - <!-- Empty due to error --> - <NcEmptyContent v-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> - - <!-- File list --> + <!-- File list - always mounted --> <FilesListVirtual v-else ref="filesListVirtual" :current-folder="currentFolder" :current-view="currentView" :nodes="dirContentsSorted" - :summary="summary" /> + :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, Folder, INode } from '@nextcloud/files' +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' import type { CancelablePromise } from 'cancelable-promise' import type { ComponentPublicInstance } from 'vue' import type { Route } from 'vue-router' import type { UserConfig } from '../types.ts' +import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' import { translate as t } from '@nextcloud/l10n' -import { join, dirname, normalize } from 'path' +import { 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' @@ -178,22 +184,22 @@ import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { getSummaryFor } from '../utils/fileUtils.ts' -import { humanizeWebDAVError } from '../utils/davUtils.ts' -import { useFileListHeaders } from '../composables/useFileListHeaders.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 { useNavigation } from '../composables/useNavigation.ts' import { usePathsStore } from '../store/paths.ts' -import { useRouteParameters } from '../composables/useRouteParameters.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 FilesListHeader from '../components/FilesListHeader.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' import filesSortingMixin from '../mixins/filesSorting.ts' import logger from '../logger.ts' @@ -206,7 +212,6 @@ export default defineComponent({ components: { BreadCrumbs, DragAndDropNotice, - FilesListHeader, FilesListVirtual, LinkIcon, ListViewIcon, @@ -239,6 +244,8 @@ export default defineComponent({ const { currentView } = useNavigation() const { directory, fileId } = useRouteParameters() const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() const filesStore = useFilesStore() const filtersStore = useFiltersStore() const pathsStore = usePathsStore() @@ -255,9 +262,9 @@ export default defineComponent({ directory, fileId, fileListWidth, - headers: useFileListHeaders(), t, + activeStore, filesStore, filtersStore, pathsStore, @@ -320,21 +327,23 @@ export default defineComponent({ /** * The current folder. */ - currentFolder(): Folder | undefined { - if (!this.currentView?.id) { - return - } - - if (this.directory === '/') { - return this.filesStore.getRoot(this.currentView.id) - } + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) - const source = this.pathsStore.getPath(this.currentView.id, this.directory) - if (source === undefined) { - return + if (!this.currentView?.id) { + return dummyFolder } - return this.filesStore.getNode(source) as Folder + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder }, dirContents(): Node[] { @@ -346,7 +355,7 @@ export default defineComponent({ /** * The current directory contents. */ - dirContentsSorted() { + dirContentsSorted(): INode[] { if (!this.currentView) { return [] } @@ -360,12 +369,28 @@ export default defineComponent({ return this.isAscSorting ? results : results.reverse() } - return sortNodes(this.dirContentsFiltered, { + 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 }, /** @@ -479,16 +504,13 @@ export default defineComponent({ const hidden = this.dirContents.length - this.dirContentsFiltered.length return getSummaryFor(this.dirContentsFiltered, hidden) }, - }, - watch: { - /** - * Update the window title to match the page heading - */ - pageHeading() { - document.title = `${this.pageHeading} - ${getCapabilities().theming?.productName ?? 'Nextcloud'}` + 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 @@ -503,6 +525,10 @@ export default defineComponent({ } }, + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + currentView(newView, oldView) { if (newView?.id === oldView?.id) { return @@ -547,14 +573,16 @@ export default defineComponent({ // 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()) + 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()) { + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { showError(t('files', 'The file could not be found')) } } @@ -564,9 +592,17 @@ export default defineComponent({ 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 @@ -574,10 +610,21 @@ export default defineComponent({ const currentView = this.currentView if (!currentView) { - logger.debug('The current view doesn\'t exists or is not ready.', { currentView }) + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) return } + logger.debug('Fetching contents for directory', { dir, currentView }) + // If we have a cancellable promise ongoing, cancel it if (this.promise && 'cancel' in this.promise) { this.promise.cancel() diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index a88878e2d3a..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -10,7 +10,8 @@ import NavigationView from './Navigation.vue' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' -import router from '../router/router' +import router from '../router/router.ts' +import RouterService from '../services/RouterService' const resetNavigation = () => { const nav = getNavigation() @@ -27,9 +28,18 @@ const createView = (id: string, name: string, parent?: string) => new View({ parent, }) +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} + describe('Navigation renders', () => { - before(() => { + 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, @@ -41,6 +51,7 @@ describe('Navigation renders', () => { it('renders', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -60,6 +71,7 @@ describe('Navigation API', () => { before(async () => { delete window._nc_navigation Navigation = getNavigation() + mockWindow() await router.replace({ name: 'filelist', params: { view: 'files' } }) }) @@ -152,14 +164,18 @@ describe('Navigation API', () => { }) describe('Quota rendering', () => { - before(() => { + before(async () => { delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) afterEach(() => cy.unmockInitialState()) it('Unknown quota', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -174,9 +190,11 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 1024 * 1024 * 1024, quota: -1, + total: 50 * 1024 * 1024 * 1024, }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -193,10 +211,12 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 1024 * 1024 * 1024, quota: 5 * 1024 * 1024 * 1024, + total: 5 * 1024 * 1024 * 1024, relative: 20, // percent }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -215,10 +235,12 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 5 * 1024 * 1024 * 1024, quota: 1024 * 1024 * 1024, + total: 1024 * 1024 * 1024, relative: 500, // percent }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 3147268f34d..c424a0d74b8 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -7,7 +7,7 @@ class="files-navigation" :aria-label="t('files', 'Files')"> <template #search> - <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter file names …')" /> + <FilesNavigationSearch /> </template> <template #default> <NcAppNavigationList class="files-navigation__list" @@ -39,24 +39,24 @@ </template> <script lang="ts"> -import { getNavigation, type View } from '@nextcloud/files' +import type { View } from '@nextcloud/files' import type { ViewConfig } from '../types.ts' -import { defineComponent } from 'vue' import { emit, subscribe } from '@nextcloud/event-bus' -import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' import IconCog from 'vue-material-design-icons/Cog.vue' import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' -import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' 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 { useFilenameFilter } from '../composables/useFilenameFilter' import { useFiltersStore } from '../store/filters.ts' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.ts' @@ -75,12 +75,12 @@ export default defineComponent({ components: { IconCog, FilesNavigationItem, + FilesNavigationSearch, NavigationQuota, NcAppNavigation, NcAppNavigationItem, NcAppNavigationList, - NcAppNavigationSearch, SettingsModal, }, @@ -88,11 +88,9 @@ export default defineComponent({ const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() const { currentView, views } = useNavigation() - const { searchQuery } = useFilenameFilter() return { currentView, - searchQuery, t, views, 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 872b8a8e6d3..1aee8d0ae79 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -9,6 +9,27 @@ @update:open="onClose"> <!-- Settings API--> <NcAppSettingsSection id="settings" :name="t('files', 'Files settings')"> + <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)"> @@ -24,17 +45,16 @@ @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column" + :checked="userConfig.show_mime_column" + @update:checked="setConfig('show_mime_column', $event)"> + {{ t('files', 'Show file type column') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews" :checked="userConfig.crop_image_previews" @update:checked="setConfig('crop_image_previews', $event)"> {{ t('files', 'Crop image previews') }} </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch v-if="enableGridView" - data-cy-files-settings-setting="grid_view" - :checked="userConfig.grid_view" - @update:checked="setConfig('grid_view', $event)"> - {{ t('files', 'Enable the grid view') }} - </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" :checked="userConfig.folder_tree" @update:checked="setConfig('folder_tree', $event)"> @@ -375,6 +395,12 @@ export default { </script> <style lang="scss" scoped> +.files-settings { + &__default-view { + margin-bottom: 0.5rem; + } +} + .setting-link:hover { text-decoration: underline; } diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 15286a220e9..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -57,7 +57,7 @@ import { translate as t } from '@nextcloud/l10n' import { generateRemoteUrl } from '@nextcloud/router' import { normalize, extname, join } from 'path' import { defineComponent } from 'vue' -import { createFromTemplate, getTemplates } from '../services/Templates.js' +import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcModal from '@nextcloud/vue/components/NcModal' @@ -215,7 +215,7 @@ export default defineComponent({ } }, - async createFile(templateFields) { + 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 @@ -274,9 +274,18 @@ export default defineComponent({ }, async onSubmit() { - if (this.selectedTemplate?.fields?.length > 0) { + 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: this.selectedTemplate.fields, + fields, onSubmit: this.createFile, }) } else { diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts index a49a13f91e1..95450f0d71a 100644 --- a/apps/files/src/views/files.ts +++ b/apps/files/src/views/files.ts @@ -2,22 +2,64 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import { getContents } from '../services/Files' +import { emit, subscribe } from '@nextcloud/event-bus' import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Files.ts' +import { useActiveStore } from '../store/active.ts' +import { defaultView } from '../utils/filesViews.ts' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' + +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { + // we cache the query to allow more performant search (see below in event listener) + let oldQuery = '' -export default () => { const Navigation = getNavigation() Navigation.register(new View({ - id: 'files', + id: VIEW_ID, name: t('files', 'All files'), caption: t('files', 'List of your files and folders.'), icon: FolderSvg, - order: 0, + // if this is the default view we set it at the top of the list - otherwise below it + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) + + // when the search is updated + // and we are in the files view + // and there is already a folder fetched + // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered + subscribe('files:search:updated', ({ scope, query }) => { + if (scope === 'globally') { + return + } + + if (Navigation.active?.id !== VIEW_ID) { + return + } + + // If neither the old query nor the new query is longer than the search minimum + // then we do not need to trigger a new PROPFIND / SEARCH + // so we skip unneccessary requests here + if (oldQuery.length < 3 && query.length < 3) { + return + } + + const store = useActiveStore() + if (!store.activeFolder) { + return + } + + oldQuery = query + emit('files:node:updated', store.activeFolder) + }) } diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts index 66d4e77b376..36888eb7ee0 100644 --- a/apps/files/src/views/personal-files.ts +++ b/apps/files/src/views/personal-files.ts @@ -2,23 +2,27 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' + +import { t } from '@nextcloud/l10n' import { View, getNavigation } from '@nextcloud/files' +import { getContents } from '../services/PersonalFiles.ts' +import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts' -import { getContents } from '../services/PersonalFiles' import AccountIcon from '@mdi/svg/svg/account.svg?raw' -import { loadState } from '@nextcloud/initial-state' -export default () => { - // Don't show this view if the user has no storage quota - const storageStats = loadState('files', 'storageStats', { quota: -1 }) - if (storageStats.quota === 0) { +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: 'personal', + id: VIEW_ID, name: t('files', 'Personal files'), caption: t('files', 'List of your files and folders that are not shared.'), @@ -26,7 +30,8 @@ export default () => { emptyCaption: t('files', 'Files that are not shared will show up here.'), icon: AccountIcon, - order: 5, + // if this is the default view we set it at the top of the list - otherwise default position of fifth + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts new file mode 100644 index 00000000000..a30f732163c --- /dev/null +++ b/apps/files/src/views/search.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance' + +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Search.ts' +import { VIEW_ID as FILES_VIEW_ID } from './files.ts' +import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw' +import Vue from 'vue' + +export const VIEW_ID = 'search' + +/** + * Register the search-in-files view + */ +export function registerSearchView() { + let instance: Vue + let view: ComponentPublicInstanceConstructor + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Search'), + caption: t('files', 'Search results within your files.'), + + async emptyView(el) { + if (!view) { + view = (await import('./SearchEmptyView.vue')).default + } else { + instance.$destroy() + } + instance = new Vue(view) + instance.$mount(el) + }, + + icon: MagnifySvg, + order: 10, + + parent: FILES_VIEW_ID, + // it should be shown expanded + expanded: true, + // this view is hidden by default and only shown when active + hidden: true, + + getContents, + })) +} |