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 | 86 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 909 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.cy.ts | 260 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 323 | ||||
-rw-r--r-- | apps/files/src/views/ReferenceFileWidget.vue | 306 | ||||
-rw-r--r-- | apps/files/src/views/SearchEmptyView.vue | 53 | ||||
-rw-r--r-- | apps/files/src/views/Settings.vue | 380 | ||||
-rw-r--r-- | apps/files/src/views/Sidebar.vue | 373 | ||||
-rw-r--r-- | apps/files/src/views/TemplatePicker.vue | 225 | ||||
-rw-r--r-- | apps/files/src/views/favorites.spec.ts | 261 | ||||
-rw-r--r-- | apps/files/src/views/favorites.ts | 183 | ||||
-rw-r--r-- | apps/files/src/views/files.ts | 65 | ||||
-rw-r--r-- | apps/files/src/views/folderTree.ts | 176 | ||||
-rw-r--r-- | apps/files/src/views/personal-files.ts | 38 | ||||
-rw-r--r-- | apps/files/src/views/recent.ts | 28 | ||||
-rw-r--r-- | apps/files/src/views/search.ts | 51 |
18 files changed, 3403 insertions, 567 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 new file mode 100644 index 00000000000..b4d4bc54f14 --- /dev/null +++ b/apps/files/src/views/FileReferencePickerElement.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div :id="containerId"> + <FilePicker v-bind="filepickerOptions" @close="onClose" /> + </div> +</template> + +<script lang="ts"> +import type { Node as NcNode } from '@nextcloud/files' +import type { IFilePickerButton } from '@nextcloud/dialogs' + +import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + +export default defineComponent({ + name: 'FileReferencePickerElement', + components: { + FilePicker, + }, + props: { + providerId: { + type: String, + required: true, + }, + accessible: { + type: Boolean, + default: false, + }, + }, + computed: { + containerId() { + return `filepicker-${Math.random().toString(36).slice(7)}` + }, + filepickerOptions() { + return { + allowPickDirectory: true, + buttons: this.buttonFactory, + container: `#${this.containerId}`, + multiselect: false, + name: t('files', 'Select file or folder to link to'), + } + }, + }, + methods: { + t, + + buttonFactory(selected: NcNode[]): IFilePickerButton[] { + const buttons = [] as IFilePickerButton[] + if (selected.length === 0) { + return [] + } + const node = selected.at(0) + if (node.path === '/') { + return [] // Do not allow selecting the users root folder + } + buttons.push({ + label: t('files', 'Choose {file}', { file: node.displayname }), + type: 'primary', + callback: this.onClose, + }) + return buttons + }, + + onClose(nodes?: NcNode[]) { + if (nodes === undefined || nodes.length === 0) { + this.$emit('cancel') + } else { + this.onSubmit(nodes[0]) + } + }, + + onSubmit(node: NcNode) { + const url = new URL(window.location.href) + url.pathname = generateUrl('/f/{fileId}', { fileId: node.fileid! }) + url.search = '' + this.$emit('submit', url.href) + }, + }, +}) +</script> diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue new file mode 100644 index 00000000000..f9e517e92ee --- /dev/null +++ b/apps/files/src/views/FilesList.vue @@ -0,0 +1,909 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcAppContent :page-heading="pageHeading" data-cy-files-content> + <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }"> + <!-- Current folder breadcrumbs --> + <BreadCrumbs :path="directory" @reload="fetchContent"> + <template #actions> + <!-- Sharing button --> + <NcButton v-if="canShare && fileListWidth >= 512" + :aria-label="shareButtonLabel" + :class="{ 'files-list__header-share-button--shared': shareButtonType }" + :title="shareButtonLabel" + class="files-list__header-share-button" + type="tertiary" + @click="openSharingSidebar"> + <template #icon> + <LinkIcon v-if="shareButtonType === ShareType.Link" /> + <AccountPlusIcon v-else :size="20" /> + </template> + </NcButton> + + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + </template> + </BreadCrumbs> + + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + + <NcActions class="files-list__header-actions" + :inline="1" + type="tertiary" + force-name> + <NcActionButton v-for="action in enabledFileListActions" + :key="action.id" + :disabled="!!loadingAction" + :data-cy-files-list-action="action.id" + close-after-click + @click="execFileListAction(action)"> + <template #icon> + <NcLoadingIcon v-if="loadingAction === action.id" :size="18" /> + <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView" + :svg="action.iconSvgInline(currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + + <NcButton v-if="fileListWidth >= 512 && enableGridView" + :aria-label="gridViewButtonLabel" + :title="gridViewButtonLabel" + class="files-list__header-grid-button" + type="tertiary" + @click="toggleGridView"> + <template #icon> + <ListViewIcon v-if="userConfig.grid_view" /> + <ViewGridIcon v-else /> + </template> + </NcButton> + </div> + + <!-- Drag and drop notice --> + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- File list - always mounted --> + <FilesListVirtual v-else + ref="filesListVirtual" + :current-folder="currentFolder" + :current-view="currentView" + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> + </NcAppContent> +</template> + +<script lang="ts"> +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' +import type { Upload } from '@nextcloud/upload' +import type { CancelablePromise } from 'cancelable-promise' +import type { ComponentPublicInstance } from 'vue' +import type { Route } from 'vue-router' +import type { UserConfig } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import { translate as t } from '@nextcloud/l10n' +import { join, dirname, normalize, relative } from 'path' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { UploadPicker, UploadStatus } from '@nextcloud/upload' +import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' +import { defineComponent } from 'vue' + +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconReload from 'vue-material-design-icons/Reload.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' +import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' +import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' +import FilesListVirtual from '../components/FilesListVirtual.vue' +import filesSortingMixin from '../mixins/filesSorting.ts' +import logger from '../logger.ts' + +const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined + +export default defineComponent({ + name: 'FilesList', + + components: { + BreadCrumbs, + DragAndDropNotice, + FilesListVirtual, + LinkIcon, + ListViewIcon, + NcAppContent, + NcActions, + NcActionButton, + NcButton, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, + AccountPlusIcon, + UploadPicker, + ViewGridIcon, + IconAlertCircleOutline, + IconReload, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + isPublic: { + type: Boolean, + default: false, + }, + }, + + setup() { + const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() + const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() + const filesStore = useFilesStore() + const filtersStore = useFiltersStore() + const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const userConfigStore = useUserConfigStore() + const viewConfigStore = useViewConfigStore() + + const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) + + return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, + filesStore, + filtersStore, + pathsStore, + selectionStore, + uploaderStore, + userConfigStore, + viewConfigStore, + + // non reactive data + enableGridView, + forbiddenCharacters, + ShareType, + } + }, + + data() { + return { + loading: true, + loadingAction: null as string | null, + error: null as string | null, + promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, + + dirContentsFiltered: [] as INode[], + } + }, + + computed: { + /** + * Get a callback function for the uploader to fetch directory contents for conflict resolution + */ + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + + pageHeading(): string { + const title = this.currentView?.name ?? t('files', 'Files') + + if (this.currentFolder === undefined || this.directory === '/') { + return title + } + return `${this.currentFolder.displayname} - ${title}` + }, + + /** + * The current folder. + */ + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) + + if (!this.currentView?.id) { + return dummyFolder + } + + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder + }, + + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.filesStore.getNode) + .filter((node: Node) => !!node) + }, + + /** + * The current directory contents. + */ + dirContentsSorted(): INode[] { + if (!this.currentView) { + return [] + } + + const customColumn = (this.currentView?.columns || []) + .find(column => column.id === this.sortingMode) + + // Custom column must provide their own sorting methods + if (customColumn?.sort && typeof customColumn.sort === 'function') { + const results = [...this.dirContentsFiltered].sort(customColumn.sort) + return this.isAscSorting ? results : results.reverse() + } + + const nodes = sortNodes(this.dirContentsFiltered, { + sortFavoritesFirst: this.userConfig.sort_favorites_first, + sortFoldersFirst: this.userConfig.sort_folders_first, + sortingMode: this.sortingMode, + sortingOrder: this.isAscSorting ? 'asc' : 'desc', + }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 + } + return 1 + }) + } + + return nodes + }, + + /** + * The current directory is empty. + */ + isEmptyDir(): boolean { + return this.dirContents.length === 0 + }, + + /** + * We are refreshing the current directory. + * But we already have a cached version of it + * that is not empty. + */ + isRefreshing(): boolean { + return this.currentFolder !== undefined + && !this.isEmptyDir + && this.loading + }, + + /** + * Route to the previous directory. + */ + toPreviousDir(): Route { + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' + return { ...this.$route, query: { dir } } + }, + + shareTypesAttributes(): number[] | undefined { + if (!this.currentFolder?.attributes?.['share-types']) { + return undefined + } + return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] + }, + shareButtonLabel() { + if (!this.shareTypesAttributes) { + return t('files', 'Share') + } + + if (this.shareButtonType === ShareType.Link) { + return t('files', 'Shared by link') + } + return t('files', 'Shared') + }, + shareButtonType(): ShareType | null { + if (!this.shareTypesAttributes) { + return null + } + + // If all types are links, show the link icon + if (this.shareTypesAttributes.some(type => type === ShareType.Link)) { + return ShareType.Link + } + + return ShareType.User + }, + + gridViewButtonLabel() { + return this.userConfig.grid_view + ? t('files', 'Switch to list view') + : t('files', 'Switch to grid view') + }, + + /** + * Check if the current folder has create permissions + */ + canUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0 + }, + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 + }, + + /** + * Check if current folder has share permissions + */ + canShare() { + return isSharingEnabled && !this.isPublic + && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 + }, + + showCustomEmptyView() { + return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined + }, + + enabledFileListActions() { + if (!this.currentView || !this.currentFolder) { + return [] + } + + const actions = getFileListActions() + const enabledActions = actions + .filter(action => { + if (action.enabled === undefined) { + return true + } + return action.enabled( + this.currentView!, + this.dirContents, + this.currentFolder as Folder, + ) + }) + .toSorted((a, b) => a.order - b.order) + return enabledActions + }, + + /** + * Using the filtered content if filters are active + */ + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) + }, + + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, + }, + + watch: { + /** + * Handle rendering the custom empty view + * @param show The current state if the custom empty view should be rendered + */ + showCustomEmptyView(show: boolean) { + if (show) { + this.$nextTick(() => { + const el = this.$refs.customEmptyView as HTMLDivElement + // We can cast here because "showCustomEmptyView" assets that current view is set + this.currentView!.emptyView!(el) + }) + } + }, + + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + + currentView(newView, oldView) { + if (newView?.id === oldView?.id) { + return + } + + logger.debug('View changed', { newView, oldView }) + this.selectionStore.reset() + this.fetchContent() + }, + + directory(newDir, oldDir) { + logger.debug('Directory changed', { newDir, oldDir }) + // TODO: preserve selection on browsing? + this.selectionStore.reset() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } + this.fetchContent() + + // Scroll to top, force virtual scroller to re-render + const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined + if (filesListVirtual?.$el) { + filesListVirtual.$el.scrollTop = 0 + } + }, + + dirContents(contents) { + logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) + emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() + }, + }, + + async mounted() { + subscribe('files:node:deleted', this.onNodeDeleted) + subscribe('files:node:updated', this.onUpdatedNode) + + // reload on settings change + subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } + }, + + unmounted() { + unsubscribe('files:node:deleted', this.onNodeDeleted) + unsubscribe('files:node:updated', this.onUpdatedNode) + unsubscribe('files:config:updated', this.fetchContent) + unsubscribe('files:filters:changed', this.filterDirContent) + unsubscribe('files:search:updated', this.onUpdateSearch) + }, + + methods: { + onUpdateSearch({ query, scope }) { + if (query && scope !== 'filter') { + this.debouncedFetchContent() + } + }, + + async fetchContent() { + this.loading = true + this.error = null + const dir = this.directory + const currentView = this.currentView + + if (!currentView) { + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) + return + } + + logger.debug('Fetching contents for directory', { dir, currentView }) + + // If we have a cancellable promise ongoing, cancel it + if (this.promise && 'cancel' in this.promise) { + this.promise.cancel() + logger.debug('Cancelled previous ongoing fetch') + } + + // Fetch the current dir contents + this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot> + try { + const { folder, contents } = await this.promise + logger.debug('Fetched contents', { dir, folder, contents }) + + // Update store + this.filesStore.updateNodes(contents) + + // Define current directory children + // TODO: make it more official + this.$set(folder, '_children', contents.map(node => node.source)) + + // If we're in the root dir, define the root + if (dir === '/') { + this.filesStore.setRoot({ service: currentView.id, root: folder }) + } else { + // Otherwise, add the folder to the store + if (folder.fileid) { + this.filesStore.updateNodes([folder]) + this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir }) + } else { + // If we're here, the view API messed up + logger.fatal('Invalid root folder returned', { dir, folder, currentView }) + } + } + + // Update paths store + const folders = contents.filter(node => node.type === 'folder') + folders.forEach((node) => { + this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) }) + }) + } catch (error) { + logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) + } finally { + this.loading = false + } + + }, + + /** + * Handle the node deleted event to reset open file + * @param node The deleted node + */ + onNodeDeleted(node: Node) { + if (node.fileid && node.fileid === this.fileId) { + if (node.fileid === this.currentFolder?.fileid) { + // Handle the edge case that the current directory is deleted + // in this case we need to keep the current view but move to the parent directory + window.OCP.Files.Router.goToRoute( + null, + { view: this.currentView!.id }, + { dir: this.currentFolder?.dirname ?? '/' }, + ) + } else { + // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: undefined }, + { ...this.$route.query, openfile: undefined }, + ) + } + } + }, + + /** + * The upload manager have finished handling the queue + * @param {Upload} upload the uploaded data + */ + onUpload(upload: Upload) { + // Let's only refresh the current Folder + // Navigating to a different folder will refresh it anyway + const needsRefresh = dirname(upload.source) === this.currentFolder!.source + + // TODO: fetch uploaded files data only + // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid + if (needsRefresh) { + // fetchContent will cancel the previous ongoing promise + this.fetchContent() + } + }, + + async onUploadFail(upload: Upload) { + const status = upload.response?.status || 0 + + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + + // Check known status codes + if (status === 507) { + showError(t('files', 'Not enough free space')) + return + } else if (status === 404 || status === 409) { + showError(t('files', 'Target folder does not exist any more')) + return + } else if (status === 403) { + showError(t('files', 'Operation is blocked by access control')) + return + } + + // Else we try to parse the response error message + if (typeof upload.response?.data === 'string') { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(upload.response.data, 'text/xml') + const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? '' + if (message.trim() !== '') { + // The server message is also translated + showError(t('files', 'Error during upload: {message}', { message })) + return + } + } catch (error) { + logger.error('Could not parse message', { error }) + } + } + + // Finally, check the status code if we have one + if (status !== 0) { + showError(t('files', 'Error during upload, status code {status}', { status })) + return + } + + showError(t('files', 'Unknown error during upload')) + }, + + /** + * Refreshes the current folder on update. + * + * @param node is the file/folder being updated. + */ + onUpdatedNode(node?: Node) { + if (node?.fileid === this.currentFolder?.fileid) { + this.fetchContent() + } + }, + + openSharingSidebar() { + if (!this.currentFolder) { + logger.debug('No current folder found for opening sharing sidebar') + return + } + + if (window?.OCA?.Files?.Sidebar?.setActiveTab) { + window.OCA.Files.Sidebar.setActiveTab('sharing') + } + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) + }, + + toggleGridView() { + this.userConfigStore.update('grid_view', !this.userConfig.grid_view) + }, + + filterDirContent() { + let nodes: INode[] = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, + + actionDisplayName(action: FileListAction): string { + let displayName = action.id + try { + displayName = action.displayName(this.currentView!) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + return displayName + }, + + async execFileListAction(action: FileListAction) { + this.loadingAction = action.id + + const displayName = this.actionDisplayName(action) + try { + const success = await action.exec(this.source, this.dirContents, this.currentDir) + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + this.loadingAction = null + } + }, + }, +}) +</script> + +<style scoped lang="scss"> +:global(.toast-loading-icon) { + // Reduce start margin (it was made for text but this is an icon) + margin-inline-start: -4px; + // 16px icon + 5px on both sides + min-width: 26px; +} + +.app-content { + // Virtual list needs to be full height and is scrollable + display: flex; + overflow: hidden; + flex-direction: column; + max-height: 100%; + position: relative !important; +} + +.files-list { + &__header { + display: flex; + align-items: center; + // Do not grow or shrink (vertically) + flex: 0 0; + max-width: 100%; + // Align with the navigation toggle icon + margin-block: var(--app-navigation-padding, 4px); + margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px); + + &--public { + // There is no navigation toggle on public shares + margin-inline: 0 var(--app-navigation-padding, 4px); + } + + >* { + // Do not grow or shrink (horizontally) + // Only the breadcrumbs shrinks + flex: 0 0; + } + + &-share-button { + color: var(--color-text-maxcontrast) !important; + + &--shared { + color: var(--color-main-text) !important; + } + } + + &-actions { + min-width: fit-content !important; + margin-inline: calc(var(--default-grid-baseline) * 2); + } + } + + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + + &__empty-view-wrapper { + display: flex; + height: 100%; + } + + &__refresh-icon { + flex: 0 0 var(--default-clickable-area); + width: var(--default-clickable-area); + height: var(--default-clickable-area); + } + + &__loading-icon { + margin: auto; + } +} +</style> diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index c8b0f07dea1..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -1,28 +1,61 @@ -import * as InitialState from '@nextcloud/initial-state' -import * as L10n from '@nextcloud/l10n' -import FolderSvg from '@mdi/svg/svg/folder.svg' -import ShareSvg from '@mdi/svg/svg/share-variant.svg' +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Navigation } from '@nextcloud/files' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import { createTestingPinia } from '@pinia/testing' -import NavigationService from '../services/Navigation' import NavigationView from './Navigation.vue' -import router from '../router/router.js' - -describe('Navigation renders', () => { - const Navigation = new NavigationService() +import { useViewConfigStore } from '../store/viewConfig' +import { Folder, View, getNavigation } from '@nextcloud/files' + +import router from '../router/router.ts' +import RouterService from '../services/RouterService' + +const resetNavigation = () => { + const nav = getNavigation() + ;[...nav.views].forEach(({ id }) => nav.remove(id)) + nav.setActive(null) +} + +const createView = (id: string, name: string, parent?: string) => new View({ + id, + name, + getContents: async () => ({ folder: {} as Folder, contents: [] }), + icon: FolderSvg, + order: 1, + parent, +}) - before(() => { - cy.stub(InitialState, 'loadState') - .returns({ - used: 1024 * 1024 * 1024, - quota: -1, - }) +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} +describe('Navigation renders', () => { + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) + + cy.mockInitialState('files', 'storageStats', { + used: 1000 * 1000 * 1000, + quota: -1, + }) }) + after(() => cy.unmockInitialState()) + it('renders', () => { cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -33,22 +66,31 @@ describe('Navigation renders', () => { }) describe('Navigation API', () => { - const Navigation = new NavigationService() + let Navigation: Navigation + + before(async () => { + delete window._nc_navigation + Navigation = getNavigation() + mockWindow() + + await router.replace({ name: 'filelist', params: { view: 'files' } }) + }) + + beforeEach(() => resetNavigation()) it('Check API entries rendering', () => { - Navigation.register({ - id: 'files', - name: 'Files', - getFiles: () => [], - icon: FolderSvg, - order: 1, - }) + Navigation.register(createView('files', 'Files')) + console.warn(Navigation.views) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [ + createTestingPinia({ + createSpy: cy.spy, + }), + ], + }, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -58,19 +100,16 @@ describe('Navigation API', () => { }) it('Adds a new entry and render', () => { - Navigation.register({ - id: 'sharing', - name: 'Sharing', - getFiles: () => [], - icon: ShareSvg, - order: 2, - }) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -80,76 +119,67 @@ describe('Navigation API', () => { }) it('Adds a new children, render and open menu', () => { - Navigation.register({ - id: 'sharingin', - name: 'Shared with me', - getFiles: () => [], - parent: 'sharing', - icon: ShareSvg, - order: 1, - }) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) + Navigation.register(createView('sharingin', 'Shared with me', 'sharing')) cy.mount(NavigationView, { - propsData: { - Navigation, - }, router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, }) + cy.wrap(useViewConfigStore()).as('viewConfigStore') + cy.get('[data-cy-files-navigation]').should('be.visible') cy.get('[data-cy-files-navigation-item]').should('have.length', 3) - // Intercept collapse preference request - cy.intercept('POST', '*/apps/files/api/v1/toggleShowFolder/*', { - statusCode: 200, - }).as('toggleShowFolder') - // Toggle the sharing entry children cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist') cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true }) - cy.wait('@toggleShowFolder') + + // Expect store update to be called + cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true) // Validate children cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible') cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me') + // Toggle the sharing entry children 🇦again + cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true }) + cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible') + + // Expect store update to be called + cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false) }) it('Throws when adding a duplicate entry', () => { - expect(() => { - Navigation.register({ - id: 'files', - name: 'Files', - getFiles: () => [], - icon: FolderSvg, - order: 1, - }) - }).to.throw('Navigation id files is already registered') + Navigation.register(createView('files', 'Files')) + expect(() => Navigation.register(createView('files', 'Files'))) + .to.throw('View id files is already registered') }) }) describe('Quota rendering', () => { - const Navigation = new NavigationService() - - beforeEach(() => { - // TODO: remove when @nextcloud/l10n 2.0 is released - // https://github.com/nextcloud/nextcloud-l10n/pull/542 - cy.stub(L10n, 'translate', (app, text, vars = {}, number) => { - cy.log({app, text, vars, number}) - return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => { - return vars[key] - }) - }) + before(async () => { + delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) - it('Unknown quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns(undefined) + afterEach(() => cy.unmockInitialState()) + it('Unknown quota', () => { cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -157,16 +187,18 @@ describe('Quota rendering', () => { }) it('Unlimited quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 1024 * 1024 * 1024, - quota: -1, - }) + cy.mockInitialState('files', 'storageStats', { + used: 1024 * 1024 * 1024, + quota: -1, + total: 50 * 1024 * 1024 * 1024, + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) @@ -176,44 +208,50 @@ describe('Quota rendering', () => { }) it('Non-reached quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 1024 * 1024 * 1024, - quota: 5 * 1024 * 1024 * 1024, - relative: 20, // percent - }) + cy.mockInitialState('files', 'storageStats', { + used: 1024 * 1024 * 1024, + quota: 5 * 1024 * 1024 * 1024, + total: 5 * 1024 * 1024 * 1024, + relative: 20, // percent + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20') + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '20') }) it('Reached quota', () => { - cy.stub(InitialState, 'loadState') - .as('loadStateStats') - .returns({ - used: 5 * 1024 * 1024 * 1024, - quota: 1024 * 1024 * 1024, - relative: 500, // percent - }) + cy.mockInitialState('files', 'storageStats', { + used: 5 * 1024 * 1024 * 1024, + quota: 1024 * 1024 * 1024, + total: 1024 * 1024 * 1024, + relative: 500, // percent + }) cy.mount(NavigationView, { - propsData: { - Navigation, + router, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], }, }) cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible') cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible') - cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100 + cy.get('[data-cy-files-navigation-settings-quota] progress') + .should('exist') + .and('have.attr', 'value', '100') // progress max is 100 }) }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 040e1482e32..0f3c3647c6e 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -1,45 +1,24 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcAppNavigation data-cy-files-navigation> - <template #list> - <NcAppNavigationItem v-for="view in parentViews" - :key="view.id" - :allow-collapse="true" - :data-cy-files-navigation-item="view.id" - :icon="view.iconClass" - :open="view.expanded" - :pinned="view.sticky" - :title="view.name" - :to="generateToNavigation(view)" - @update:open="onToggleExpand(view)"> - <NcAppNavigationItem v-for="child in childViews[view.id]" - :key="child.id" - :data-cy-files-navigation-item="child.id" - :exact="true" - :icon="child.iconClass" - :title="child.name" - :to="generateToNavigation(child)" /> - </NcAppNavigationItem> + <NcAppNavigation data-cy-files-navigation + class="files-navigation" + :aria-label="t('files', 'Files')"> + <template #search> + <FilesNavigationSearch /> + </template> + <template #default> + <NcAppNavigationList class="files-navigation__list" + :aria-label="t('files', 'Views')"> + <FilesNavigationItem :views="viewMap" /> + </NcAppNavigationList> + + <!-- Settings modal--> + <SettingsModal :open.sync="settingsOpened" + data-cy-files-navigation-settings + @close="onSettingsClose" /> </template> <!-- Non-scrollable navigation bottom elements --> @@ -49,54 +28,75 @@ <NavigationQuota /> <!-- Files settings modal toggle--> - <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')" - :title="t('files', 'Files settings')" + <NcAppNavigationItem :name="t('files', 'Files settings')" data-cy-files-navigation-settings-button @click.prevent.stop="openSettings"> - <Cog slot="icon" :size="20" /> + <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> - - <!-- Settings modal--> - <SettingsModal :open="settingsOpened" - data-cy-files-navigation-settings - @close="onSettingsClose" /> </NcAppNavigation> </template> -<script> -import { emit, subscribe } from '@nextcloud/event-bus' -import { generateUrl } from '@nextcloud/router' -import { translate } from '@nextcloud/l10n' - -import axios from '@nextcloud/axios' -import Cog from 'vue-material-design-icons/Cog.vue' -import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' +<script lang="ts"> +import type { View } from '@nextcloud/files' +import type { ViewConfig } from '../types.ts' -import logger from '../logger.js' -import Navigation from '../services/Navigation.ts' +import { emit, subscribe } from '@nextcloud/event-bus' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import IconCog from 'vue-material-design-icons/CogOutline.vue' +import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' +import FilesNavigationItem from '../components/FilesNavigationItem.vue' +import FilesNavigationSearch from '../components/FilesNavigationSearch.vue' + +import { useNavigation } from '../composables/useNavigation' +import { useFiltersStore } from '../store/filters.ts' +import { useViewConfigStore } from '../store/viewConfig.ts' +import logger from '../logger.ts' + +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) -export default { +export default defineComponent({ name: 'Navigation', components: { - Cog, + IconCog, + FilesNavigationItem, + FilesNavigationSearch, + + NavigationQuota, NcAppNavigation, NcAppNavigationItem, + NcAppNavigationList, SettingsModal, - NavigationQuota, }, - props: { - // eslint-disable-next-line vue/prop-name-casing - Navigation: { - type: Navigation, - required: true, - }, + setup() { + const filtersStore = useFiltersStore() + const viewConfigStore = useViewConfigStore() + const { currentView, views } = useNavigation() + + return { + currentView, + t, + views, + + filtersStore, + viewConfigStore, + } }, data() { @@ -106,134 +106,77 @@ export default { }, computed: { + /** + * The current view ID from the route params + */ currentViewId() { return this.$route?.params?.view || 'files' }, - /** @return {Navigation} */ - currentView() { - return this.views.find(view => view.id === this.currentViewId) - }, - - /** @return {Navigation[]} */ - views() { - return this.Navigation.views - }, - - /** @return {Navigation[]} */ - parentViews() { - return this.views - // filter child views - .filter(view => !view.parent) - // sort views by order - .sort((a, b) => { - return a.order - b.order - }) - }, - - /** @return {Navigation[]} */ - childViews() { + /** + * Map of parent ids to views + */ + viewMap(): Record<string, View[]> { return this.views - // filter parent views - .filter(view => !!view.parent) - // create a map of parents and their children - .reduce((list, view) => { - list[view.parent] = [...(list[view.parent] || []), view] - // Sort children by order - list[view.parent].sort((a, b) => { - return a.order - b.order + .reduce((map, view) => { + map[view.parent!] = [...(map[view.parent!] || []), view] + map[view.parent!].sort((a, b) => { + if (typeof a.order === 'number' || typeof b.order === 'number') { + return (a.order ?? 0) - (b.order ?? 0) + } + return collator.compare(a.name, b.name) }) - return list - }, {}) + return map + }, {} as Record<string, View[]>) }, }, watch: { - currentView(view, oldView) { - logger.debug('View changed', { id: view.id, view }) - this.showView(view, oldView) + currentViewId(newView, oldView) { + if (this.currentViewId !== this.currentView?.id) { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + // The new view as active + this.showView(view) + logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view }) + } }, }, - beforeMount() { - if (this.currentView) { - logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) - this.showView(this.currentView) - } + created() { + subscribe('files:folder-tree:initialized', this.loadExpandedViews) + subscribe('files:folder-tree:expanded', this.loadExpandedViews) + }, - subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged) + beforeMount() { + // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view + const view = this.views.find(({ id }) => id === this.currentViewId)! + this.showView(view) + logger.debug('Navigation mounted. Showing requested view', { view }) }, methods: { - /** - * @param {Navigation} view the new active view - * @param {Navigation} oldView the old active view - */ - showView(view, oldView) { - // Closing any opened sidebar - window?.OCA?.Files?.Sidebar?.close?.() - - if (view.legacy) { - const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer') - document.querySelectorAll('#app-content .viewcontainer').forEach(el => { - el.classList.add('hidden') - }) - newAppContent.classList.remove('hidden') - - // Triggering legacy navigation events - const { dir = '/' } = OC.Util.History.parseUrlQuery() - const params = { itemId: view.id, dir } - - logger.debug('Triggering legacy navigation event', params) - window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params)) - window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params)) - - } - - this.Navigation.setActive(view) - emit('files:navigation:changed', view) - }, - - /** - * Coming from the legacy files app. - * TODO: remove when all views are migrated. - * - * @param {Navigation} view the new active view - */ - onLegacyNavigationChanged({ id } = { id: 'files' }) { - const view = this.Navigation.views.find(view => view.id === id) - if (view && view.legacy && view.id !== this.currentView.id) { - // Force update the current route as the request comes - // from the legacy files app router - this.$router.replace({ ...this.$route, params: { view: view.id } }) - this.Navigation.setActive(view) - this.showView(view) + async loadExpandedViews() { + const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>) + .filter(([, config]) => config.expanded === true) + .map(([viewId]) => this.views.find(view => view.id === viewId)) + // eslint-disable-next-line no-use-before-define + .filter(Boolean as unknown as ((u: unknown) => u is View)) + .filter((view) => view.loadChildViews && !view.loaded) + for (const view of viewsToLoad) { + await view.loadChildViews(view) } }, /** - * Expand/collapse a a view with children and permanently - * save this setting in the server. - * - * @param {Navigation} view the view to toggle - */ - onToggleExpand(view) { - // Invert state - view.expanded = !view.expanded - axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded }) - }, - - /** - * Generate the route to a view - * - * @param {Navigation} view the view to toggle + * Set the view as active on the navigation and handle internal state + * @param view View to set active */ - generateToNavigation(view) { - if (view.params) { - const { dir, fileid } = view.params - return { name: 'filelist', params: view.params, query: { dir, fileid } } - } - return { name: 'filelist', params: { view: view.id } } + showView(view: View) { + // Closing any opened sidebar + window.OCA?.Files?.Sidebar?.close?.() + getNavigation().setActive(view) + emit('files:navigation:changed', view) }, /** @@ -249,22 +192,20 @@ export default { onSettingsClose() { this.settingsOpened = false }, - - t: translate, }, -} +}) </script> <style scoped lang="scss"> -// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in -.app-navigation::v-deep .app-navigation-entry-icon { - background-repeat: no-repeat; - background-position: center; -} - -.app-navigation > ul.app-navigation__list { - // Use flex gap value for more elegant spacing - padding-bottom: var(--default-grid-baseline, 4px); +.app-navigation { + :deep(.app-navigation-entry.active .button-vue.icon-collapse:not(:hover)) { + color: var(--color-primary-element-text); + } + + > ul.app-navigation__list { + // Use flex gap value for more elegant spacing + padding-bottom: var(--default-grid-baseline, 4px); + } } .app-navigation-entry__settings { @@ -274,4 +215,14 @@ export default { // Prevent shrinking or growing flex: 0 0 auto; } + +.files-navigation { + &__list { + height: 100%; // Fill all available space for sticky views + } + + :deep(.app-navigation__content > ul.app-navigation__list) { + will-change: scroll-position; + } +} </style> diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue new file mode 100644 index 00000000000..9db346ea35d --- /dev/null +++ b/apps/files/src/views/ReferenceFileWidget.vue @@ -0,0 +1,306 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div v-if="!accessible" class="widget-file widget-file--no-access"> + <span class="widget-file__image widget-file__image--icon"> + <FolderIcon v-if="isFolder" :size="88" /> + <FileIcon v-else :size="88" /> + </span> + <span class="widget-file__details"> + <p class="widget-file__title"> + {{ t('files', 'File cannot be accessed') }} + </p> + <p class="widget-file__description"> + {{ t('files', 'The file could not be found or you do not have permissions to view it. Ask the sender to share it.') }} + </p> + </span> + </div> + + <!-- Live preview if a handler is available --> + <component :is="viewerHandler.component" + v-else-if="interactive && viewerHandler && !failedViewer" + :active="false /* prevent video from autoplaying */" + :can-swipe="false" + :can-zoom="false" + :is-embedded="true" + v-bind="viewerFile" + :file-list="[viewerFile]" + :is-full-screen="false" + :is-sidebar-shown="false" + class="widget-file widget-file--interactive" + @error="failedViewer = true" /> + + <!-- The file is accessible --> + <a v-else + class="widget-file widget-file--link" + :href="richObject.link" + target="_blank" + @click="navigate"> + <span class="widget-file__image" :class="filePreviewClass" :style="filePreviewStyle"> + <template v-if="!previewUrl"> + <FolderIcon v-if="isFolder" :size="88" fill-color="var(--color-primary-element)" /> + <FileIcon v-else :size="88" /> + </template> + </span> + <span class="widget-file__details"> + <p class="widget-file__title">{{ richObject.name }}</p> + <p class="widget-file__description">{{ fileSize }}<br>{{ fileMtime }}</p> + <p class="widget-file__link">{{ filePath }}</p> + </span> + </a> +</template> + +<script lang="ts"> +import { defineComponent, type Component, type PropType } from 'vue' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { Node } from '@nextcloud/files' +import FileIcon from 'vue-material-design-icons/File.vue' +import FolderIcon from 'vue-material-design-icons/Folder.vue' +import path from 'path' + +// see lib/private/Collaboration/Reference/File/FileReferenceProvider.php +type Ressource = { + id: number + name: string + size: number + path: string + link: string + mimetype: string + mtime: number // as unix timestamp + 'preview-available': boolean +} + +type ViewerHandler = { + id: string + group: string + mimes: string[] + component: Component +} + +/** + * Minimal mock of the legacy Viewer FileInfo + * TODO: replace by Node object + */ +type ViewerFile = { + filename: string // the path to the root folder + basename: string // the file name + lastmod: Date // the last modification date + size: number // the file size in bytes + type: string + mime: string + fileid: number + failed: boolean + loaded: boolean + davPath: string + source: string +} + +export default defineComponent({ + name: 'ReferenceFileWidget', + components: { + FolderIcon, + FileIcon, + }, + props: { + richObject: { + type: Object as PropType<Ressource>, + required: true, + }, + accessible: { + type: Boolean, + default: true, + }, + interactive: { + type: Boolean, + default: true, + }, + }, + + data() { + return { + previewUrl: null as string | null, + failedViewer: false, + } + }, + + computed: { + availableViewerHandlers(): ViewerHandler[] { + return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[] + }, + viewerHandler(): ViewerHandler | undefined { + return this.availableViewerHandlers + .find(handler => handler.mimes.includes(this.richObject.mimetype)) + }, + viewerFile(): ViewerFile { + const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`) + .replace(/\/\/$/, '/') + return { + filename: this.richObject.path, + basename: this.richObject.name, + lastmod: new Date(this.richObject.mtime * 1000), + size: this.richObject.size, + type: 'file', + mime: this.richObject.mimetype, + fileid: this.richObject.id, + failed: false, + loaded: true, + davPath: davSource, + source: davSource, + } + }, + + fileSize() { + return window.OC.Util.humanFileSize(this.richObject.size) + }, + fileMtime() { + return window.OC.Util.relativeModifiedDate(this.richObject.mtime * 1000) + }, + filePath() { + return path.dirname(this.richObject.path) + }, + filePreviewStyle() { + if (this.previewUrl) { + return { + backgroundImage: 'url(' + this.previewUrl + ')', + } + } + return {} + }, + filePreviewClass() { + if (this.previewUrl) { + return 'widget-file__image--preview' + } + return 'widget-file__image--icon' + + }, + isFolder() { + return this.richObject.mimetype === 'httpd/unix-directory' + }, + }, + + mounted() { + if (this.richObject['preview-available']) { + const previewUrl = generateUrl('/core/preview?fileId={fileId}&x=250&y=250', { + fileId: this.richObject.id, + }) + const img = new Image() + img.onload = () => { + this.previewUrl = previewUrl + } + img.onerror = err => { + console.error('could not load recommendation preview', err) + } + img.src = previewUrl + } + }, + methods: { + navigate(event) { + if (this.isFolder) { + event.stopPropagation() + event.preventDefault() + this.openFilePicker() + } else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) { + event.stopPropagation() + event.preventDefault() + window?.OCA?.Viewer?.open({ path: this.richObject.path }) + } + }, + + openFilePicker() { + const picker = getFilePickerBuilder(t('settings', 'Your files')) + .allowDirectories(true) + .setMultiSelect(false) + .addButton({ + id: 'open', + label: this.t('settings', 'Open in files'), + callback(nodes: Node[]) { + if (nodes[0]) { + window.open(generateUrl('/f/{fileid}', { + fileid: nodes[0].fileid, + })) + } + }, + type: 'primary', + }) + .disableNavigation() + .startAt(this.richObject.path) + .build() + picker.pick() + }, + }, +}) +</script> + +<style lang="scss" scoped> +.widget-file { + display: flex; + flex-grow: 1; + color: var(--color-main-text) !important; + text-decoration: none !important; + padding: 0 !important; + + &__image { + width: 30%; + min-width: 160px; + max-width: 320px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + + &--icon { + min-width: 88px; + max-width: 88px; + padding: 12px; + padding-inline-end: 0; + display: flex; + align-items: center; + justify-content: center; + } + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: bold; + } + + &__details { + padding: 12px; + flex-grow: 1; + display: flex; + flex-direction: column; + + p { + margin: 0; + padding: 0; + } + } + + &__description { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + } + + // No preview, standard link to ressource + &--link { + color: var(--color-text-maxcontrast); + } + + &--interactive { + position: relative; + height: 400px; + max-height: 50vh; + margin: 0; + } +} +</style> diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue new file mode 100644 index 00000000000..904e1b0831d --- /dev/null +++ b/apps/files/src/views/SearchEmptyView.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnifyClose } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import debounce from 'debounce' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import { getPinia } from '../store/index.ts' +import { useSearchStore } from '../store/search.ts' + +const searchStore = useSearchStore(getPinia()) +const debouncedUpdate = debounce((value: string) => { + searchStore.query = value +}, 500) +</script> + +<template> + <NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnifyClose" /> + </template> + <template #action> + <div class="search-empty-view__wrapper"> + <NcInputField class="search-empty-view__input" + :label="t('files', 'Search for files')" + :model-value="searchStore.query" + type="search" + @update:model-value="debouncedUpdate" /> + </div> + </template> + </NcEmptyContent> +</template> + +<style scoped lang="scss"> +.search-empty-view { + &__input { + flex: 0 1; + min-width: min(400px, 50vw); + } + + &__wrapper { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + } +} +</style> diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index 9a63fea4924..bfac8e0b3d6 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -1,36 +1,70 @@ <!-- - - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> - - - - @author Gary Kim <gary@garykim.dev> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSettingsDialog :open="open" :show-navigation="true" - :title="t('files', 'Files settings')" + :name="t('files', 'Files settings')" @update:open="onClose"> <!-- Settings API--> - <NcAppSettingsSection id="settings" :title="t('files', 'Files settings')"> - <NcCheckboxRadioSwitch :checked.sync="show_hidden" + <NcAppSettingsSection id="settings" :name="t('files', 'General')"> + <fieldset class="files-settings__default-view" + data-cy-files-settings-setting="default_view"> + <legend> + {{ t('files', 'Default view') }} + </legend> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="files" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'All files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="personal" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'Personal files') }} + </NcCheckboxRadioSwitch> + </fieldset> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first" + :checked="userConfig.sort_favorites_first" + @update:checked="setConfig('sort_favorites_first', $event)"> + {{ t('files', 'Sort favorites first') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_folders_first" + :checked="userConfig.sort_folders_first" + @update:checked="setConfig('sort_folders_first', $event)"> + {{ t('files', 'Sort folders before files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" + :checked="userConfig.folder_tree" + @update:checked="setConfig('folder_tree', $event)"> + {{ t('files', 'Folder tree') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <!-- Appearance --> + <NcAppSettingsSection id="settings" :name="t('files', 'Appearance')"> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden" + :checked="userConfig.show_hidden" @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch :checked.sync="crop_image_previews" + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column" + :checked="userConfig.show_mime_column" + @update:checked="setConfig('show_mime_column', $event)"> + {{ t('files', 'Show file type column') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_files_extensions" + :checked="userConfig.show_files_extensions" + @update:checked="setConfig('show_files_extensions', $event)"> + {{ t('files', 'Show file extensions') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews" + :checked="userConfig.crop_image_previews" @update:checked="setConfig('crop_image_previews', $event)"> {{ t('files', 'Crop image previews') }} </NcCheckboxRadioSwitch> @@ -39,19 +73,21 @@ <!-- Settings API--> <NcAppSettingsSection v-if="settings.length !== 0" id="more-settings" - :title="t('files', 'Additional settings')"> + :name="t('files', 'Additional settings')"> <template v-for="setting in settings"> <Setting :key="setting.name" :el="setting.el" /> </template> </NcAppSettingsSection> <!-- Webdav URL--> - <NcAppSettingsSection id="webdav" :title="t('files', 'Webdav')"> + <NcAppSettingsSection id="webdav" :name="t('files', 'WebDAV')"> <NcInputField id="webdav-url-input" + :label="t('files', 'WebDAV URL')" :show-trailing-button="true" :success="webdavUrlCopied" - :trailing-button-label="t('files', 'Copy to clipboard')" + :trailing-button-label="t('files', 'Copy')" :value="webdavUrl" + class="webdav-url-input" readonly="readonly" type="url" @focus="$event.target.select()" @@ -61,34 +97,209 @@ </template> </NcInputField> <em> - <a :href="webdavDocs" target="_blank" rel="noreferrer noopener"> - {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗ + <a class="setting-link" + :href="webdavDocs" + target="_blank" + rel="noreferrer noopener"> + {{ t('files', 'How to access files using WebDAV') }} ↗ </a> </em> + <br> + <em v-if="isTwoFactorEnabled"> + <a class="setting-link" :href="appPasswordUrl"> + {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗ + </a> + </em> + </NcAppSettingsSection> + + <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')"> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_file_extension" + @update:checked="setConfig('show_dialog_file_extension', $event)"> + {{ t('files', 'Warn before changing a file extension') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_deletion" + @update:checked="setConfig('show_dialog_deletion', $event)"> + {{ t('files', 'Warn before deleting files') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <NcAppSettingsSection id="shortcuts" + :name="t('files', 'Keyboard shortcuts')"> + + <h3>{{ t('files', 'Actions') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>a</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'File actions') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>F2</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Rename') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Del</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Delete') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>s</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Add or remove favorite') }} + </dd> + </div> + <div v-if="isSystemtagsEnabled"> + <dt class="shortcut-key"> + <kbd>t</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Manage tags') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Selection') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>A</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select all files') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>ESC</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Deselect all') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Space</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select or deselect') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>Ctrl</kbd> + <kbd>Shift</kbd> <span>+ <kbd>Space</kbd></span> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Select a range') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'Navigation') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>Alt</kbd> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to parent folder') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↑</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file above') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>↓</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go to file below') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>←</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go left in grid') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>→</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Go right in grid') }} + </dd> + </div> + </dl> + + <h3>{{ t('files', 'View') }}</h3> + <dl> + <div> + <dt class="shortcut-key"> + <kbd>V</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Toggle grid view') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>D</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Open file sidebar') }} + </dd> + </div> + <div> + <dt class="shortcut-key"> + <kbd>?</kbd> + </dt> + <dd class="shortcut-description"> + {{ t('files', 'Show those shortcuts') }} + </dd> + </div> + </dl> </NcAppSettingsSection> </NcAppSettingsDialog> </template> <script> -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import Clipboard from 'vue-material-design-icons/Clipboard.vue' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField' -import Setting from '../components/Setting.vue' - -import { emit } from '@nextcloud/event-bus' -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' -import { loadState } from '@nextcloud/initial-state' +import { getCapabilities } from '@nextcloud/capabilities' import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate } from '@nextcloud/l10n' -import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -const userConfig = loadState('files', 'config', { - show_hidden: false, - crop_image_previews: true, -}) +import Clipboard from 'vue-material-design-icons/ContentCopy.vue' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcInputField from '@nextcloud/vue/components/NcInputField' + +import { useUserConfigStore } from '../store/userconfig.ts' +import Setting from '../components/Setting.vue' export default { name: 'Settings', @@ -108,21 +319,55 @@ export default { }, }, - data() { + setup() { + const userConfigStore = useUserConfigStore() + const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true return { + isSystemtagsEnabled, + userConfigStore, + t, + } + }, - ...userConfig, - + data() { + return { // Settings API settings: window.OCA?.Files?.Settings?.settings || [], // Webdav infos webdavUrl: generateRemoteUrl('dav/files/' + encodeURIComponent(getCurrentUser()?.uid)), webdavDocs: 'https://docs.nextcloud.com/server/stable/go.php?to=user-webdav', + appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'), webdavUrlCopied: false, + enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true), + isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)), } }, + computed: { + userConfig() { + return this.userConfigStore.userConfig + }, + + sortedSettings() { + // Sort settings by name + return [...this.settings].sort((a, b) => { + if (a.order && b.order) { + return a.order - b.order + } + return a.name.localeCompare(b.name) + }) + }, + }, + + created() { + // ? opens the settings dialog on the keyboard shortcuts section + useHotKey('?', this.showKeyboardShortcuts, { + stop: true, + prevent: true, + }) + }, + beforeMount() { // Update the settings API entries state this.settings.forEach(setting => setting.open()) @@ -139,10 +384,7 @@ export default { }, setConfig(key, value) { - emit('files:config:updated', { key, value }) - axios.post(generateUrl('/apps/files/api/v1/config/' + key), { - value, - }) + this.userConfigStore.update(key, value) }, async copyCloudId() { @@ -156,17 +398,47 @@ export default { await navigator.clipboard.writeText(this.webdavUrl) this.webdavUrlCopied = true - showSuccess(t('files', 'Webdav URL copied to clipboard')) + showSuccess(t('files', 'WebDAV URL copied')) setTimeout(() => { this.webdavUrlCopied = false }, 5000) }, - t: translate, + async showKeyboardShortcuts() { + this.$emit('update:open', true) + + await this.$nextTick() + document.getElementById('settings-section_shortcuts').scrollIntoView({ + behavior: 'smooth', + inline: 'nearest', + }) + }, }, } </script> <style lang="scss" scoped> +.files-settings { + &__default-view { + margin-bottom: 0.5rem; + } +} + +.setting-link:hover { + text-decoration: underline; +} +.shortcut-key { + width: 160px; + // some shortcuts are too long to fit in one line + white-space: normal; + span { + // force portion of a shortcut on a new line for nicer display + white-space: nowrap; + } +} + +.webdav-url-input { + margin-block-end: 0.5rem; +} </style> diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index c97fb304c32..40a16d42b42 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -1,49 +1,60 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebar v-if="file" ref="sidebar" + data-cy-sidebar v-bind="appSidebar" :force-menu="true" - tabindex="0" @close="close" @update:active="setActiveTab" - @update:starred="toggleStarred" @[defaultActionListener].stop.prevent="onDefaultAction" @opening="handleOpening" @opened="handleOpened" @closing="handleClosing" @closed="handleClosed"> + <template v-if="fileInfo" #subname> + <div class="sidebar__subname"> + <NcIconSvgWrapper v-if="fileInfo.isFavourited" + :path="mdiStar" + :name="t('files', 'Favorite')" + inline /> + <span>{{ size }}</span> + <span class="sidebar__subname-separator">•</span> + <NcDateTime :timestamp="fileInfo.mtime" /> + <span class="sidebar__subname-separator">•</span> + <span>{{ t('files', 'Owner') }}</span> + <NcUserBubble :user="ownerId" + :display-name="nodeOwnerLabel" /> + </div> + </template> + <!-- TODO: create a standard to allow multiple elements here? --> <template v-if="fileInfo" #description> - <LegacyView v-for="view in views" - :key="view.cid" - :component="view" - :file-info="fileInfo" /> + <div class="sidebar__description"> + <SystemTags v-if="isSystemTagsEnabled && showTagsDefault" + v-show="showTags" + :disabled="!fileInfo?.canEdit()" + :file-id="fileInfo.id" /> + <LegacyView v-for="view in views" + :key="view.cid" + :component="view" + :file-info="fileInfo" /> + </div> </template> <!-- Actions menu --> <template v-if="fileInfo" #secondary-actions> + <NcActionButton :close-after-click="true" + @click="toggleStarred(!fileInfo.isFavourited)"> + <template #icon> + <NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStarOutline : mdiStar" /> + </template> + {{ fileInfo.isFavourited ? t('files', 'Remove from favorites') : t('files', 'Add to favorites') }} + </NcActionButton> <!-- TODO: create proper api for apps to register actions And inject themselves here. --> <NcActionButton v-if="isSystemTagsEnabled" @@ -81,41 +92,71 @@ </template> </NcAppSidebar> </template> -<script> +<script lang="ts"> +import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files' +import { defineComponent } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { encodePath } from '@nextcloud/paths' +import { fetchNode } from '../services/WebdavClient.ts' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { getCurrentUser } from '@nextcloud/auth' +import { mdiStar, mdiStarOutline } from '@mdi/js' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' import $ from 'jquery' import axios from '@nextcloud/axios' -import { emit } from '@nextcloud/event-bus' -import moment from '@nextcloud/moment' -import { Type as ShareTypes } from '@nextcloud/sharing' -import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' +import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' -import FileInfo from '../services/FileInfo' -import SidebarTab from '../components/SidebarTab' -import LegacyView from '../components/LegacyView' +import FileInfo from '../services/FileInfo.js' +import LegacyView from '../components/LegacyView.vue' +import SidebarTab from '../components/SidebarTab.vue' +import SystemTags from '../../../systemtags/src/components/SystemTags.vue' +import logger from '../logger.ts' -export default { +export default defineComponent({ name: 'Sidebar', components: { + LegacyView, NcActionButton, NcAppSidebar, + NcDateTime, NcEmptyContent, - LegacyView, + NcIconSvgWrapper, SidebarTab, + SystemTags, + NcUserBubble, + }, + + setup() { + const currentUser = getCurrentUser() + + // Non reactive properties + return { + currentUser, + + mdiStar, + mdiStarOutline, + } }, data() { return { // reactive state Sidebar: OCA.Files.Sidebar.state, + showTags: false, + showTagsDefault: true, error: null, loading: true, fileInfo: null, - starLoading: false, + node: null, isFullScreen: false, hasLowHeight: false, } @@ -157,14 +198,12 @@ export default { * @return {string} */ davPath() { - const user = OC.getCurrentUser().uid - return OC.linkToRemote(`dav/files/${user}${encodePath(this.file)}`) + return `${davRemoteURL}${davRootPath}${encodePath(this.file)}` }, /** * Current active tab handler * - * @param {string} id the tab id to set as active * @return {string} the current active tab */ activeTab() { @@ -172,39 +211,12 @@ export default { }, /** - * Sidebar subtitle - * - * @return {string} - */ - subtitle() { - return `${this.size}, ${this.time}` - }, - - /** - * File last modified formatted string - * - * @return {string} - */ - time() { - return OC.Util.relativeModifiedDate(this.fileInfo.mtime) - }, - - /** - * File last modified full string - * - * @return {string} - */ - fullTime() { - return moment(this.fileInfo.mtime).format('LLL') - }, - - /** * File size formatted string * * @return {string} */ size() { - return OC.Util.humanFileSize(this.fileInfo.size) + return formatFileSize(this.fileInfo?.size) }, /** @@ -225,7 +237,6 @@ export default { if (this.fileInfo) { return { 'data-mimetype': this.fileInfo.mimetype, - 'star-loading': this.starLoading, active: this.activeTab, background: this.background, class: { @@ -234,24 +245,27 @@ export default { }, compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen, loading: this.loading, - starred: this.fileInfo.isFavourited, - subtitle: this.subtitle, - subtitleTooltip: this.fullTime, - title: this.fileInfo.name, - titleTooltip: this.fileInfo.name, + name: this.node?.displayname ?? this.fileInfo.name, + title: this.node?.displayname ?? this.fileInfo.name, } } else if (this.error) { return { key: 'error', // force key to re-render - subtitle: '', - title: '', + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, } } // no fileInfo yet, showing empty data return { loading: this.loading, - subtitle: '', - title: '', + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, } }, @@ -282,14 +296,36 @@ export default { }, isSystemTagsEnabled() { - return OCA && 'SystemTags' in OCA + return getCapabilities()?.systemtags?.enabled === true + }, + ownerId() { + return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid + }, + currentUserIsOwner() { + return this.ownerId === this.currentUser.uid + }, + nodeOwnerLabel() { + let ownerDisplayName = this.node?.attributes?.['owner-display-name'] + if (this.currentUserIsOwner) { + ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})` + } + return ownerDisplayName + }, + sharedMultipleTimes() { + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return t('files', 'Shared multiple times with different people') + } + return null }, }, created() { + subscribe('files:node:deleted', this.onNodeDeleted) + window.addEventListener('resize', this.handleWindowResize) this.handleWindowResize() }, beforeDestroy() { + unsubscribe('file:node:deleted', this.onNodeDeleted) window.removeEventListener('resize', this.handleWindowResize) }, @@ -314,8 +350,9 @@ export default { }, getPreviewIfAny(fileInfo) { - if (fileInfo.hasPreview && !this.isFullScreen) { - return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`) + if (fileInfo?.hasPreview && !this.isFullScreen) { + const etag = fileInfo?.etag || '' + return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`) } return this.getIconUrl(fileInfo) }, @@ -328,7 +365,7 @@ export default { * @return {string} Url to the icon for mimeType */ getIconUrl(fileInfo) { - const mimeType = fileInfo.mimetype || 'application/octet-stream' + const mimeType = fileInfo?.mimetype || 'application/octet-stream' if (mimeType === 'httpd/unix-directory') { // use default folder icon if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') { @@ -338,8 +375,8 @@ export default { } else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') { return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType) } else if (fileInfo.shareTypes && ( - fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) > -1 - || fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_EMAIL) > -1) + fileInfo.shareTypes.indexOf(ShareType.Link) > -1 + || fileInfo.shareTypes.indexOf(ShareType.Email) > -1) ) { return OC.MimeType.getIconUrl('dir-public') } else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) { @@ -357,17 +394,23 @@ export default { */ setActiveTab(id) { OCA.Files.Sidebar.setActiveTab(id) + this.tabs.forEach(tab => { + try { + tab.setIsActive(id === tab.id) + } catch (error) { + logger.error('Error while setting tab active state', { error, id: tab.id, tab }) + } + }) }, /** - * Toggle favourite state + * Toggle favorite state * TODO: better implementation * - * @param {boolean} state favourited or not + * @param {boolean} state is favorite or not */ async toggleStarred(state) { try { - this.starLoading = true await axios({ method: 'PROPPATCH', url: this.davPath, @@ -381,17 +424,28 @@ export default { </d:propertyupdate>`, }) - // TODO: Obliterate as soon as possible and use events with new files app - // Terrible fallback for legacy files: toggle filelist as well - if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) { - OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList) - } + /** + * TODO: adjust this when the Sidebar is finally using File/Folder classes + * @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115 + */ + const isDir = this.fileInfo.type === 'dir' + const Node = isDir ? Folder : File + const node = new Node({ + fileid: this.fileInfo.id, + source: `${davRemoteURL}${davRootPath}${this.file}`, + root: davRootPath, + mime: isDir ? undefined : this.fileInfo.mimetype, + attributes: { + favorite: 1, + }, + }) + emit(state ? 'files:favorites:added' : 'files:favorites:removed', node) + this.fileInfo.isFavourited = state } catch (error) { - OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file')) - console.error('Unable to change favourite state', error) + showError(t('files', 'Unable to change the favorite state of the file')) + logger.error('Unable to change favorite state', { error }) } - this.starLoading = false }, onDefaultAction() { @@ -410,9 +464,10 @@ export default { * Toggle the tags selector */ toggleTags() { - if (OCA.SystemTags && OCA.SystemTags.View) { - OCA.SystemTags.View.toggle() - } + // toggle + this.showTags = !this.showTags + // save the new state + this.setShowTagsDefault(this.showTags) }, /** @@ -423,38 +478,50 @@ export default { * @throws {Error} loading failure */ async open(path) { + if (!path || path.trim() === '') { + throw new Error(`Invalid path '${path}'`) + } + + // Only focus the tab when the selected file/tab is changed in already opened sidebar + // Focusing the sidebar on first file open is handled by NcAppSidebar + const focusTabAfterLoad = !!this.Sidebar.file + // update current opened file this.Sidebar.file = path - if (path && path.trim() !== '') { - // reset data, keep old fileInfo to not reload all tabs and just hide them - this.error = null - this.loading = true + // reset data, keep old fileInfo to not reload all tabs and just hide them + this.error = null + this.loading = true - try { - this.fileInfo = await FileInfo(this.davPath) - // adding this as fallback because other apps expect it - this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') - - // DEPRECATED legacy views - // TODO: remove - this.views.forEach(view => { - view.setFileInfo(this.fileInfo) - }) - - this.$nextTick(() => { - if (this.$refs.tabs) { - this.$refs.tabs.updateTabs() - } - }) - } catch (error) { - this.error = t('files', 'Error while loading the file data') - console.error('Error while loading the file data', error) + try { + this.node = await fetchNode(this.file) + this.fileInfo = FileInfo(this.node) + // adding this as fallback because other apps expect it + this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') + + // DEPRECATED legacy views + // TODO: remove + this.views.forEach(view => { + view.setFileInfo(this.fileInfo) + }) + + await this.$nextTick() - throw new Error(error) - } finally { - this.loading = false + this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id) + + this.loading = false + + await this.$nextTick() + + if (focusTabAfterLoad && this.$refs.sidebar) { + this.$refs.sidebar.focusActiveTabContent() } + } catch (error) { + this.loading = false + this.error = t('files', 'Error while loading the file data') + console.error('Error while loading the file data', error) + + throw new Error(error) } }, @@ -463,10 +530,21 @@ export default { */ close() { this.Sidebar.file = '' + this.showTags = false this.resetData() }, /** + * Handle if the current node was deleted + * @param {import('@nextcloud/files').Node} node The deleted node + */ + onNodeDeleted(node) { + if (this.fileInfo && node && this.fileInfo.id === node.fileid) { + this.close() + } + }, + + /** * Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar * * @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen. @@ -483,6 +561,15 @@ export default { }, /** + * Allow to set whether tags should be shown by default from OCA.Files.Sidebar + * + * @param {boolean} showTagsDefault - Whether or not to show the tags by default. + */ + setShowTagsDefault(showTagsDefault) { + this.showTagsDefault = showTagsDefault + }, + + /** * Emit SideBar events. */ handleOpening() { @@ -501,11 +588,11 @@ export default { this.hasLowHeight = document.documentElement.clientHeight < 1024 }, }, -} +}) </script> <style lang="scss" scoped> .app-sidebar { - &--has-preview::v-deep { + &--has-preview:deep { .app-sidebar-header__figure { background-size: cover; } @@ -525,12 +612,40 @@ export default { height: 100% !important; } + :deep { + .app-sidebar-header__description { + margin: 0 16px 4px 16px !important; + } + } + .svg-icon { - ::v-deep svg { + :deep(svg) { width: 20px; height: 20px; fill: currentColor; } } } + +.sidebar__subname { + display: flex; + align-items: center; + gap: 0 8px; + + &-separator { + display: inline-block; + font-weight: bold !important; + } + + .user-bubble__wrapper { + display: inline-flex; + } +} + +.sidebar__description { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px 0; + } </style> diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 33b925aa2ed..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -1,30 +1,13 @@ <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcModal v-if="opened" :clear-view-delay="-1" class="templates-picker" - size="normal" + size="large" @close="close"> <form class="templates-picker__form" :style="style" @@ -34,7 +17,9 @@ <!-- Templates list --> <ul class="templates-picker__list"> <TemplatePreview v-bind="emptyTemplate" + ref="emptyTemplatePreview" :checked="checked === emptyTemplate.fileid" + @confirm-click="onConfirmClick" @check="onCheck" /> <TemplatePreview v-for="template in provider.templates" @@ -42,14 +27,12 @@ v-bind="template" :checked="checked === template.fileid" :ratio="provider.ratio" + @confirm-click="onConfirmClick" @check="onCheck" /> </ul> <!-- Cancel and submit --> <div class="templates-picker__buttons"> - <button @click="close"> - {{ t('files', 'Cancel') }} - </button> <input type="submit" class="primary" :value="t('files', 'Create')" @@ -63,21 +46,29 @@ </NcModal> </template> -<script> -import { normalize } from 'path' -import { showError } from '@nextcloud/dialogs' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent' -import NcModal from '@nextcloud/vue/dist/Components/NcModal' - -import { getCurrentDirectory } from '../utils/davUtils' -import { createFromTemplate, getTemplates } from '../services/Templates' -import TemplatePreview from '../components/TemplatePreview' +<script lang="ts"> +import type { TemplateFile } from '../types.ts' + +import { getCurrentUser } from '@nextcloud/auth' +import { showError, spawnDialog } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { File } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { generateRemoteUrl } from '@nextcloud/router' +import { normalize, extname, join } from 'path' +import { defineComponent } from 'vue' +import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js' + +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcModal from '@nextcloud/vue/components/NcModal' +import TemplatePreview from '../components/TemplatePreview.vue' +import TemplateFiller from '../components/TemplateFiller.vue' +import logger from '../logger.ts' const border = 2 const margin = 8 -const width = margin * 20 -export default { +export default defineComponent({ name: 'TemplatePicker', components: { @@ -87,9 +78,12 @@ export default { }, props: { - logger: { + /** + * The parent folder where to create the node + */ + parent: { type: Object, - required: true, + default: () => null, }, }, @@ -98,44 +92,57 @@ export default { // Check empty template by default checked: -1, loading: false, - name: null, + name: null as string|null, opened: false, - provider: null, + provider: null as TemplateFile|null, } }, computed: { - /** - * Strip away extension from name - * - * @return {string} - */ + extension() { + return extname(this.name ?? '') + }, + nameWithoutExt() { - return this.name.indexOf('.') > -1 - ? this.name.split('.').slice(0, -1).join('.') - : this.name + // Strip extension from name if defined + return !this.extension + ? this.name! + : this.name!.slice(0, 0 - this.extension.length) }, emptyTemplate() { return { basename: t('files', 'Blank'), fileid: -1, - filename: this.t('files', 'Blank'), + filename: t('files', 'Blank'), hasPreview: false, mime: this.provider?.mimetypes[0] || this.provider?.mimetypes, } }, selectedTemplate() { - return this.provider.templates.find(template => template.fileid === this.checked) + if (!this.provider) { + return null + } + + return this.provider.templates!.find((template) => template.fileid === this.checked) }, /** - * Style css vars bin,d + * Style css vars bind * * @return {object} */ style() { + if (!this.provider) { + return {} + } + + // Fallback to 16:9 landscape ratio + const ratio = this.provider.ratio ? this.provider.ratio : 1.77 + // Landscape templates should be wider than tall ones + // We fit 3 templates per row at max for landscape and 4 for portrait + const width = ratio > 1 ? margin * 30 : margin * 20 return { '--margin': margin + 'px', '--width': width + 'px', @@ -147,14 +154,15 @@ export default { }, methods: { + t, + /** * Open the picker * * @param {string} name the file name to create * @param {object} provider the template provider picked */ - async open(name, provider) { - + async open(name: string, provider) { this.checked = this.emptyTemplate.fileid this.name = name this.provider = provider @@ -174,6 +182,11 @@ export default { // Else, open the picker this.opened = true + + // Set initial focus to the empty template preview + this.$nextTick(() => { + this.$refs.emptyTemplatePreview?.focus() + }) }, /** @@ -190,60 +203,98 @@ export default { /** * Manages the radio template picker change * - * @param {number} fileid the selected template file id + * @param fileid the selected template file id */ - onCheck(fileid) { + onCheck(fileid: number) { this.checked = fileid }, - async onSubmit() { - this.loading = true - const currentDirectory = getCurrentDirectory() - const fileList = OCA?.Files?.App?.currentFileList + onConfirmClick(fileid: number) { + if (fileid === this.checked) { + this.onSubmit() + } + }, + + async createFile(templateFields = []) { + const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/' // If the file doesn't have an extension, add the default one if (this.nameWithoutExt === this.name) { - this.logger.debug('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) - this.name = this.name + this.provider?.extension + logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension }) + this.name = `${this.name}${this.provider?.extension ?? ''}` } try { const fileInfo = await createFromTemplate( normalize(`${currentDirectory}/${this.name}`), - this.selectedTemplate?.filename, - this.selectedTemplate?.templateType, + this.selectedTemplate?.filename as string ?? '', + this.selectedTemplate?.templateType as string ?? '', + templateFields, ) - this.logger.debug('Created new file', fileInfo) - - // Fetch FileInfo and model - const data = await fileList?.addAndFetchFileInfo(this.name).then((status, data) => data) - const model = new OCA.Files.FileInfoModel(data, { - filesClient: fileList?.filesClient, + logger.debug('Created new file', fileInfo) + + const owner = getCurrentUser()?.uid || null + const node = new File({ + id: fileInfo.fileid, + source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)), + root: `/files/${owner}`, + mime: fileInfo.mime, + mtime: new Date(fileInfo.lastmod * 1000), + owner, + size: fileInfo.size, + permissions: fileInfo.permissions, + attributes: { + // Inherit some attributes from parent folder like the mount type and real owner + 'mount-type': this.parent?.attributes?.['mount-type'], + 'owner-id': this.parent?.attributes?.['owner-id'], + 'owner-display-name': this.parent?.attributes?.['owner-display-name'], + ...fileInfo, + 'has-preview': fileInfo.hasPreview, + }, }) - // Run default action - const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL) - if (fileAction) { - fileAction.action(fileInfo.basename, { - $file: fileList?.findFileEl(this.name), - dir: currentDirectory, - fileList, - fileActions: fileList?.fileActions, - fileInfoModel: model, - }) - } + // Update files list + emit('files:node:created', node) + + // Open the new file + window.OCP.Files.Router.goToRoute( + null, // use default route + { view: 'files', fileid: node.fileid }, + { dir: node.dirname, openfile: 'true' }, + ) + // Close the picker this.close() } catch (error) { - this.logger.error('Error while creating the new file from template') - console.error(error) - showError(this.t('files', 'Unable to create new file from template')) + logger.error('Error while creating the new file from template', { error }) + showError(t('files', 'Unable to create new file from template')) } finally { this.loading = false } }, + + async onSubmit() { + const fileId = this.selectedTemplate?.fileid + + // Only request field extraction if there is a valid template + // selected and it's not the blank template + let fields = [] + if (fileId && fileId !== this.emptyTemplate.fileid) { + fields = await getTemplateFields(fileId) + } + + if (fields.length > 0) { + spawnDialog(TemplateFiller, { + fields, + onSubmit: this.createFile, + }) + } else { + this.loading = true + await this.createFile() + } + }, }, -} +}) </script> <style lang="scss" scoped> @@ -275,11 +326,11 @@ export default { &__buttons { display: flex; - justify-content: space-between; + justify-content: end; padding: calc(var(--margin) * 2) var(--margin); position: sticky; bottom: 0; - background-image: linear-gradient(0, var(--gradient-main-background)); + background-image: linear-gradient(0deg, var(--gradient-main-background)); button, input[type='submit'] { height: 44px; @@ -287,14 +338,14 @@ export default { } // Make sure we're relative for the loading emptycontent on top - ::v-deep .modal-container { + :deep(.modal-container) { position: relative; } &__loading { position: absolute; top: 0; - left: 0; + inset-inline-start: 0; justify-content: center; width: 100%; height: 100%; diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts new file mode 100644 index 00000000000..f793eb9f54c --- /dev/null +++ b/apps/files/src/views/favorites.spec.ts @@ -0,0 +1,261 @@ +/* eslint-disable import/no-named-as-default-member */ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Folder as CFolder, Navigation } from '@nextcloud/files' + +import * as filesUtils from '@nextcloud/files' +import * as filesDavUtils from '@nextcloud/files/dav' +import { CancelablePromise } from 'cancelable-promise' +import { basename } from 'path' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import * as eventBus from '@nextcloud/event-bus' + +import { action } from '../actions/favoriteAction' +import * as favoritesService from '../services/Favorites' +import { registerFavoritesView } from './favorites' + +// eslint-disable-next-line import/namespace +const { Folder, getNavigation } = filesUtils + +vi.mock('@nextcloud/axios') + +window.OC = { + ...window.OC, + TAG_FAVORITE: '_$!<Favorite>!$_', +} + +declare global { + interface Window { + _nc_navigation?: Navigation + } +} + +describe('Favorites view definition', () => { + let Navigation + beforeEach(() => { + vi.resetAllMocks() + + delete window._nc_navigation + Navigation = getNavigation() + expect(window._nc_navigation).toBeDefined() + }) + + test('Default empty favorite view', async () => { + vi.spyOn(eventBus, 'subscribe') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + expect(eventBus.subscribe).toHaveBeenCalledTimes(3) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything()) + expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything()) + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + expect(favoritesView?.id).toBe('favorites') + expect(favoritesView?.name).toBe('Favorites') + expect(favoritesView?.caption).toBeDefined() + expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/) + expect(favoritesView?.order).toBe(15) + expect(favoritesView?.columns).toStrictEqual([]) + expect(favoritesView?.getContents).toBeDefined() + }) + + test('Default with favorites', async () => { + const favoriteFolders = [ + new Folder({ + id: 1, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo', + owner: 'admin', + }), + new Folder({ + id: 2, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/bar', + owner: 'admin', + }), + new Folder({ + id: 3, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar', + owner: 'admin', + }), + new Folder({ + id: 4, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar/yabadaba', + owner: 'admin', + }), + ] + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders)) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and 3 children + expect(Navigation.views.length).toBe(5) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(4) + + // Sorted by basename: bar, bar, foo + const expectedOrder = [2, 0, 1, 3] + + favoriteFolders.forEach((folder, index) => { + const favoriteView = favoriteFoldersViews[index] + expect(favoriteView).toBeDefined() + expect(favoriteView?.id).toBeDefined() + expect(favoriteView?.name).toBe(basename(folder.path)) + expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/) + expect(favoriteView?.order).toBe(expectedOrder[index]) + expect(favoriteView?.params).toStrictEqual({ + dir: folder.path, + fileid: String(folder.fileid), + view: 'favorites', + }) + expect(favoriteView?.parent).toBe('favorites') + expect(favoriteView?.columns).toStrictEqual([]) + expect(favoriteView?.getContents).toBeDefined() + }) + }) +}) + +describe('Dynamic update of favorite folders', () => { + let Navigation + beforeEach(() => { + vi.restoreAllMocks() + + delete window._nc_navigation + Navigation = getNavigation() + }) + + test('Add a favorite folder creates a new entry in the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder) + }) + + test('Remove a favorite folder remove the entry from the navigation column', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([ + new Folder({ + id: 42, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }), + ])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + let favoritesView = Navigation.views.find(view => view.id === 'favorites') + let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(2) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(1) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + root: '/files/admin', + attributes: { + favorite: 1, + }, + }) + + const fo = vi.fn() + eventBus.subscribe('files:favorites:removed', fo) + + // Exec the action + await action.exec(folder, favoritesView, '/') + + expect(eventBus.emit).toHaveBeenCalledTimes(1) + expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder) + expect(fo).toHaveBeenCalled() + + favoritesView = Navigation.views.find(view => view.id === 'favorites') + favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + }) + + test('Renaming a favorite folder updates the navigation', async () => { + vi.spyOn(eventBus, 'emit') + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) + + await registerFavoritesView() + const favoritesView = Navigation.views.find(view => view.id === 'favorites') + const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') + + // one main view and no children + expect(Navigation.views.length).toBe(1) + expect(favoritesView).toBeDefined() + expect(favoriteFoldersViews.length).toBe(0) + + // expect(eventBus.emit).toHaveBeenCalledTimes(2) + + // Create new folder to favorite + const folder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar', + owner: 'admin', + }) + + // Exec the action + await action.exec(folder, favoritesView, '/') + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder) + + // Create a folder with the same id but renamed + const renamedFolder = new Folder({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed', + owner: 'admin', + }) + + // Exec the rename action + eventBus.emit('files:node:renamed', renamedFolder) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder) + }) +}) diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts new file mode 100644 index 00000000000..cac776507ef --- /dev/null +++ b/apps/files/src/views/favorites.ts @@ -0,0 +1,183 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Folder, Node } from '@nextcloud/files' + +import { FileType, View, getNavigation } from '@nextcloud/files' +import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { getFavoriteNodes } from '@nextcloud/files/dav' +import { subscribe } from '@nextcloud/event-bus' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import StarSvg from '@mdi/svg/svg/star-outline.svg?raw' + +import { client } from '../services/WebdavClient.ts' +import { getContents } from '../services/Favorites' +import { hashCode } from '../utils/hashUtils' +import logger from '../logger' + +const generateFavoriteFolderView = function(folder: Folder, index = 0): View { + return new View({ + id: generateIdFromPath(folder.path), + name: folder.displayname, + + icon: FolderSvg, + order: index, + + params: { + dir: folder.path, + fileid: String(folder.fileid), + view: 'favorites', + }, + + parent: 'favorites', + + columns: [], + + getContents, + }) +} + +const generateIdFromPath = function(path: string): string { + return `favorite-${hashCode(path)}` +} + +export const registerFavoritesView = async () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: 'favorites', + name: t('files', 'Favorites'), + caption: t('files', 'List of favorite files and folders.'), + + emptyTitle: t('files', 'No favorites yet'), + emptyCaption: t('files', 'Files and folders you mark as favorite will show up here'), + + icon: StarSvg, + order: 15, + + columns: [], + + getContents, + })) + + const favoriteFolders = (await getFavoriteNodes(client)).filter(node => node.type === FileType.Folder) as Folder[] + const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[] + logger.debug('Generating favorites view', { favoriteFolders }) + favoriteFoldersViews.forEach(view => Navigation.register(view)) + + /** + * Update favorites navigation when a new folder is added + */ + subscribe('files:favorites:added', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + addToFavorites(node as Folder) + }) + + /** + * Remove favorites navigation when a folder is removed + */ + subscribe('files:favorites:removed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + // Sanity check + if (node.path === null || !node.root?.startsWith('/files')) { + logger.error('Favorite folder is not within user files root', { node }) + return + } + + removePathFromFavorites(node.path) + }) + + /** + * Update favorites navigation when a folder is renamed + */ + subscribe('files:node:renamed', (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + + if (node.attributes.favorite !== 1) { + return + } + + updateNodeFromFavorites(node as Folder) + }) + + /** + * Sort the favorites paths array and + * update the order property of the existing views + */ + const updateAndSortViews = function() { + favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' })) + favoriteFolders.forEach((folder, index) => { + const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path)) + if (view) { + view.order = index + } + }) + } + + // Add a folder to the favorites paths array and update the views + const addToFavorites = function(node: Folder) { + const view = generateFavoriteFolderView(node) + + // Skip if already exists + if (favoriteFolders.find((folder) => folder.path === node.path)) { + return + } + + // Update arrays + favoriteFolders.push(node) + favoriteFoldersViews.push(view) + + // Update and sort views + updateAndSortViews() + Navigation.register(view) + } + + // Remove a folder from the favorites paths array and update the views + const removePathFromFavorites = function(path: string) { + const id = generateIdFromPath(path) + const index = favoriteFolders.findIndex((folder) => folder.path === path) + + // Skip if not exists + if (index === -1) { + return + } + + // Update arrays + favoriteFolders.splice(index, 1) + favoriteFoldersViews.splice(index, 1) + + // Update and sort views + Navigation.remove(id) + updateAndSortViews() + } + + // Update a folder from the favorites paths array and update the views + const updateNodeFromFavorites = function(node: Folder) { + const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid) + + // Skip if it does not exists + if (favoriteFolder === undefined) { + return + } + + removePathFromFavorites(favoriteFolder.path) + addToFavorites(node) + } + + updateAndSortViews() +} diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts new file mode 100644 index 00000000000..a94aab0f14b --- /dev/null +++ b/apps/files/src/views/files.ts @@ -0,0 +1,65 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit, subscribe } from '@nextcloud/event-bus' +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Files.ts' +import { useActiveStore } from '../store/active.ts' +import { defaultView } from '../utils/filesViews.ts' + +import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw' + +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { + // we cache the query to allow more performant search (see below in event listener) + let oldQuery = '' + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'All files'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderSvg, + // if this is the default view we set it at the top of the list - otherwise below it + order: defaultView() === VIEW_ID ? 0 : 5, + + getContents, + })) + + // when the search is updated + // and we are in the files view + // and there is already a folder fetched + // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered + subscribe('files:search:updated', ({ scope, query }) => { + if (scope === 'globally') { + return + } + + if (Navigation.active?.id !== VIEW_ID) { + return + } + + // If neither the old query nor the new query is longer than the search minimum + // then we do not need to trigger a new PROPFIND / SEARCH + // so we skip unneccessary requests here + if (oldQuery.length < 3 && query.length < 3) { + return + } + + const store = useActiveStore() + if (!store.activeFolder) { + return + } + + oldQuery = query + emit('files:node:updated', store.activeFolder) + }) +} diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts new file mode 100644 index 00000000000..2ce4e501e6f --- /dev/null +++ b/apps/files/src/views/folderTree.ts @@ -0,0 +1,176 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { TreeNode } from '../services/FolderTree.ts' + +import PQueue from 'p-queue' +import { FileType, Folder, Node, View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { emit, subscribe } from '@nextcloud/event-bus' +import { isSamePath } from '@nextcloud/paths' +import { loadState } from '@nextcloud/initial-state' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' + +import { + folderTreeId, + getContents, + getFolderTreeNodes, + getSourceParent, + sourceRoot, +} from '../services/FolderTree.ts' + +const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree + +let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden + +const Navigation = getNavigation() + +const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + +const registerTreeChildren = async (path: string = '/') => { + await queue.add(async () => { + const nodes = await getFolderTreeNodes(path) + const promises = nodes.map(node => registerQueue.add(() => registerNodeView(node))) + await Promise.allSettled(promises) + }) +} + +const getLoadChildViews = (node: TreeNode | Folder) => { + return async (view: View): Promise<void> => { + // @ts-expect-error Custom property on View instance + if (view.loading || view.loaded) { + return + } + // @ts-expect-error Custom property + view.loading = true + await registerTreeChildren(node.path) + // @ts-expect-error Custom property + view.loading = false + // @ts-expect-error Custom property + view.loaded = true + // @ts-expect-error No payload + emit('files:navigation:updated') + // @ts-expect-error No payload + emit('files:folder-tree:expanded') + } +} + +const registerNodeView = (node: TreeNode | Folder) => { + const registeredView = Navigation.views.find(view => view.id === node.encodedSource) + if (registeredView) { + Navigation.remove(registeredView.id) + } + if (!showHiddenFiles && node.basename.startsWith('.')) { + return + } + Navigation.register(new View({ + id: node.encodedSource, + parent: getSourceParent(node.source), + + // @ts-expect-error Casing differences + name: node.displayName ?? node.displayname ?? node.basename, + + icon: FolderSvg, + + getContents, + loadChildViews: getLoadChildViews(node), + + params: { + view: folderTreeId, + fileid: String(node.fileid), // Needed for matching exact routes + dir: node.path, + }, + })) +} + +const removeFolderView = (folder: Folder) => { + const viewId = folder.encodedSource + Navigation.remove(viewId) +} + +const removeFolderViewSource = (source: string) => { + Navigation.remove(source) +} + +const onCreateNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + registerNodeView(node) +} + +const onDeleteNode = (node: Node) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderView(node) +} + +const onMoveNode = ({ node, oldSource }) => { + if (node.type !== FileType.Folder) { + return + } + removeFolderViewSource(oldSource) + registerNodeView(node) + + const newPath = node.source.replace(sourceRoot, '') + const oldPath = oldSource.replace(sourceRoot, '') + const childViews = Navigation.views.filter(view => { + if (!view.params?.dir) { + return false + } + if (isSamePath(view.params.dir, oldPath)) { + return false + } + return view.params.dir.startsWith(oldPath) + }) + for (const view of childViews) { + // @ts-expect-error FIXME Allow setting parent + view.parent = getSourceParent(node.source) + // @ts-expect-error dir param is defined + view.params.dir = view.params.dir.replace(oldPath, newPath) + } +} + +const onUserConfigUpdated = async ({ key, value }) => { + if (key === 'show_hidden') { + showHiddenFiles = value + await registerTreeChildren() + // @ts-expect-error No payload + emit('files:folder-tree:initialized') + } +} + +const registerTreeRoot = () => { + Navigation.register(new View({ + id: folderTreeId, + + name: t('files', 'Folder tree'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderMultipleSvg, + order: 50, // Below all other views + + getContents, + })) +} + +export const registerFolderTreeView = async () => { + if (!isFolderTreeEnabled) { + return + } + registerTreeRoot() + await registerTreeChildren() + subscribe('files:node:created', onCreateNode) + subscribe('files:node:deleted', onDeleteNode) + subscribe('files:node:moved', onMoveNode) + subscribe('files:config:updated', onUserConfigUpdated) + // @ts-expect-error No payload + emit('files:folder-tree:initialized') +} diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts new file mode 100644 index 00000000000..241582057d1 --- /dev/null +++ b/apps/files/src/views/personal-files.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' +import { View, getNavigation } from '@nextcloud/files' +import { getContents } from '../services/PersonalFiles.ts' +import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts' + +import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw' + +export const VIEW_ID = 'personal' + +/** + * Register the personal files view if allowed + */ +export function registerPersonalFilesView(): void { + if (!hasPersonalFilesView()) { + return + } + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Personal files'), + caption: t('files', 'List of your files and folders that are not shared.'), + + emptyTitle: t('files', 'No personal files found'), + emptyCaption: t('files', 'Files that are not shared will show up here.'), + + icon: AccountIcon, + // if this is the default view we set it at the top of the list - otherwise default position of fifth + order: defaultView() === VIEW_ID ? 0 : 5, + + getContents, + })) +} diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts new file mode 100644 index 00000000000..fda1d99e13d --- /dev/null +++ b/apps/files/src/views/recent.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import HistorySvg from '@mdi/svg/svg/history.svg?raw' + +import { getContents } from '../services/Recent' + +export default () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: 'recent', + name: t('files', 'Recent'), + caption: t('files', 'List of recently modified files and folders.'), + + emptyTitle: t('files', 'No recently modified files'), + emptyCaption: t('files', 'Files and folders you recently modified will show up here.'), + + icon: HistorySvg, + order: 10, + + defaultSortKey: 'mtime', + + getContents, + })) +} diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts new file mode 100644 index 00000000000..a30f732163c --- /dev/null +++ b/apps/files/src/views/search.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance' + +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Search.ts' +import { VIEW_ID as FILES_VIEW_ID } from './files.ts' +import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw' +import Vue from 'vue' + +export const VIEW_ID = 'search' + +/** + * Register the search-in-files view + */ +export function registerSearchView() { + let instance: Vue + let view: ComponentPublicInstanceConstructor + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Search'), + caption: t('files', 'Search results within your files.'), + + async emptyView(el) { + if (!view) { + view = (await import('./SearchEmptyView.vue')).default + } else { + instance.$destroy() + } + instance = new Vue(view) + instance.$mount(el) + }, + + icon: MagnifySvg, + order: 10, + + parent: FILES_VIEW_ID, + // it should be shown expanded + expanded: true, + // this view is hidden by default and only shown when active + hidden: true, + + getContents, + })) +} |