diff options
Diffstat (limited to 'apps/files/src/views')
-rw-r--r-- | apps/files/src/views/DialogConfirmFileExtension.cy.ts | 161 | ||||
-rw-r--r-- | apps/files/src/views/DialogConfirmFileExtension.vue | 92 | ||||
-rw-r--r-- | apps/files/src/views/FileReferencePickerElement.vue | 45 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 745 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.cy.ts | 139 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 288 | ||||
-rw-r--r-- | apps/files/src/views/ReferenceFileWidget.vue | 24 | ||||
-rw-r--r-- | apps/files/src/views/SearchEmptyView.vue | 53 | ||||
-rw-r--r-- | apps/files/src/views/Settings.vue | 323 | ||||
-rw-r--r-- | apps/files/src/views/Sidebar.vue | 190 | ||||
-rw-r--r-- | apps/files/src/views/TemplatePicker.vue | 79 | ||||
-rw-r--r-- | apps/files/src/views/favorites.spec.ts | 191 | ||||
-rw-r--r-- | apps/files/src/views/favorites.ts | 100 | ||||
-rw-r--r-- | apps/files/src/views/files.ts | 75 | ||||
-rw-r--r-- | apps/files/src/views/folderTree.ts | 176 | ||||
-rw-r--r-- | apps/files/src/views/personal-files.ts | 47 | ||||
-rw-r--r-- | apps/files/src/views/recent.ts | 21 | ||||
-rw-r--r-- | apps/files/src/views/search.ts | 51 |
18 files changed, 1940 insertions, 860 deletions
diff --git a/apps/files/src/views/DialogConfirmFileExtension.cy.ts b/apps/files/src/views/DialogConfirmFileExtension.cy.ts new file mode 100644 index 00000000000..460497dd91f --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.cy.ts @@ -0,0 +1,161 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createTestingPinia } from '@pinia/testing' +import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue' +import { useUserConfigStore } from '../store/userconfig' + +describe('DialogConfirmFileExtension', () => { + it('renders with both extensions', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('heading') + .should('contain.text', 'Change file extension') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .should('exist') + .and('not.be.checked') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without old extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep without extension' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without new extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Remove extension' }) + .should('be.visible') + }) + + it('emits correct value on keep old', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[false]])) + }) + + it('emits correct value on use new', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[true]])) + }) + + it('updates user config when checking the checkbox', () => { + const pinia = createTestingPinia({ + createSpy: cy.spy, + }) + + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [pinia], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .check({ force: true }) + + cy.wrap(useUserConfigStore()) + .its('update') + .should('have.been.calledWith', 'show_dialog_file_extension', false) + }) +}) diff --git a/apps/files/src/views/DialogConfirmFileExtension.vue b/apps/files/src/views/DialogConfirmFileExtension.vue new file mode 100644 index 00000000000..cc1ee363f98 --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.vue @@ -0,0 +1,92 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { IDialogButton } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { computed, ref } from 'vue' +import { useUserConfigStore } from '../store/userconfig.ts' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import svgIconCancel from '@mdi/svg/svg/cancel.svg?raw' +import svgIconCheck from '@mdi/svg/svg/check.svg?raw' + +const props = defineProps<{ + oldExtension?: string + newExtension?: string +}>() + +const emit = defineEmits<{ + (e: 'close', v: boolean): void +}>() + +const userConfigStore = useUserConfigStore() +const dontShowAgain = computed({ + get: () => !userConfigStore.userConfig.show_dialog_file_extension, + set: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value), +}) + +const buttons = computed<IDialogButton[]>(() => [ + { + label: props.oldExtension + ? t('files', 'Keep {old}', { old: props.oldExtension }) + : t('files', 'Keep without extension'), + icon: svgIconCancel, + type: 'secondary', + callback: () => closeDialog(false), + }, + { + label: props.newExtension + ? t('files', 'Use {new}', { new: props.newExtension }) + : t('files', 'Remove extension'), + icon: svgIconCheck, + type: 'primary', + callback: () => closeDialog(true), + }, +]) + +/** Open state of the dialog */ +const open = ref(true) + +/** + * Close the dialog and emit the response + * @param value User selected response + */ +function closeDialog(value: boolean) { + emit('close', value) + open.value = false +} +</script> + +<template> + <NcDialog :buttons="buttons" + :open="open" + :can-close="false" + :name="t('files', 'Change file extension')" + size="small"> + <p v-if="newExtension && oldExtension"> + {{ t('files', 'Changing the file extension from "{old}" to "{new}" may render the file unreadable.', { old: oldExtension, new: newExtension }) }} + </p> + <p v-else-if="oldExtension"> + {{ t('files', 'Removing the file extension "{old}" may render the file unreadable.', { old: oldExtension }) }} + </p> + <p v-else-if="newExtension"> + {{ t('files', 'Adding the file extension "{new}" may render the file unreadable.', { new: newExtension }) }} + </p> + + <NcCheckboxRadioSwitch v-model="dontShowAgain" + class="dialog-confirm-file-extension__checkbox" + type="checkbox"> + {{ t('files', 'Do not show this dialog again.') }} + </NcCheckboxRadioSwitch> + </NcDialog> +</template> + +<style scoped> +.dialog-confirm-file-extension__checkbox { + margin-top: 1rem; +} +</style> diff --git a/apps/files/src/views/FileReferencePickerElement.vue b/apps/files/src/views/FileReferencePickerElement.vue index a6c1522d010..b4d4bc54f14 100644 --- a/apps/files/src/views/FileReferencePickerElement.vue +++ b/apps/files/src/views/FileReferencePickerElement.vue @@ -1,23 +1,7 @@ <!-- - - @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div :id="containerId"> @@ -55,7 +39,7 @@ export default defineComponent({ }, filepickerOptions() { return { - allowPickDirectory: false, + allowPickDirectory: true, buttons: this.buttonFactory, container: `#${this.containerId}`, multiselect: false, @@ -69,18 +53,17 @@ export default defineComponent({ buttonFactory(selected: NcNode[]): IFilePickerButton[] { const buttons = [] as IFilePickerButton[] if (selected.length === 0) { - buttons.push({ - label: t('files', 'Choose file'), - type: 'tertiary' as never, - callback: this.onClose, - }) - } else { - buttons.push({ - label: t('files', 'Choose {file}', { file: selected[0].basename }), - type: 'primary', - callback: this.onClose, - }) + return [] + } + const node = selected.at(0) + if (node.path === '/') { + return [] // Do not allow selecting the users root folder } + buttons.push({ + label: t('files', 'Choose {file}', { file: node.displayname }), + type: 'primary', + callback: this.onClose, + }) return buttons }, diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index f5bb45ede1d..f9e517e92ee 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -1,32 +1,15 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppContent :page-heading="pageHeading" data-cy-files-content> - <div class="files-list__header"> + <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }"> <!-- Current folder breadcrumbs --> - <BreadCrumbs :path="dir" @reload="fetchContent"> + <BreadCrumbs :path="directory" @reload="fetchContent"> <template #actions> <!-- Sharing button --> - <NcButton v-if="canShare && filesListWidth >= 512" + <NcButton v-if="canShare && fileListWidth >= 512" :aria-label="shareButtonLabel" :class="{ 'files-list__header-share-button--shared': shareButtonType }" :title="shareButtonLabel" @@ -34,36 +17,47 @@ type="tertiary" @click="openSharingSidebar"> <template #icon> - <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" /> + <LinkIcon v-if="shareButtonType === ShareType.Link" /> <AccountPlusIcon v-else :size="20" /> </template> </NcButton> - <!-- Disabled upload button --> - <NcButton v-if="!canUpload || isQuotaExceeded" - :aria-label="cantUploadLabel" - :title="cantUploadLabel" - class="files-list__header-upload-button--disabled" - :disabled="true" - type="secondary"> - <template #icon> - <PlusIcon :size="20" /> - </template> - {{ t('files', 'New') }} - </NcButton> - <!-- Uploader --> - <UploadPicker v-else-if="currentFolder" - :content="dirContents" - :destination="currentFolder" - :multiple="true" + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple @failed="onUploadFail" @uploaded="onUpload" /> </template> </BreadCrumbs> - <NcButton v-if="filesListWidth >= 512 && enableGridView" + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + + <NcActions class="files-list__header-actions" + :inline="1" + type="tertiary" + force-name> + <NcActionButton v-for="action in enabledFileListActions" + :key="action.id" + :disabled="!!loadingAction" + :data-cy-files-list-action="action.id" + close-after-click + @click="execFileListAction(action)"> + <template #icon> + <NcLoadingIcon v-if="loadingAction === action.id" :size="18" /> + <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView" + :svg="action.iconSvgInline(currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + + <NcButton v-if="fileListWidth >= 512 && enableGridView" :aria-label="gridViewButtonLabel" :title="gridViewButtonLabel" class="files-list__header-grid-button" @@ -74,91 +68,141 @@ <ViewGridIcon v-else /> </template> </NcButton> - - <!-- Secondary loading indicator --> - <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> </div> <!-- Drag and drop notice --> - <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" /> - - <!-- Initial loading --> - <NcLoadingIcon v-if="loading && !isRefreshing" + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" class="files-list__loading-icon" :size="38" :name="t('files', 'Loading current folder')" /> - <!-- Empty content placeholder --> - <NcEmptyContent v-else-if="!loading && isEmptyDir" - :name="currentView?.emptyTitle || t('files', 'No files in here')" - :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" - data-cy-files-content-empty> - <template #action> - <NcButton v-if="dir !== '/'" - :aria-label="t('files', 'Go to the previous folder')" - type="primary" - :to="toPreviousDir"> - {{ t('files', 'Go back') }} - </NcButton> - </template> - <template #icon> - <NcIconSvgWrapper :svg="currentView.icon" /> - </template> - </NcEmptyContent> - - <!-- File list --> + <!-- File list - always mounted --> <FilesListVirtual v-else ref="filesListVirtual" :current-folder="currentFolder" :current-view="currentView" - :nodes="dirContentsSorted" /> + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> </NcAppContent> </template> <script lang="ts"> -import type { Route } from 'vue-router' +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 type { View, ContentsWithRoot } from '@nextcloud/files' -import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { Folder, Node, Permission } from '@nextcloud/files' +import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' -import { join, dirname } from 'path' -import { orderBy } from 'natural-orderby' -import { Parser } from 'xml2js' -import { showError } from '@nextcloud/dialogs' -import { translate, translatePlural } from '@nextcloud/l10n' -import { Type } from '@nextcloud/sharing' -import { UploadPicker } from '@nextcloud/upload' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import { translate as t } from '@nextcloud/l10n' +import { join, dirname, normalize, relative } from 'path' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { UploadPicker, UploadStatus } from '@nextcloud/upload' import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' import { defineComponent } from 'vue' +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconReload from 'vue-material-design-icons/Reload.vue' import LinkIcon from 'vue-material-design-icons/Link.vue' import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' -import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import PlusIcon from 'vue-material-design-icons/Plus.vue' -import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' -import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import { useUserConfigStore } from '../store/userconfig.ts' import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' -import filesListWidthMixin from '../mixins/filesListWidth.ts' import filesSortingMixin from '../mixins/filesSorting.ts' -import logger from '../logger.js' -import DragAndDropNotice from '../components/DragAndDropNotice.vue' -import debounce from 'debounce' +import logger from '../logger.ts' const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined @@ -172,23 +216,38 @@ export default defineComponent({ LinkIcon, ListViewIcon, NcAppContent, + NcActions, + NcActionButton, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, - PlusIcon, AccountPlusIcon, UploadPicker, ViewGridIcon, + IconAlertCircleOutline, + IconReload, }, mixins: [ - filesListWidthMixin, filesSortingMixin, ], + props: { + isPublic: { + type: Boolean, + default: false, + }, + }, + setup() { + const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() + const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() const filesStore = useFilesStore() + const filtersStore = useFiltersStore() const pathsStore = usePathsStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() @@ -196,142 +255,142 @@ export default defineComponent({ const viewConfigStore = useViewConfigStore() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, filesStore, + filtersStore, pathsStore, selectionStore, uploaderStore, userConfigStore, viewConfigStore, + + // non reactive data enableGridView, + forbiddenCharacters, + ShareType, } }, data() { return { - filterText: '', loading: true, - promise: null, - Type, + loadingAction: null as string | null, + error: null as string | null, + promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, - _unsubscribeStore: () => {}, + dirContentsFiltered: [] as INode[], } }, computed: { - userConfig(): UserConfig { - return this.userConfigStore.userConfig + /** + * Get a callback function for the uploader to fetch directory contents for conflict resolution + */ + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } }, - currentView(): View { - return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files')) + userConfig(): UserConfig { + return this.userConfigStore.userConfig }, pageHeading(): string { - return this.currentView?.name ?? this.t('files', 'Files') - }, + const title = this.currentView?.name ?? t('files', 'Files') - /** - * The current directory query. - */ - dir(): string { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') + if (this.currentFolder === undefined || this.directory === '/') { + return title + } + return `${this.currentFolder.displayname} - ${title}` }, /** * The current folder. */ - currentFolder(): Folder | undefined { + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) + if (!this.currentView?.id) { - return + return dummyFolder } - if (this.dir === '/') { - return this.filesStore.getRoot(this.currentView.id) - } - const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) - return this.filesStore.getNode(fileId) + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder }, - /** - * Directory content sorting parameters - * Provided by an extra computed property for caching - */ - sortingParameters() { - const identifiers = [ - // 1: Sort favorites first if enabled - ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []), - // 2: Sort folders first if sorting by name - ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []), - // 3: Use sorting mode if NOT basename (to be able to use displayName too) - ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []), - // 4: Use displayName if available, fallback to name - v => v.attributes?.displayName || v.basename, - // 5: Finally, use basename if all previous sorting methods failed - v => v.basename, - ] - const orders = [ - // (for 1): always sort favorites before normal files - ...(this.userConfig.sort_favorites_first ? ['asc'] : []), - // (for 2): always sort folders before files - ...(this.userConfig.sort_folders_first ? ['asc'] : []), - // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower - ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []), - // (also for 3 so make sure not to conflict with 2 and 3) - ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []), - // for 4: use configured sorting direction - this.isAscSorting ? 'asc' : 'desc', - // for 5: use configured sorting direction - this.isAscSorting ? 'asc' : 'desc', - ] - return [identifiers, orders] as const + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.filesStore.getNode) + .filter((node: Node) => !!node) }, /** * The current directory contents. */ - dirContentsSorted(): Node[] { + dirContentsSorted(): INode[] { if (!this.currentView) { return [] } - let filteredDirContent = [...this.dirContents] - // Filter based on the filterText obtained from nextcloud:unified-search.search event. - if (this.filterText) { - filteredDirContent = filteredDirContent.filter(node => { - return node.attributes.basename.toLowerCase().includes(this.filterText.toLowerCase()) - }) - console.debug('Files view filtered', filteredDirContent) - } - const customColumn = (this.currentView?.columns || []) .find(column => column.id === this.sortingMode) // Custom column must provide their own sorting methods if (customColumn?.sort && typeof customColumn.sort === 'function') { - const results = [...this.dirContents].sort(customColumn.sort) + const results = [...this.dirContentsFiltered].sort(customColumn.sort) return this.isAscSorting ? results : results.reverse() } - return orderBy( - filteredDirContent, - ...this.sortingParameters, - ) - }, - - dirContents(): Node[] { - const showHidden = this.userConfigStore?.userConfig.show_hidden - return (this.currentFolder?._children || []) - .map(this.getNode) - .filter(file => { - if (!showHidden) { - return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.') + const nodes = sortNodes(this.dirContentsFiltered, { + sortFavoritesFirst: this.userConfig.sort_favorites_first, + sortFoldersFirst: this.userConfig.sort_folders_first, + sortingMode: this.sortingMode, + sortingOrder: this.isAscSorting ? 'asc' : 'desc', + }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 } - - return !!file + return 1 }) + } + + return nodes }, /** @@ -356,43 +415,43 @@ export default defineComponent({ * Route to the previous directory. */ toPreviousDir(): Route { - const dir = this.dir.split('/').slice(0, -1).join('/') || '/' + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' return { ...this.$route, query: { dir } } }, - shareAttributes(): number[] | undefined { + shareTypesAttributes(): number[] | undefined { if (!this.currentFolder?.attributes?.['share-types']) { return undefined } return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] }, shareButtonLabel() { - if (!this.shareAttributes) { - return this.t('files', 'Share') + if (!this.shareTypesAttributes) { + return t('files', 'Share') } - if (this.shareButtonType === Type.SHARE_TYPE_LINK) { - return this.t('files', 'Shared by link') + if (this.shareButtonType === ShareType.Link) { + return t('files', 'Shared by link') } - return this.t('files', 'Shared') + return t('files', 'Shared') }, - shareButtonType(): Type | null { - if (!this.shareAttributes) { + shareButtonType(): ShareType | null { + if (!this.shareTypesAttributes) { return null } // If all types are links, show the link icon - if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) { - return Type.SHARE_TYPE_LINK + if (this.shareTypesAttributes.some(type => type === ShareType.Link)) { + return ShareType.Link } - return Type.SHARE_TYPE_USER + return ShareType.User }, gridViewButtonLabel() { return this.userConfig.grid_view - ? this.t('files', 'Switch to list view') - : this.t('files', 'Switch to grid view') + ? t('files', 'Switch to list view') + : t('files', 'Switch to grid view') }, /** @@ -404,23 +463,72 @@ export default defineComponent({ isQuotaExceeded() { return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 }, - cantUploadLabel() { - if (this.isQuotaExceeded) { - return this.t('files', 'Your have used your space quota and cannot upload files anymore') - } - return this.t('files', 'You don’t have permission to upload or create files here') - }, /** * Check if current folder has share permissions */ canShare() { - return isSharingEnabled + return isSharingEnabled && !this.isPublic && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 }, + + showCustomEmptyView() { + return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined + }, + + enabledFileListActions() { + if (!this.currentView || !this.currentFolder) { + return [] + } + + const actions = getFileListActions() + const enabledActions = actions + .filter(action => { + if (action.enabled === undefined) { + return true + } + return action.enabled( + this.currentView!, + this.dirContents, + this.currentFolder as Folder, + ) + }) + .toSorted((a, b) => a.order - b.order) + return enabledActions + }, + + /** + * Using the filtered content if filters are active + */ + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) + }, + + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, }, watch: { + /** + * Handle rendering the custom empty view + * @param show The current state if the custom empty view should be rendered + */ + showCustomEmptyView(show: boolean) { + if (show) { + this.$nextTick(() => { + const el = this.$refs.customEmptyView as HTMLDivElement + // We can cast here because "showCustomEmptyView" assets that current view is set + this.currentView!.emptyView!(el) + }) + } + }, + + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + currentView(newView, oldView) { if (newView?.id === oldView?.id) { return @@ -428,59 +536,97 @@ export default defineComponent({ logger.debug('View changed', { newView, oldView }) this.selectionStore.reset() - this.resetSearch() this.fetchContent() }, - dir(newDir, oldDir) { + directory(newDir, oldDir) { logger.debug('Directory changed', { newDir, oldDir }) // TODO: preserve selection on browsing? this.selectionStore.reset() - this.resetSearch() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } this.fetchContent() // Scroll to top, force virtual scroller to re-render - if (this.$refs?.filesListVirtual?.$el) { - this.$refs.filesListVirtual.$el.scrollTop = 0 + const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined + if (filesListVirtual?.$el) { + filesListVirtual.$el.scrollTop = 0 } }, dirContents(contents) { logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() }, }, - mounted() { - this.fetchContent() + async mounted() { + subscribe('files:node:deleted', this.onNodeDeleted) subscribe('files:node:updated', this.onUpdatedNode) - subscribe('nextcloud:unified-search.search', this.onSearch) - subscribe('nextcloud:unified-search.reset', this.onSearch) // reload on settings change - this._unsubscribeStore = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true }) + subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } }, unmounted() { + unsubscribe('files:node:deleted', this.onNodeDeleted) unsubscribe('files:node:updated', this.onUpdatedNode) - unsubscribe('nextcloud:unified-search.search', this.onSearch) - unsubscribe('nextcloud:unified-search.reset', this.onSearch) - this._unsubscribeStore() + 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 - const dir = this.dir + this.error = null + const dir = this.directory const currentView = this.currentView if (!currentView) { - logger.debug('The current view doesn\'t exists or is not ready.', { currentView }) + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) return } + logger.debug('Fetching contents for directory', { dir, currentView }) + // If we have a cancellable promise ongoing, cancel it - if (typeof this.promise?.cancel === 'function') { + if (this.promise && 'cancel' in this.promise) { this.promise.cancel() logger.debug('Cancelled previous ongoing fetch') } @@ -496,7 +642,7 @@ export default defineComponent({ // Define current directory children // TODO: make it more official - this.$set(folder, '_children', contents.map(node => node.fileid)) + this.$set(folder, '_children', contents.map(node => node.source)) // If we're in the root dir, define the root if (dir === '/') { @@ -505,20 +651,21 @@ export default defineComponent({ // Otherwise, add the folder to the store if (folder.fileid) { this.filesStore.updateNodes([folder]) - this.pathsStore.addPath({ service: currentView.id, fileid: folder.fileid, path: dir }) + this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir }) } else { // If we're here, the view API messed up - logger.error('Invalid root folder returned', { dir, folder, currentView }) + logger.fatal('Invalid root folder returned', { dir, folder, currentView }) } } // Update paths store const folders = contents.filter(node => node.type === 'folder') - folders.forEach(node => { - this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) }) + folders.forEach((node) => { + this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) }) }) } catch (error) { logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) } finally { this.loading = false } @@ -526,13 +673,28 @@ export default defineComponent({ }, /** - * Get a cached note from the store - * - * @param {number} fileId the file id to get - * @return {Folder|File} + * Handle the node deleted event to reset open file + * @param node The deleted node */ - getNode(fileId) { - return this.filesStore.getNode(fileId) + onNodeDeleted(node: Node) { + if (node.fileid && node.fileid === this.fileId) { + if (node.fileid === this.currentFolder?.fileid) { + // Handle the edge case that the current directory is deleted + // in this case we need to keep the current view but move to the parent directory + window.OCP.Files.Router.goToRoute( + null, + { view: this.currentView!.id }, + { dir: this.currentFolder?.dirname ?? '/' }, + ) + } else { + // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: undefined }, + { ...this.$route.query, openfile: undefined }, + ) + } + } }, /** @@ -542,8 +704,7 @@ export default defineComponent({ onUpload(upload: Upload) { // Let's only refresh the current Folder // Navigating to a different folder will refresh it anyway - const destinationSource = dirname(upload.source) - const needsRefresh = destinationSource === this.currentFolder?.source + const needsRefresh = dirname(upload.source) === this.currentFolder!.source // TODO: fetch uploaded files data only // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid @@ -556,39 +717,46 @@ export default defineComponent({ async onUploadFail(upload: Upload) { const status = upload.response?.status || 0 + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + // Check known status codes if (status === 507) { - showError(this.t('files', 'Not enough free space')) + showError(t('files', 'Not enough free space')) return } else if (status === 404 || status === 409) { - showError(this.t('files', 'Target folder does not exist any more')) + showError(t('files', 'Target folder does not exist any more')) return } else if (status === 403) { - showError(this.t('files', 'Operation is blocked by access control')) + showError(t('files', 'Operation is blocked by access control')) return } // Else we try to parse the response error message - try { - const parser = new Parser({ trim: true, explicitRoot: false }) - const response = await parser.parseStringPromise(upload.response?.data) - const message = response['s:message'][0] as string - if (typeof message === 'string' && message.trim() !== '') { - // The server message is also translated - showError(this.t('files', 'Error during upload: {message}', { message })) - return + if (typeof upload.response?.data === 'string') { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(upload.response.data, 'text/xml') + const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? '' + if (message.trim() !== '') { + // The server message is also translated + showError(t('files', 'Error during upload: {message}', { message })) + return + } + } catch (error) { + logger.error('Could not parse message', { error }) } - } catch (error) { - logger.error('Error while parsing', { error }) } // Finally, check the status code if we have one if (status !== 0) { - showError(this.t('files', 'Error during upload, status code {status}', { status })) + showError(t('files', 'Error during upload, status code {status}', { status })) return } - showError(this.t('files', 'Unknown error during upload')) + showError(t('files', 'Unknown error during upload')) }, /** @@ -601,22 +769,6 @@ export default defineComponent({ this.fetchContent() } }, - /** - * Handle search event from unified search. - * - * @param searchEvent is event object. - */ - onSearch: debounce(function(searchEvent) { - console.debug('Files app handling search event from unified search...', searchEvent) - this.filterText = searchEvent.query - }, 500), - - /** - * Reset the search query - */ - resetSearch() { - this.filterText = '' - }, openSharingSidebar() { if (!this.currentFolder) { @@ -627,19 +779,66 @@ export default defineComponent({ if (window?.OCA?.Files?.Sidebar?.setActiveTab) { window.OCA.Files.Sidebar.setActiveTab('sharing') } - sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path) + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) }, + toggleGridView() { this.userConfigStore.update('grid_view', !this.userConfig.grid_view) }, - t: translate, - n: translatePlural, + filterDirContent() { + let nodes: INode[] = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, + + actionDisplayName(action: FileListAction): string { + let displayName = action.id + try { + displayName = action.displayName(this.currentView!) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + return displayName + }, + + async execFileListAction(action: FileListAction) { + this.loadingAction = action.id + + const displayName = this.actionDisplayName(action) + try { + const success = await action.exec(this.source, this.dirContents, this.currentDir) + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + this.loadingAction = null + } + }, }, }) </script> <style scoped lang="scss"> +:global(.toast-loading-icon) { + // Reduce start margin (it was made for text but this is an icon) + margin-inline-start: -4px; + // 16px icon + 5px on both sides + min-width: 26px; +} + .app-content { // Virtual list needs to be full height and is scrollable display: flex; @@ -660,6 +859,11 @@ export default defineComponent({ margin-block: var(--app-navigation-padding, 4px); margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px); + &--public { + // There is no navigation toggle on public shares + margin-inline: 0 var(--app-navigation-padding, 4px); + } + >* { // Do not grow or shrink (horizontally) // Only the breadcrumbs shrinks @@ -673,12 +877,29 @@ export default defineComponent({ color: var(--color-main-text) !important; } } + + &-actions { + min-width: fit-content !important; + margin-inline: calc(var(--default-grid-baseline) * 2); + } + } + + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + + &__empty-view-wrapper { + display: flex; + height: 100%; } &__refresh-icon { - flex: 0 0 44px; - width: 44px; - height: 44px; + flex: 0 0 var(--default-clickable-area); + width: var(--default-clickable-area); + height: var(--default-clickable-area); } &__loading-icon { diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index 07d9eee80cb..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -1,20 +1,45 @@ -import FolderSvg from '@mdi/svg/svg/folder.svg' -import ShareSvg from '@mdi/svg/svg/share-variant.svg' +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Navigation } from '@nextcloud/files' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' import { createTestingPinia } from '@pinia/testing' import NavigationView from './Navigation.vue' -import router from '../router/router' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' -import Vue from 'vue' +import router from '../router/router.ts' +import RouterService from '../services/RouterService' + +const resetNavigation = () => { + const nav = getNavigation() + ;[...nav.views].forEach(({ id }) => nav.remove(id)) + nav.setActive(null) +} + +const createView = (id: string, name: string, parent?: string) => new View({ + id, + name, + getContents: async () => ({ folder: {} as Folder, contents: [] }), + icon: FolderSvg, + order: 1, + parent, +}) -describe('Navigation renders', () => { - delete window._nc_navigation - const Navigation = getNavigation() +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} - before(() => { - Vue.prototype.$navigation = Navigation +describe('Navigation renders', () => { + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) cy.mockInitialState('files', 'storageStats', { used: 1000 * 1000 * 1000, @@ -26,6 +51,7 @@ describe('Navigation renders', () => { it('renders', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -40,29 +66,31 @@ describe('Navigation renders', () => { }) describe('Navigation API', () => { - delete window._nc_navigation - const Navigation = getNavigation() + let Navigation: Navigation + + before(async () => { + delete window._nc_navigation + Navigation = getNavigation() + mockWindow() - before(() => { - Vue.prototype.$navigation = Navigation + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) + beforeEach(() => resetNavigation()) + it('Check API entries rendering', () => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + console.warn(Navigation.views) cy.mount(NavigationView, { + router, global: { - plugins: [createTestingPinia({ - createSpy: cy.spy, - })], + plugins: [ + createTestingPinia({ + createSpy: cy.spy, + }), + ], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -72,21 +100,16 @@ describe('Navigation API', () => { }) it('Adds a new entry and render', () => { - Navigation.register(new View({ - id: 'sharing', - name: 'Sharing', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: ShareSvg, - order: 2, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -96,22 +119,17 @@ describe('Navigation API', () => { }) it('Adds a new children, render and open menu', () => { - Navigation.register(new View({ - id: 'sharingin', - name: 'Shared with me', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - parent: 'sharing', - icon: ShareSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) + Navigation.register(createView('sharingin', 'Shared with me', 'sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.wrap(useViewConfigStore()).as('viewConfigStore') @@ -139,30 +157,25 @@ describe('Navigation API', () => { }) it('Throws when adding a duplicate entry', () => { - expect(() => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) - }).to.throw('View id files is already registered') + Navigation.register(createView('files', 'Files')) + expect(() => Navigation.register(createView('files', 'Files'))) + .to.throw('View id files is already registered') }) }) describe('Quota rendering', () => { - delete window._nc_navigation - const Navigation = getNavigation() - - before(() => { - Vue.prototype.$navigation = Navigation + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) afterEach(() => cy.unmockInitialState()) it('Unknown quota', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -177,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, @@ -196,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, @@ -209,18 +226,21 @@ describe('Quota rendering', () => { cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20') + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '20') }) it('Reached quota', () => { cy.mockInitialState('files', 'storageStats', { used: 5 * 1024 * 1024 * 1024, quota: 1024 * 1024 * 1024, + total: 1024 * 1024 * 1024, relative: 500, // percent }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -230,7 +250,8 @@ describe('Quota rendering', () => { cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100 + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '100') // progress max is 100 }) }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 020986d7c85..0f3c3647c6e 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -1,54 +1,24 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppNavigation data-cy-files-navigation + class="files-navigation" :aria-label="t('files', 'Files')"> - <template #list> - <NcAppNavigationItem v-for="view in parentViews" - :key="view.id" - :allow-collapse="true" - :data-cy-files-navigation-item="view.id" - :exact="useExactRouteMatching(view)" - :icon="view.iconClass" - :name="view.name" - :open="isExpanded(view)" - :pinned="view.sticky" - :to="generateToNavigation(view)" - @update:open="onToggleExpand(view)"> - <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> - - <!-- Child views if any --> - <NcAppNavigationItem v-for="child in childViews[view.id]" - :key="child.id" - :data-cy-files-navigation-item="child.id" - :exact-path="true" - :icon="child.iconClass" - :name="child.name" - :to="generateToNavigation(child)"> - <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" /> - </NcAppNavigationItem> - </NcAppNavigationItem> + <template #search> + <FilesNavigationSearch /> + </template> + <template #default> + <NcAppNavigationList class="files-navigation__list" + :aria-label="t('files', 'Views')"> + <FilesNavigationItem :views="viewMap" /> + </NcAppNavigationList> + + <!-- Settings modal--> + <SettingsModal :open.sync="settingsOpened" + data-cy-files-navigation-settings + @close="onSettingsClose" /> </template> <!-- Non-scrollable navigation bottom elements --> @@ -58,52 +28,73 @@ <NavigationQuota /> <!-- Files settings modal toggle--> - <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')" - :name="t('files', 'Files settings')" + <NcAppNavigationItem :name="t('files', 'Files settings')" data-cy-files-navigation-settings-button @click.prevent.stop="openSettings"> - <Cog slot="icon" :size="20" /> + <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> - - <!-- Settings modal--> - <SettingsModal :open="settingsOpened" - data-cy-files-navigation-settings - @close="onSettingsClose" /> </NcAppNavigation> </template> <script lang="ts"> import type { View } from '@nextcloud/files' +import type { ViewConfig } from '../types.ts' -import { emit } from '@nextcloud/event-bus' -import { translate } from '@nextcloud/l10n' -import Cog from 'vue-material-design-icons/Cog.vue' -import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import { emit, subscribe } from '@nextcloud/event-bus' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' -import { useViewConfigStore } from '../store/viewConfig.ts' -import logger from '../logger.js' +import IconCog from 'vue-material-design-icons/CogOutline.vue' +import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' +import FilesNavigationItem from '../components/FilesNavigationItem.vue' +import FilesNavigationSearch from '../components/FilesNavigationSearch.vue' + +import { useNavigation } from '../composables/useNavigation' +import { useFiltersStore } from '../store/filters.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import logger from '../logger.ts' -export default { +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) + +export default defineComponent({ name: 'Navigation', components: { - Cog, + IconCog, + FilesNavigationItem, + FilesNavigationSearch, + NavigationQuota, NcAppNavigation, NcAppNavigationItem, - NcIconSvgWrapper, + NcAppNavigationList, SettingsModal, }, setup() { + const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() + const { currentView, views } = useNavigation() + return { + currentView, + t, + views, + + filtersStore, viewConfigStore, } }, @@ -115,117 +106,80 @@ export default { }, computed: { + /** + * The current view ID from the route params + */ currentViewId() { return this.$route?.params?.view || 'files' }, - currentView(): View { - return this.views.find(view => view.id === this.currentViewId)! - }, - - views(): View[] { - return this.$navigation.views - }, - - parentViews(): View[] { - return this.views - // filter child views - .filter(view => !view.parent) - // sort views by order - .sort((a, b) => { - return a.order - b.order - }) - }, - - childViews(): Record<string, View[]> { + /** + * Map of parent ids to views + */ + viewMap(): Record<string, View[]> { return this.views - // filter parent views - .filter(view => !!view.parent) - // create a map of parents and their children - .reduce((list, view) => { - list[view.parent!] = [...(list[view.parent!] || []), view] - // Sort children by order - list[view.parent!].sort((a, b) => { - return a.order - b.order + .reduce((map, view) => { + map[view.parent!] = [...(map[view.parent!] || []), view] + map[view.parent!].sort((a, b) => { + if (typeof a.order === 'number' || typeof b.order === 'number') { + return (a.order ?? 0) - (b.order ?? 0) + } + return collator.compare(a.name, b.name) }) - return list + return map }, {} as Record<string, View[]>) }, }, watch: { - currentView(view, oldView) { - if (view.id !== oldView?.id) { - this.$navigation.setActive(view) - logger.debug(`Navigation changed from ${oldView.id} to ${view.id}`, { from: oldView, to: view }) - + currentViewId(newView, oldView) { + if (this.currentViewId !== this.currentView?.id) { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + // The new view as active this.showView(view) + logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view }) } }, }, + created() { + subscribe('files:folder-tree:initialized', this.loadExpandedViews) + subscribe('files:folder-tree:expanded', this.loadExpandedViews) + }, + beforeMount() { - if (this.currentView) { - logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) - this.showView(this.currentView) - } + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + this.showView(view) + logger.debug('Navigation mounted. Showing requested view', { view }) }, methods: { - /** - * Only use exact route matching on routes with child views - * Because if a view does not have children (like the files view) then multiple routes might be matched for it - * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view - * @param view The view to check - */ - useExactRouteMatching(view: View): boolean { - return this.childViews[view.id]?.length > 0 + async loadExpandedViews() { + const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>) + .filter(([, config]) => config.expanded === true) + .map(([viewId]) => this.views.find(view => view.id === viewId)) + // eslint-disable-next-line no-use-before-define + .filter(Boolean as unknown as ((u: unknown) => u is View)) + .filter((view) => view.loadChildViews && !view.loaded) + for (const view of viewsToLoad) { + await view.loadChildViews(view) + } }, + /** + * Set the view as active on the navigation and handle internal state + * @param view View to set active + */ showView(view: View) { // Closing any opened sidebar - window?.OCA?.Files?.Sidebar?.close?.() - this.$navigation.setActive(view) + window.OCA?.Files?.Sidebar?.close?.() + getNavigation().setActive(view) emit('files:navigation:changed', view) }, /** - * Expand/collapse a a view with children and permanently - * save this setting in the server. - * @param view View to toggle - */ - onToggleExpand(view: View) { - // Invert state - const isExpanded = this.isExpanded(view) - // Update the view expanded state, might not be necessary - view.expanded = !isExpanded - this.viewConfigStore.update(view.id, 'expanded', !isExpanded) - }, - - /** - * Check if a view is expanded by user config - * or fallback to the default value. - * @param view View to check if expanded - */ - isExpanded(view: View): boolean { - return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' - ? this.viewConfigStore.getConfig(view.id).expanded === true - : view.expanded === true - }, - - /** - * Generate the route to a view - * @param view View to generate "to" navigation for - */ - generateToNavigation(view: View) { - if (view.params) { - const { dir } = view.params - return { name: 'filelist', params: view.params, query: { dir } } - } - return { name: 'filelist', params: { view: view.id } } - }, - - /** * Open the settings modal */ openSettings() { @@ -238,26 +192,20 @@ export default { onSettingsClose() { this.settingsOpened = false }, - - t: translate, }, -} +}) </script> <style scoped lang="scss"> -// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in -.app-navigation::v-deep .app-navigation-entry-icon { - background-repeat: no-repeat; - background-position: center; -} - -.app-navigation::v-deep .app-navigation-entry.active .button-vue.icon-collapse:not(:hover) { - color: var(--color-primary-element-text); -} - -.app-navigation > ul.app-navigation__list { - // Use flex gap value for more elegant spacing - padding-bottom: var(--default-grid-baseline, 4px); +.app-navigation { + :deep(.app-navigation-entry.active .button-vue.icon-collapse:not(:hover)) { + color: var(--color-primary-element-text); + } + + > ul.app-navigation__list { + // Use flex gap value for more elegant spacing + padding-bottom: var(--default-grid-baseline, 4px); + } } .app-navigation-entry__settings { @@ -267,4 +215,14 @@ export default { // Prevent shrinking or growing flex: 0 0 auto; } + +.files-navigation { + &__list { + height: 100%; // Fill all available space for sticky views + } + + :deep(.app-navigation__content > ul.app-navigation__list) { + will-change: scroll-position; + } +} </style> diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue index 2b9fdfe720a..9db346ea35d 100644 --- a/apps/files/src/views/ReferenceFileWidget.vue +++ b/apps/files/src/views/ReferenceFileWidget.vue @@ -1,23 +1,7 @@ <!-- - - @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-if="!accessible" class="widget-file widget-file--no-access"> @@ -272,7 +256,7 @@ export default defineComponent({ min-width: 88px; max-width: 88px; padding: 12px; - padding-right: 0; + padding-inline-end: 0; display: flex; align-items: center; justify-content: center; diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue new file mode 100644 index 00000000000..904e1b0831d --- /dev/null +++ b/apps/files/src/views/SearchEmptyView.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnifyClose } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import debounce from 'debounce' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import { getPinia } from '../store/index.ts' +import { useSearchStore } from '../store/search.ts' + +const searchStore = useSearchStore(getPinia()) +const debouncedUpdate = debounce((value: string) => { + searchStore.query = value +}, 500) +</script> + +<template> + <NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnifyClose" /> + </template> + <template #action> + <div class="search-empty-view__wrapper"> + <NcInputField class="search-empty-view__input" + :label="t('files', 'Search for files')" + :model-value="searchStore.query" + type="search" + @update:model-value="debouncedUpdate" /> + </div> + </template> + </NcEmptyContent> +</template> + +<style scoped lang="scss"> +.search-empty-view { + &__input { + flex: 0 1; + min-width: min(400px, 50vw); + } + + &__wrapper { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + } +} +</style> diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index 6005975f034..bfac8e0b3d6 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -1,31 +1,34 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSettingsDialog :open="open" :show-navigation="true" :name="t('files', 'Files settings')" @update:open="onClose"> <!-- Settings API--> - <NcAppSettingsSection id="settings" :name="t('files', 'Files settings')"> + <NcAppSettingsSection id="settings" :name="t('files', 'General')"> + <fieldset class="files-settings__default-view" + data-cy-files-settings-setting="default_view"> + <legend> + {{ t('files', 'Default view') }} + </legend> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="files" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'All files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="personal" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'Personal files') }} + </NcCheckboxRadioSwitch> + </fieldset> <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first" :checked="userConfig.sort_favorites_first" @update:checked="setConfig('sort_favorites_first', $event)"> @@ -36,22 +39,35 @@ @update:checked="setConfig('sort_folders_first', $event)"> {{ t('files', 'Sort folders before files') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" + :checked="userConfig.folder_tree" + @update:checked="setConfig('folder_tree', $event)"> + {{ t('files', 'Folder tree') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <!-- Appearance --> + <NcAppSettingsSection id="settings" :name="t('files', 'Appearance')"> <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden" :checked="userConfig.show_hidden" @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column" + :checked="userConfig.show_mime_column" + @update:checked="setConfig('show_mime_column', $event)"> + {{ t('files', 'Show file type column') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_files_extensions" + :checked="userConfig.show_files_extensions" + @update:checked="setConfig('show_files_extensions', $event)"> + {{ t('files', 'Show file extensions') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews" :checked="userConfig.crop_image_previews" @update:checked="setConfig('crop_image_previews', $event)"> {{ t('files', 'Crop image previews') }} </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch v-if="enableGridView" - data-cy-files-settings-setting="grid_view" - :checked="userConfig.grid_view" - @update:checked="setConfig('grid_view', $event)"> - {{ t('files', 'Enable the grid view') }} - </NcCheckboxRadioSwitch> </NcAppSettingsSection> <!-- Settings API--> @@ -69,8 +85,9 @@ :label="t('files', 'WebDAV URL')" :show-trailing-button="true" :success="webdavUrlCopied" - :trailing-button-label="t('files', 'Copy to clipboard')" + :trailing-button-label="t('files', 'Copy')" :value="webdavUrl" + class="webdav-url-input" readonly="readonly" type="url" @focus="$event.target.select()" @@ -84,33 +101,205 @@ :href="webdavDocs" target="_blank" rel="noreferrer noopener"> - {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗ + {{ t('files', 'How to access files using WebDAV') }} ↗ </a> </em> <br> - <em> + <em v-if="isTwoFactorEnabled"> <a class="setting-link" :href="appPasswordUrl"> - {{ t('files', 'If you have enabled 2FA, you must create and use a new app password by clicking here.') }} ↗ + {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗ </a> </em> </NcAppSettingsSection> + + <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')"> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_file_extension" + @update:checked="setConfig('show_dialog_file_extension', $event)"> + {{ t('files', 'Warn before changing a file extension') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_deletion" + @update:checked="setConfig('show_dialog_deletion', $event)"> + {{ t('files', 'Warn before deleting files') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <NcAppSettingsSection id="shortcuts" + :name="t('files', 'Keyboard shortcuts')"> + + <h3>{{ t('files', 'Actions') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>a</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'File actions') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>F2</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Rename') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Del</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Delete') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>s</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Add or remove favorite') }} + </dd> + </div> + <div v-if="isSystemtagsEnabled"> + <dt class="shortcut-key"> + <kbd>t</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Manage tags') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Selection') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>A</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select all files') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>ESC</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Deselect all') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Space</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select or deselect') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Shift</kbd> <span>+ <kbd>Space</kbd></span> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select a range') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Navigation') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Alt</kbd> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to parent folder') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file above') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↓</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file below') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>←</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go left in grid') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>→</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go right in grid') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'View') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>V</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Toggle grid view') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>D</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Open file sidebar') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>?</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Show those shortcuts') }} + </dd> + </div> + </dl> + </NcAppSettingsSection> </NcAppSettingsDialog> </template> <script> -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import Clipboard from 'vue-material-design-icons/Clipboard.vue' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import Setting from '../components/Setting.vue' - -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' + +import Clipboard from 'vue-material-design-icons/ContentCopy.vue' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcInputField from '@nextcloud/vue/components/NcInputField' + import { useUserConfigStore } from '../store/userconfig.ts' +import Setting from '../components/Setting.vue' export default { name: 'Settings', @@ -132,8 +321,11 @@ export default { setup() { const userConfigStore = useUserConfigStore() + const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true return { + isSystemtagsEnabled, userConfigStore, + t, } }, @@ -148,6 +340,7 @@ export default { appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'), webdavUrlCopied: false, enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true), + isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)), } }, @@ -155,6 +348,24 @@ export default { userConfig() { return this.userConfigStore.userConfig }, + + sortedSettings() { + // Sort settings by name + return [...this.settings].sort((a, b) => { + if (a.order && b.order) { + return a.order - b.order + } + return a.name.localeCompare(b.name) + }) + }, + }, + + created() { + // ? opens the settings dialog on the keyboard shortcuts section + useHotKey('?', this.showKeyboardShortcuts, { + stop: true, + prevent: true, + }) }, beforeMount() { @@ -187,19 +398,47 @@ export default { await navigator.clipboard.writeText(this.webdavUrl) this.webdavUrlCopied = true - showSuccess(t('files', 'WebDAV URL copied to clipboard')) + showSuccess(t('files', 'WebDAV URL copied')) setTimeout(() => { this.webdavUrlCopied = false }, 5000) }, - t: translate, + async showKeyboardShortcuts() { + this.$emit('update:open', true) + + await this.$nextTick() + document.getElementById('settings-section_shortcuts').scrollIntoView({ + behavior: 'smooth', + inline: 'nearest', + }) + }, }, } </script> <style lang="scss" scoped> +.files-settings { + &__default-view { + margin-bottom: 0.5rem; + } +} + .setting-link:hover { text-decoration: underline; } + +.shortcut-key { + width: 160px; + // some shortcuts are too long to fit in one line + white-space: normal; + span { + // force portion of a shortcut on a new line for nicer display + white-space: nowrap; + } +} + +.webdav-url-input { + margin-block-end: 0.5rem; +} </style> diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index 398144e2414..40a16d42b42 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -1,29 +1,12 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebar v-if="file" ref="sidebar" - cy-data-sidebar + data-cy-sidebar v-bind="appSidebar" :force-menu="true" @close="close" @@ -34,12 +17,19 @@ @closing="handleClosing" @closed="handleClosed"> <template v-if="fileInfo" #subname> - <NcIconSvgWrapper v-if="fileInfo.isFavourited" - :path="mdiStar" - :name="t('files', 'Favorite')" - inline /> - {{ size }} - <NcDateTime :timestamp="fileInfo.mtime" /> + <div class="sidebar__subname"> + <NcIconSvgWrapper v-if="fileInfo.isFavourited" + :path="mdiStar" + :name="t('files', 'Favorite')" + inline /> + <span>{{ size }}</span> + <span class="sidebar__subname-separator">•</span> + <NcDateTime :timestamp="fileInfo.mtime" /> + <span class="sidebar__subname-separator">•</span> + <span>{{ t('files', 'Owner') }}</span> + <NcUserBubble :user="ownerId" + :display-name="nodeOwnerLabel" /> + </div> </template> <!-- TODO: create a standard to allow multiple elements here? --> @@ -47,8 +37,8 @@ <div class="sidebar__description"> <SystemTags v-if="isSystemTagsEnabled && showTagsDefault" v-show="showTags" - :file-id="fileInfo.id" - @has-tags="value => showTags = value" /> + :disabled="!fileInfo?.canEdit()" + :file-id="fileInfo.id" /> <LegacyView v-for="view in views" :key="view.cid" :component="view" @@ -102,32 +92,35 @@ </template> </NcAppSidebar> </template> -<script> -import { getCurrentUser } from '@nextcloud/auth' -import { getCapabilities } from '@nextcloud/capabilities' -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import { File, Folder, formatFileSize } from '@nextcloud/files' +<script lang="ts"> +import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files' +import { defineComponent } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { encodePath } from '@nextcloud/paths' -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' -import { Type as ShareTypes } from '@nextcloud/sharing' +import { fetchNode } from '../services/WebdavClient.ts' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { getCurrentUser } from '@nextcloud/auth' import { mdiStar, mdiStarOutline } from '@mdi/js' -import axios from '@nextcloud/axios' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' import $ from 'jquery' +import axios from '@nextcloud/axios' -import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' import FileInfo from '../services/FileInfo.js' import LegacyView from '../components/LegacyView.vue' import SidebarTab from '../components/SidebarTab.vue' import SystemTags from '../../../systemtags/src/components/SystemTags.vue' -import logger from '../logger.js' +import logger from '../logger.ts' -export default { +export default defineComponent({ name: 'Sidebar', components: { @@ -139,6 +132,7 @@ export default { NcIconSvgWrapper, SidebarTab, SystemTags, + NcUserBubble, }, setup() { @@ -162,6 +156,7 @@ export default { error: null, loading: true, fileInfo: null, + node: null, isFullScreen: false, hasLowHeight: false, } @@ -203,8 +198,7 @@ export default { * @return {string} */ davPath() { - const user = this.currentUser.uid - return generateRemoteUrl(`dav/files/${user}${encodePath(this.file)}`) + return `${davRemoteURL}${davRootPath}${encodePath(this.file)}` }, /** @@ -251,8 +245,8 @@ export default { }, compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen, loading: this.loading, - name: this.fileInfo.name, - title: this.fileInfo.name, + name: this.node?.displayname ?? this.fileInfo.name, + title: this.node?.displayname ?? this.fileInfo.name, } } else if (this.error) { return { @@ -304,12 +298,34 @@ export default { isSystemTagsEnabled() { return getCapabilities()?.systemtags?.enabled === true }, + ownerId() { + return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid + }, + currentUserIsOwner() { + return this.ownerId === this.currentUser.uid + }, + nodeOwnerLabel() { + let ownerDisplayName = this.node?.attributes?.['owner-display-name'] + if (this.currentUserIsOwner) { + ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})` + } + return ownerDisplayName + }, + sharedMultipleTimes() { + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return t('files', 'Shared multiple times with different people') + } + return null + }, }, created() { + subscribe('files:node:deleted', this.onNodeDeleted) + window.addEventListener('resize', this.handleWindowResize) this.handleWindowResize() }, beforeDestroy() { + unsubscribe('file:node:deleted', this.onNodeDeleted) window.removeEventListener('resize', this.handleWindowResize) }, @@ -335,7 +351,8 @@ export default { getPreviewIfAny(fileInfo) { if (fileInfo?.hasPreview && !this.isFullScreen) { - return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`) + const etag = fileInfo?.etag || '' + return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`) } return this.getIconUrl(fileInfo) }, @@ -358,8 +375,8 @@ export default { } else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') { return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType) } else if (fileInfo.shareTypes && ( - fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) > -1 - || fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_EMAIL) > -1) + fileInfo.shareTypes.indexOf(ShareType.Link) > -1 + || fileInfo.shareTypes.indexOf(ShareType.Email) > -1) ) { return OC.MimeType.getIconUrl('dir-public') } else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) { @@ -387,10 +404,10 @@ export default { }, /** - * Toggle favourite state + * Toggle favorite state * TODO: better implementation * - * @param {boolean} state favourited or not + * @param {boolean} state is favorite or not */ async toggleStarred(state) { try { @@ -413,17 +430,21 @@ export default { */ const isDir = this.fileInfo.type === 'dir' const Node = isDir ? Folder : File - emit(state ? 'files:favorites:added' : 'files:favorites:removed', new Node({ + const node = new Node({ fileid: this.fileInfo.id, - source: this.davPath, - root: `/files/${getCurrentUser().uid}`, + source: `${davRemoteURL}${davRootPath}${this.file}`, + root: davRootPath, mime: isDir ? undefined : this.fileInfo.mimetype, - })) + attributes: { + favorite: 1, + }, + }) + emit(state ? 'files:favorites:added' : 'files:favorites:removed', node) this.fileInfo.isFavourited = state } catch (error) { - showError(t('files', 'Unable to change the favourite state of the file')) - logger.error('Unable to change favourite state', { error }) + showError(t('files', 'Unable to change the favorite state of the file')) + logger.error('Unable to change favorite state', { error }) } }, @@ -443,7 +464,10 @@ export default { * Toggle the tags selector */ toggleTags() { - this.showTagsDefault = this.showTags = !this.showTags + // toggle + this.showTags = !this.showTags + // save the new state + this.setShowTagsDefault(this.showTags) }, /** @@ -470,7 +494,8 @@ export default { this.loading = true try { - this.fileInfo = await FileInfo(this.davPath) + this.node = await fetchNode(this.file) + this.fileInfo = FileInfo(this.node) // adding this as fallback because other apps expect it this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') @@ -488,7 +513,7 @@ export default { await this.$nextTick() - if (focusTabAfterLoad) { + if (focusTabAfterLoad && this.$refs.sidebar) { this.$refs.sidebar.focusActiveTabContent() } } catch (error) { @@ -510,6 +535,16 @@ export default { }, /** + * Handle if the current node was deleted + * @param {import('@nextcloud/files').Node} node The deleted node + */ + onNodeDeleted(node) { + if (this.fileInfo && node && this.fileInfo.id === node.fileid) { + this.close() + } + }, + + /** * Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar * * @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen. @@ -553,7 +588,7 @@ export default { this.hasLowHeight = document.documentElement.clientHeight < 1024 }, }, -} +}) </script> <style lang="scss" scoped> .app-sidebar { @@ -584,7 +619,7 @@ export default { } .svg-icon { - ::v-deep svg { + :deep(svg) { width: 20px; height: 20px; fill: currentColor; @@ -592,10 +627,25 @@ export default { } } -.sidebar__description { - display: flex; - flex-direction: column; - width: 100%; - gap: 8px 0; +.sidebar__subname { + display: flex; + align-items: center; + gap: 0 8px; + + &-separator { + display: inline-block; + font-weight: bold !important; + } + + .user-bubble__wrapper { + display: inline-flex; + } } + +.sidebar__description { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px 0; + } </style> diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index e235a9e6054..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcModal v-if="opened" @@ -34,7 +17,9 @@ <!-- Templates list --> <ul class="templates-picker__list"> <TemplatePreview v-bind="emptyTemplate" + ref="emptyTemplatePreview" :checked="checked === emptyTemplate.fileid" + @confirm-click="onConfirmClick" @check="onCheck" /> <TemplatePreview v-for="template in provider.templates" @@ -42,6 +27,7 @@ v-bind="template" :checked="checked === template.fileid" :ratio="provider.ratio" + @confirm-click="onConfirmClick" @check="onCheck" /> </ul> @@ -64,19 +50,20 @@ import type { TemplateFile } from '../types.ts' import { getCurrentUser } from '@nextcloud/auth' -import { showError } from '@nextcloud/dialogs' +import { showError, spawnDialog } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' import { File } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { generateRemoteUrl } from '@nextcloud/router' import { normalize, extname, join } from 'path' import { defineComponent } from 'vue' -import { createFromTemplate, getTemplates } from '../services/Templates.js' +import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcModal from '@nextcloud/vue/components/NcModal' import TemplatePreview from '../components/TemplatePreview.vue' -import logger from '../logger.js' +import TemplateFiller from '../components/TemplateFiller.vue' +import logger from '../logger.ts' const border = 2 const margin = 8 @@ -195,6 +182,11 @@ export default defineComponent({ // Else, open the picker this.opened = true + + // Set initial focus to the empty template preview + this.$nextTick(() => { + this.$refs.emptyTemplatePreview?.focus() + }) }, /** @@ -217,8 +209,13 @@ export default defineComponent({ this.checked = fileid }, - async onSubmit() { - this.loading = true + onConfirmClick(fileid: number) { + if (fileid === this.checked) { + this.onSubmit() + } + }, + + async createFile(templateFields = []) { const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/' // If the file doesn't have an extension, add the default one @@ -232,6 +229,7 @@ export default defineComponent({ normalize(`${currentDirectory}/${this.name}`), this.selectedTemplate?.filename as string ?? '', this.selectedTemplate?.templateType as string ?? '', + templateFields, ) logger.debug('Created new file', fileInfo) @@ -274,6 +272,27 @@ export default defineComponent({ this.loading = false } }, + + async onSubmit() { + const fileId = this.selectedTemplate?.fileid + + // Only request field extraction if there is a valid template + // selected and it's not the blank template + let fields = [] + if (fileId && fileId !== this.emptyTemplate.fileid) { + fields = await getTemplateFields(fileId) + } + + if (fields.length > 0) { + spawnDialog(TemplateFiller, { + fields, + onSubmit: this.createFile, + }) + } else { + this.loading = true + await this.createFile() + } + }, }, }) </script> @@ -311,7 +330,7 @@ export default defineComponent({ padding: calc(var(--margin) * 2) var(--margin); position: sticky; bottom: 0; - background-image: linear-gradient(0, var(--gradient-main-background)); + background-image: linear-gradient(0deg, var(--gradient-main-background)); button, input[type='submit'] { height: 44px; @@ -319,14 +338,14 @@ export default defineComponent({ } // Make sure we're relative for the loading emptycontent on top - ::v-deep .modal-container { + :deep(.modal-container) { position: relative; } &__loading { position: absolute; top: 0; - left: 0; + inset-inline-start: 0; justify-content: center; width: 100%; height: 100%; diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts index dbf987991cb..f793eb9f54c 100644 --- a/apps/files/src/views/favorites.spec.ts +++ b/apps/files/src/views/favorites.spec.ts @@ -1,39 +1,29 @@ +/* eslint-disable import/no-named-as-default-member */ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { Folder as CFolder, Navigation } from '@nextcloud/files' + +import * as filesUtils from '@nextcloud/files' +import * as filesDavUtils from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' import { basename } from 'path' -import { expect } from '@jest/globals' -import { Folder, Navigation, getNavigation } from '@nextcloud/files' +import { beforeEach, describe, expect, test, vi } from 'vitest' import * as eventBus from '@nextcloud/event-bus' -import * as initialState from '@nextcloud/initial-state' import { action } from '../actions/favoriteAction' import * as favoritesService from '../services/Favorites' -import registerFavoritesView from './favorites' +import { registerFavoritesView } from './favorites' + +// eslint-disable-next-line import/namespace +const { Folder, getNavigation } = filesUtils -jest.mock('webdav/dist/node/request.js', () => ({ - request: jest.fn(), -})) +vi.mock('@nextcloud/axios') -global.window.OC = { +window.OC = { + ...window.OC, TAG_FAVORITE: '_$!<Favorite>!$_', } @@ -46,25 +36,26 @@ declare global { describe('Favorites view definition', () => { let Navigation beforeEach(() => { - Navigation = getNavigation() - expect(window._nc_navigation).toBeDefined() - }) + vi.resetAllMocks() - afterEach(() => { delete window._nc_navigation + Navigation = getNavigation() + expect(window._nc_navigation).toBeDefined() }) - test('Default empty favorite view', () => { - jest.spyOn(eventBus, 'subscribe') - jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + test('Default empty favorite view', async () => { + vi.spyOn(eventBus, 'subscribe') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) - registerFavoritesView() + await registerFavoritesView() const favoritesView = Navigation.views.find(view => view.id === 'favorites') const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') - expect(eventBus.subscribe).toHaveBeenCalledTimes(2) + expect(eventBus.subscribe).toHaveBeenCalledTimes(3) expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything()) expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything()) // one main view and no children expect(Navigation.views.length).toBe(1) @@ -74,40 +65,64 @@ describe('Favorites view definition', () => { expect(favoritesView?.id).toBe('favorites') expect(favoritesView?.name).toBe('Favorites') expect(favoritesView?.caption).toBeDefined() - expect(favoritesView?.icon).toBe('<svg>SvgMock</svg>') + expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/) expect(favoritesView?.order).toBe(15) expect(favoritesView?.columns).toStrictEqual([]) expect(favoritesView?.getContents).toBeDefined() }) - test('Default with favorites', () => { + test('Default with favorites', async () => { const favoriteFolders = [ - { fileid: 1, path: '/foo' }, - { fileid: 2, path: '/bar' }, - { fileid: 3, path: '/foo/bar' }, + new Folder({ + id: 1, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo', + owner: 'admin', + }), + new Folder({ + id: 2, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/bar', + owner: 'admin', + }), + new Folder({ + id: 3, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar', + owner: 'admin', + }), + new Folder({ + id: 4, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar/yabadaba', + owner: 'admin', + }), ] - jest.spyOn(initialState, 'loadState').mockReturnValue(favoriteFolders) - jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders)) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) - registerFavoritesView() + await registerFavoritesView() const favoritesView = Navigation.views.find(view => view.id === 'favorites') const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') // one main view and 3 children - expect(Navigation.views.length).toBe(4) + expect(Navigation.views.length).toBe(5) expect(favoritesView).toBeDefined() - expect(favoriteFoldersViews.length).toBe(3) + expect(favoriteFoldersViews.length).toBe(4) + + // Sorted by basename: bar, bar, foo + const expectedOrder = [2, 0, 1, 3] favoriteFolders.forEach((folder, index) => { const favoriteView = favoriteFoldersViews[index] expect(favoriteView).toBeDefined() expect(favoriteView?.id).toBeDefined() expect(favoriteView?.name).toBe(basename(folder.path)) - expect(favoriteView?.icon).toBe('<svg>SvgMock</svg>') - expect(favoriteView?.order).toBe(index) + expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/) + expect(favoriteView?.order).toBe(expectedOrder[index]) expect(favoriteView?.params).toStrictEqual({ dir: folder.path, - fileid: folder.fileid.toString(), + fileid: String(folder.fileid), view: 'favorites', }) expect(favoriteView?.parent).toBe('favorites') @@ -117,22 +132,21 @@ describe('Favorites view definition', () => { }) }) -describe('Dynamic update of favourite folders', () => { +describe('Dynamic update of favorite folders', () => { let Navigation beforeEach(() => { - Navigation = getNavigation() - }) + vi.restoreAllMocks() - afterEach(() => { delete window._nc_navigation + Navigation = getNavigation() }) test('Add a favorite folder creates a new entry in the navigation', async () => { - jest.spyOn(eventBus, 'emit') - jest.spyOn(initialState, 'loadState').mockReturnValue([]) - jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) - registerFavoritesView() + await registerFavoritesView() const favoritesView = Navigation.views.find(view => view.id === 'favorites') const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') @@ -144,7 +158,7 @@ describe('Dynamic update of favourite folders', () => { // Create new folder to favorite const folder = new Folder({ id: 1, - source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar', + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', owner: 'admin', }) @@ -156,12 +170,18 @@ describe('Dynamic update of favourite folders', () => { }) test('Remove a favorite folder remove the entry from the navigation column', async () => { - jest.spyOn(eventBus, 'emit') - jest.spyOn(eventBus, 'subscribe') - jest.spyOn(initialState, 'loadState').mockReturnValue([{ fileid: 42, path: '/Foo/Bar' }]) - jest.spyOn(favoritesService, 'getContents').mockReturnValue(Promise.resolve({ folder: {} as Folder, contents: [] })) + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([ + new Folder({ + id: 42, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }), + ])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) - registerFavoritesView() + await registerFavoritesView() let favoritesView = Navigation.views.find(view => view.id === 'favorites') let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') @@ -173,7 +193,7 @@ describe('Dynamic update of favourite folders', () => { // Create new folder to favorite const folder = new Folder({ id: 1, - source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar', + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', owner: 'admin', root: '/files/admin', attributes: { @@ -181,11 +201,15 @@ describe('Dynamic update of favourite folders', () => { }, }) + const fo = vi.fn() + eventBus.subscribe('files:favorites:removed', fo) + // Exec the action await action.exec(folder, favoritesView, '/') expect(eventBus.emit).toHaveBeenCalledTimes(1) expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder) + expect(fo).toHaveBeenCalled() favoritesView = Navigation.views.find(view => view.id === 'favorites') favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') @@ -195,4 +219,43 @@ describe('Dynamic update of favourite folders', () => { expect(favoritesView).toBeDefined() expect(favoriteFoldersViews.length).toBe(0) }) + + test('Renaming a favorite folder updates the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // expect(eventBus.emit).toHaveBeenCalledTimes(2) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder) + + // Create a folder with the same id but renamed + const renamedFolder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed', + owner: 'admin', + }) + + // Exec the rename action + eventBus.emit('files:node:renamed', renamedFolder) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder) + }) }) diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts index 2d880facae7..cac776507ef 100644 --- a/apps/files/src/views/favorites.ts +++ b/apps/files/src/views/favorites.ts @@ -1,54 +1,33 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { Folder, Node } from '@nextcloud/files' -import { subscribe } from '@nextcloud/event-bus' import { FileType, View, getNavigation } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' -import { getLanguage, translate as t } from '@nextcloud/l10n' -import { basename } from 'path' +import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { getFavoriteNodes } from '@nextcloud/files/dav' +import { subscribe } from '@nextcloud/event-bus' + import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import StarSvg from '@mdi/svg/svg/star.svg?raw' +import StarSvg from '@mdi/svg/svg/star-outline.svg?raw' +import { client } from '../services/WebdavClient.ts' import { getContents } from '../services/Favorites' import { hashCode } from '../utils/hashUtils' import logger from '../logger' -// The return type of the initial state -interface IFavoriteFolder { - fileid: number - path: string -} - -export const generateFavoriteFolderView = function(folder: IFavoriteFolder, index = 0): View { +const generateFavoriteFolderView = function(folder: Folder, index = 0): View { return new View({ id: generateIdFromPath(folder.path), - name: basename(folder.path), + name: folder.displayname, icon: FolderSvg, order: index, + params: { dir: folder.path, - fileid: folder.fileid.toString(), + fileid: String(folder.fileid), view: 'favorites', }, @@ -60,21 +39,16 @@ export const generateFavoriteFolderView = function(folder: IFavoriteFolder, inde }) } -export const generateIdFromPath = function(path: string): string { +const generateIdFromPath = function(path: string): string { return `favorite-${hashCode(path)}` } -export default () => { - // Load state in function for mock testing purposes - const favoriteFolders = loadState<IFavoriteFolder[]>('files', 'favoriteFolders', []) - const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[] - logger.debug('Generating favorites view', { favoriteFolders }) - +export const registerFavoritesView = async () => { const Navigation = getNavigation() Navigation.register(new View({ id: 'favorites', name: t('files', 'Favorites'), - caption: t('files', 'List of favorites files and folders.'), + caption: t('files', 'List of favorite files and folders.'), emptyTitle: t('files', 'No favorites yet'), emptyCaption: t('files', 'Files and folders you mark as favorite will show up here'), @@ -87,10 +61,13 @@ export default () => { getContents, })) + const favoriteFolders = (await getFavoriteNodes(client)).filter(node => node.type === FileType.Folder) as Folder[] + const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[] + logger.debug('Generating favorites view', { favoriteFolders }) favoriteFoldersViews.forEach(view => Navigation.register(view)) /** - * Update favourites navigation when a new folder is added + * Update favorites navigation when a new folder is added */ subscribe('files:favorites:added', (node: Node) => { if (node.type !== FileType.Folder) { @@ -107,7 +84,7 @@ export default () => { }) /** - * Remove favourites navigation when a folder is removed + * Remove favorites navigation when a folder is removed */ subscribe('files:favorites:removed', (node: Node) => { if (node.type !== FileType.Folder) { @@ -124,11 +101,26 @@ export default () => { }) /** + * Update favorites navigation when a folder is renamed + */ + subscribe('files:node:renamed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + if (node.attributes.favorite !== 1) { + return + } + + updateNodeFromFavorites(node as Folder) + }) + + /** * Sort the favorites paths array and * update the order property of the existing views */ const updateAndSortViews = function() { - favoriteFolders.sort((a, b) => a.path.localeCompare(b.path, getLanguage(), { ignorePunctuation: true })) + favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' })) favoriteFolders.forEach((folder, index) => { const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path)) if (view) { @@ -139,8 +131,7 @@ export default () => { // Add a folder to the favorites paths array and update the views const addToFavorites = function(node: Folder) { - const newFavoriteFolder: IFavoriteFolder = { path: node.path, fileid: node.fileid! } - const view = generateFavoriteFolderView(newFavoriteFolder) + const view = generateFavoriteFolderView(node) // Skip if already exists if (favoriteFolders.find((folder) => folder.path === node.path)) { @@ -148,7 +139,7 @@ export default () => { } // Update arrays - favoriteFolders.push(newFavoriteFolder) + favoriteFolders.push(node) favoriteFoldersViews.push(view) // Update and sort views @@ -174,4 +165,19 @@ export default () => { Navigation.remove(id) updateAndSortViews() } + + // Update a folder from the favorites paths array and update the views + const updateNodeFromFavorites = function(node: Folder) { + const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid) + + // Skip if it does not exists + if (favoriteFolder === undefined) { + return + } + + removePathFromFavorites(favoriteFolder.path) + addToFavorites(node) + } + + updateAndSortViews() } diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts index a535481a061..a94aab0f14b 100644 --- a/apps/files/src/views/files.ts +++ b/apps/files/src/views/files.ts @@ -1,40 +1,65 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import { getContents } from '../services/Files' +import { emit, subscribe } from '@nextcloud/event-bus' import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Files.ts' +import { useActiveStore } from '../store/active.ts' +import { defaultView } from '../utils/filesViews.ts' + +import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw' + +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { + // we cache the query to allow more performant search (see below in event listener) + let oldQuery = '' -export default () => { const Navigation = getNavigation() Navigation.register(new View({ - id: 'files', + id: VIEW_ID, name: t('files', 'All files'), caption: t('files', 'List of your files and folders.'), icon: FolderSvg, - order: 0, + // if this is the default view we set it at the top of the list - otherwise below it + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) + + // when the search is updated + // and we are in the files view + // and there is already a folder fetched + // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered + subscribe('files:search:updated', ({ scope, query }) => { + if (scope === 'globally') { + return + } + + if (Navigation.active?.id !== VIEW_ID) { + return + } + + // If neither the old query nor the new query is longer than the search minimum + // then we do not need to trigger a new PROPFIND / SEARCH + // so we skip unneccessary requests here + if (oldQuery.length < 3 && query.length < 3) { + return + } + + const store = useActiveStore() + if (!store.activeFolder) { + return + } + + oldQuery = query + emit('files:node:updated', store.activeFolder) + }) } diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts new file mode 100644 index 00000000000..2ce4e501e6f --- /dev/null +++ b/apps/files/src/views/folderTree.ts @@ -0,0 +1,176 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { TreeNode } from '../services/FolderTree.ts' + +import PQueue from 'p-queue' +import { FileType, Folder, Node, View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { emit, subscribe } from '@nextcloud/event-bus' +import { isSamePath } from '@nextcloud/paths' +import { loadState } from '@nextcloud/initial-state' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' + +import { + folderTreeId, + getContents, + getFolderTreeNodes, + getSourceParent, + sourceRoot, +} from '../services/FolderTree.ts' + +const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree + +let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden + +const Navigation = getNavigation() + +const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerTreeChildren = async (path: string = '/') => { + await queue.add(async () => { + const nodes = await getFolderTreeNodes(path) + const promises = nodes.map(node => registerQueue.add(() => registerNodeView(node))) + await Promise.allSettled(promises) + }) +} + +const getLoadChildViews = (node: TreeNode | Folder) => { + return async (view: View): Promise<void> => { + // @ts-expect-error Custom property on View instance + if (view.loading || view.loaded) { + return + } + // @ts-expect-error Custom property + view.loading = true + await registerTreeChildren(node.path) + // @ts-expect-error Custom property + view.loading = false + // @ts-expect-error Custom property + view.loaded = true + // @ts-expect-error No payload + emit('files:navigation:updated') + // @ts-expect-error No payload + emit('files:folder-tree:expanded') + } +} + +const registerNodeView = (node: TreeNode | Folder) => { + const registeredView = Navigation.views.find(view => view.id === node.encodedSource) + if (registeredView) { + Navigation.remove(registeredView.id) + } + if (!showHiddenFiles && node.basename.startsWith('.')) { + return + } + Navigation.register(new View({ + id: node.encodedSource, + parent: getSourceParent(node.source), + + // @ts-expect-error Casing differences + name: node.displayName ?? node.displayname ?? node.basename, + + icon: FolderSvg, + + getContents, + loadChildViews: getLoadChildViews(node), + + params: { + view: folderTreeId, + fileid: String(node.fileid), // Needed for matching exact routes + dir: node.path, + }, + })) +} + +const removeFolderView = (folder: Folder) => { + const viewId = folder.encodedSource + Navigation.remove(viewId) +} + +const removeFolderViewSource = (source: string) => { + Navigation.remove(source) +} + +const onCreateNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + registerNodeView(node) +} + +const onDeleteNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderView(node) +} + +const onMoveNode = ({ node, oldSource }) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderViewSource(oldSource) + registerNodeView(node) + + const newPath = node.source.replace(sourceRoot, '') + const oldPath = oldSource.replace(sourceRoot, '') + const childViews = Navigation.views.filter(view => { + if (!view.params?.dir) { + return false + } + if (isSamePath(view.params.dir, oldPath)) { + return false + } + return view.params.dir.startsWith(oldPath) + }) + for (const view of childViews) { + // @ts-expect-error FIXME Allow setting parent + view.parent = getSourceParent(node.source) + // @ts-expect-error dir param is defined + view.params.dir = view.params.dir.replace(oldPath, newPath) + } +} + +const onUserConfigUpdated = async ({ key, value }) => { + if (key === 'show_hidden') { + showHiddenFiles = value + await registerTreeChildren() + // @ts-expect-error No payload + emit('files:folder-tree:initialized') + } +} + +const registerTreeRoot = () => { + Navigation.register(new View({ + id: folderTreeId, + + name: t('files', 'Folder tree'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderMultipleSvg, + order: 50, // Below all other views + + getContents, + })) +} + +export const registerFolderTreeView = async () => { + if (!isFolderTreeEnabled) { + return + } + registerTreeRoot() + await registerTreeChildren() + subscribe('files:node:created', onCreateNode) + subscribe('files:node:deleted', onDeleteNode) + subscribe('files:node:moved', onMoveNode) + subscribe('files:config:updated', onUserConfigUpdated) + // @ts-expect-error No payload + emit('files:folder-tree:initialized') +} diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts index 6af962959a0..241582057d1 100644 --- a/apps/files/src/views/personal-files.ts +++ b/apps/files/src/views/personal-files.ts @@ -1,42 +1,37 @@ /** - * @copyright Copyright (c) 2024 Eduardo Morales <emoral435@gmail.com> - * - * @author Eduardo Morales <emoral435@gmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' + +import { t } from '@nextcloud/l10n' import { View, getNavigation } from '@nextcloud/files' +import { getContents } from '../services/PersonalFiles.ts' +import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts' + +import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw' + +export const VIEW_ID = 'personal' -import { getContents } from '../services/PersonalFiles' -import AccountIcon from '@mdi/svg/svg/account.svg?raw' +/** + * Register the personal files view if allowed + */ +export function registerPersonalFilesView(): void { + if (!hasPersonalFilesView()) { + return + } -export default () => { const Navigation = getNavigation() Navigation.register(new View({ - id: 'personal', - name: t('files', 'Personal Files'), + id: VIEW_ID, + name: t('files', 'Personal files'), caption: t('files', 'List of your files and folders that are not shared.'), emptyTitle: t('files', 'No personal files found'), emptyCaption: t('files', 'Files that are not shared will show up here.'), icon: AccountIcon, - order: 5, + // if this is the default view we set it at the top of the list - otherwise default position of fifth + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts index 257aaf30dfd..fda1d99e13d 100644 --- a/apps/files/src/views/recent.ts +++ b/apps/files/src/views/recent.ts @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { View, getNavigation } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' 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, + })) +} |