diff options
Diffstat (limited to 'apps/files/src/views/FilesList.vue')
-rw-r--r-- | apps/files/src/views/FilesList.vue | 745 |
1 files changed, 483 insertions, 262 deletions
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index f5bb45ede1d..f9e517e92ee 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -1,32 +1,15 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppContent :page-heading="pageHeading" data-cy-files-content> - <div class="files-list__header"> + <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }"> <!-- Current folder breadcrumbs --> - <BreadCrumbs :path="dir" @reload="fetchContent"> + <BreadCrumbs :path="directory" @reload="fetchContent"> <template #actions> <!-- Sharing button --> - <NcButton v-if="canShare && filesListWidth >= 512" + <NcButton v-if="canShare && fileListWidth >= 512" :aria-label="shareButtonLabel" :class="{ 'files-list__header-share-button--shared': shareButtonType }" :title="shareButtonLabel" @@ -34,36 +17,47 @@ type="tertiary" @click="openSharingSidebar"> <template #icon> - <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" /> + <LinkIcon v-if="shareButtonType === ShareType.Link" /> <AccountPlusIcon v-else :size="20" /> </template> </NcButton> - <!-- Disabled upload button --> - <NcButton v-if="!canUpload || isQuotaExceeded" - :aria-label="cantUploadLabel" - :title="cantUploadLabel" - class="files-list__header-upload-button--disabled" - :disabled="true" - type="secondary"> - <template #icon> - <PlusIcon :size="20" /> - </template> - {{ t('files', 'New') }} - </NcButton> - <!-- Uploader --> - <UploadPicker v-else-if="currentFolder" - :content="dirContents" - :destination="currentFolder" - :multiple="true" + <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder" + allow-folders class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple @failed="onUploadFail" @uploaded="onUpload" /> </template> </BreadCrumbs> - <NcButton v-if="filesListWidth >= 512 && enableGridView" + <!-- Secondary loading indicator --> + <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> + + <NcActions class="files-list__header-actions" + :inline="1" + type="tertiary" + force-name> + <NcActionButton v-for="action in enabledFileListActions" + :key="action.id" + :disabled="!!loadingAction" + :data-cy-files-list-action="action.id" + close-after-click + @click="execFileListAction(action)"> + <template #icon> + <NcLoadingIcon v-if="loadingAction === action.id" :size="18" /> + <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView" + :svg="action.iconSvgInline(currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </NcActions> + + <NcButton v-if="fileListWidth >= 512 && enableGridView" :aria-label="gridViewButtonLabel" :title="gridViewButtonLabel" class="files-list__header-grid-button" @@ -74,91 +68,141 @@ <ViewGridIcon v-else /> </template> </NcButton> - - <!-- Secondary loading indicator --> - <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> </div> <!-- Drag and drop notice --> - <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" /> - - <!-- Initial loading --> - <NcLoadingIcon v-if="loading && !isRefreshing" + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" class="files-list__loading-icon" :size="38" :name="t('files', 'Loading current folder')" /> - <!-- Empty content placeholder --> - <NcEmptyContent v-else-if="!loading && isEmptyDir" - :name="currentView?.emptyTitle || t('files', 'No files in here')" - :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" - data-cy-files-content-empty> - <template #action> - <NcButton v-if="dir !== '/'" - :aria-label="t('files', 'Go to the previous folder')" - type="primary" - :to="toPreviousDir"> - {{ t('files', 'Go back') }} - </NcButton> - </template> - <template #icon> - <NcIconSvgWrapper :svg="currentView.icon" /> - </template> - </NcEmptyContent> - - <!-- File list --> + <!-- File list - always mounted --> <FilesListVirtual v-else ref="filesListVirtual" :current-folder="currentFolder" :current-view="currentView" - :nodes="dirContentsSorted" /> + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> </NcAppContent> </template> <script lang="ts"> -import type { Route } from 'vue-router' +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' +import type { CancelablePromise } from 'cancelable-promise' +import type { ComponentPublicInstance } from 'vue' +import type { Route } from 'vue-router' import type { UserConfig } from '../types.ts' -import type { View, ContentsWithRoot } from '@nextcloud/files' -import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { Folder, Node, Permission } from '@nextcloud/files' +import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' -import { join, dirname } from 'path' -import { orderBy } from 'natural-orderby' -import { Parser } from 'xml2js' -import { showError } from '@nextcloud/dialogs' -import { translate, translatePlural } from '@nextcloud/l10n' -import { Type } from '@nextcloud/sharing' -import { UploadPicker } from '@nextcloud/upload' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import { translate as t } from '@nextcloud/l10n' +import { join, dirname, normalize, relative } from 'path' +import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { UploadPicker, UploadStatus } from '@nextcloud/upload' import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' import { defineComponent } from 'vue' +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconReload from 'vue-material-design-icons/Reload.vue' import LinkIcon from 'vue-material-design-icons/Link.vue' import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' -import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import PlusIcon from 'vue-material-design-icons/Plus.vue' -import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' -import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' import { useFilesStore } from '../store/files.ts' +import { useFiltersStore } from '../store/filters.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import { useUserConfigStore } from '../store/userconfig.ts' import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' -import filesListWidthMixin from '../mixins/filesListWidth.ts' import filesSortingMixin from '../mixins/filesSorting.ts' -import logger from '../logger.js' -import DragAndDropNotice from '../components/DragAndDropNotice.vue' -import debounce from 'debounce' +import logger from '../logger.ts' const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined @@ -172,23 +216,38 @@ export default defineComponent({ LinkIcon, ListViewIcon, NcAppContent, + NcActions, + NcActionButton, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, - PlusIcon, AccountPlusIcon, UploadPicker, ViewGridIcon, + IconAlertCircleOutline, + IconReload, }, mixins: [ - filesListWidthMixin, filesSortingMixin, ], + props: { + isPublic: { + type: Boolean, + default: false, + }, + }, + setup() { + const { currentView } = useNavigation() + const { directory, fileId } = useRouteParameters() + const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() const filesStore = useFilesStore() + const filtersStore = useFiltersStore() const pathsStore = usePathsStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() @@ -196,142 +255,142 @@ export default defineComponent({ const viewConfigStore = useViewConfigStore() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) + const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', []) return { + currentView, + directory, + fileId, + fileListWidth, + t, + + activeStore, filesStore, + filtersStore, pathsStore, selectionStore, uploaderStore, userConfigStore, viewConfigStore, + + // non reactive data enableGridView, + forbiddenCharacters, + ShareType, } }, data() { return { - filterText: '', loading: true, - promise: null, - Type, + loadingAction: null as string | null, + error: null as string | null, + promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null, - _unsubscribeStore: () => {}, + dirContentsFiltered: [] as INode[], } }, computed: { - userConfig(): UserConfig { - return this.userConfigStore.userConfig + /** + * Get a callback function for the uploader to fetch directory contents for conflict resolution + */ + getContent() { + const view = this.currentView! + return async (path?: string) => { + // as the path is allowed to be undefined we need to normalize the path ('//' to '/') + const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`) + // Try cache first + const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath) + if (nodes.length > 0) { + return nodes + } + // If not found in the files store (cache) + // use the current view to fetch the content for the requested path + return (await view.getContents(normalizedPath)).contents + } }, - currentView(): View { - return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files')) + userConfig(): UserConfig { + return this.userConfigStore.userConfig }, pageHeading(): string { - return this.currentView?.name ?? this.t('files', 'Files') - }, + const title = this.currentView?.name ?? t('files', 'Files') - /** - * The current directory query. - */ - dir(): string { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') + if (this.currentFolder === undefined || this.directory === '/') { + return title + } + return `${this.currentFolder.displayname} - ${title}` }, /** * The current folder. */ - currentFolder(): Folder | undefined { + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) + if (!this.currentView?.id) { - return + return dummyFolder } - if (this.dir === '/') { - return this.filesStore.getRoot(this.currentView.id) - } - const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) - return this.filesStore.getNode(fileId) + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder }, - /** - * Directory content sorting parameters - * Provided by an extra computed property for caching - */ - sortingParameters() { - const identifiers = [ - // 1: Sort favorites first if enabled - ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []), - // 2: Sort folders first if sorting by name - ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []), - // 3: Use sorting mode if NOT basename (to be able to use displayName too) - ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []), - // 4: Use displayName if available, fallback to name - v => v.attributes?.displayName || v.basename, - // 5: Finally, use basename if all previous sorting methods failed - v => v.basename, - ] - const orders = [ - // (for 1): always sort favorites before normal files - ...(this.userConfig.sort_favorites_first ? ['asc'] : []), - // (for 2): always sort folders before files - ...(this.userConfig.sort_folders_first ? ['asc'] : []), - // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower - ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []), - // (also for 3 so make sure not to conflict with 2 and 3) - ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []), - // for 4: use configured sorting direction - this.isAscSorting ? 'asc' : 'desc', - // for 5: use configured sorting direction - this.isAscSorting ? 'asc' : 'desc', - ] - return [identifiers, orders] as const + dirContents(): Node[] { + return (this.currentFolder?._children || []) + .map(this.filesStore.getNode) + .filter((node: Node) => !!node) }, /** * The current directory contents. */ - dirContentsSorted(): Node[] { + dirContentsSorted(): INode[] { if (!this.currentView) { return [] } - let filteredDirContent = [...this.dirContents] - // Filter based on the filterText obtained from nextcloud:unified-search.search event. - if (this.filterText) { - filteredDirContent = filteredDirContent.filter(node => { - return node.attributes.basename.toLowerCase().includes(this.filterText.toLowerCase()) - }) - console.debug('Files view filtered', filteredDirContent) - } - const customColumn = (this.currentView?.columns || []) .find(column => column.id === this.sortingMode) // Custom column must provide their own sorting methods if (customColumn?.sort && typeof customColumn.sort === 'function') { - const results = [...this.dirContents].sort(customColumn.sort) + const results = [...this.dirContentsFiltered].sort(customColumn.sort) return this.isAscSorting ? results : results.reverse() } - return orderBy( - filteredDirContent, - ...this.sortingParameters, - ) - }, - - dirContents(): Node[] { - const showHidden = this.userConfigStore?.userConfig.show_hidden - return (this.currentFolder?._children || []) - .map(this.getNode) - .filter(file => { - if (!showHidden) { - return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.') + const nodes = sortNodes(this.dirContentsFiltered, { + sortFavoritesFirst: this.userConfig.sort_favorites_first, + sortFoldersFirst: this.userConfig.sort_folders_first, + sortingMode: this.sortingMode, + sortingOrder: this.isAscSorting ? 'asc' : 'desc', + }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 } - - return !!file + return 1 }) + } + + return nodes }, /** @@ -356,43 +415,43 @@ export default defineComponent({ * Route to the previous directory. */ toPreviousDir(): Route { - const dir = this.dir.split('/').slice(0, -1).join('/') || '/' + const dir = this.directory.split('/').slice(0, -1).join('/') || '/' return { ...this.$route, query: { dir } } }, - shareAttributes(): number[] | undefined { + shareTypesAttributes(): number[] | undefined { if (!this.currentFolder?.attributes?.['share-types']) { return undefined } return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[] }, shareButtonLabel() { - if (!this.shareAttributes) { - return this.t('files', 'Share') + if (!this.shareTypesAttributes) { + return t('files', 'Share') } - if (this.shareButtonType === Type.SHARE_TYPE_LINK) { - return this.t('files', 'Shared by link') + if (this.shareButtonType === ShareType.Link) { + return t('files', 'Shared by link') } - return this.t('files', 'Shared') + return t('files', 'Shared') }, - shareButtonType(): Type | null { - if (!this.shareAttributes) { + shareButtonType(): ShareType | null { + if (!this.shareTypesAttributes) { return null } // If all types are links, show the link icon - if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) { - return Type.SHARE_TYPE_LINK + if (this.shareTypesAttributes.some(type => type === ShareType.Link)) { + return ShareType.Link } - return Type.SHARE_TYPE_USER + return ShareType.User }, gridViewButtonLabel() { return this.userConfig.grid_view - ? this.t('files', 'Switch to list view') - : this.t('files', 'Switch to grid view') + ? t('files', 'Switch to list view') + : t('files', 'Switch to grid view') }, /** @@ -404,23 +463,72 @@ export default defineComponent({ isQuotaExceeded() { return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 }, - cantUploadLabel() { - if (this.isQuotaExceeded) { - return this.t('files', 'Your have used your space quota and cannot upload files anymore') - } - return this.t('files', 'You don’t have permission to upload or create files here') - }, /** * Check if current folder has share permissions */ canShare() { - return isSharingEnabled + return isSharingEnabled && !this.isPublic && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 }, + + showCustomEmptyView() { + return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined + }, + + enabledFileListActions() { + if (!this.currentView || !this.currentFolder) { + return [] + } + + const actions = getFileListActions() + const enabledActions = actions + .filter(action => { + if (action.enabled === undefined) { + return true + } + return action.enabled( + this.currentView!, + this.dirContents, + this.currentFolder as Folder, + ) + }) + .toSorted((a, b) => a.order - b.order) + return enabledActions + }, + + /** + * Using the filtered content if filters are active + */ + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) + }, + + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, }, watch: { + /** + * Handle rendering the custom empty view + * @param show The current state if the custom empty view should be rendered + */ + showCustomEmptyView(show: boolean) { + if (show) { + this.$nextTick(() => { + const el = this.$refs.customEmptyView as HTMLDivElement + // We can cast here because "showCustomEmptyView" assets that current view is set + this.currentView!.emptyView!(el) + }) + } + }, + + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + currentView(newView, oldView) { if (newView?.id === oldView?.id) { return @@ -428,59 +536,97 @@ export default defineComponent({ logger.debug('View changed', { newView, oldView }) this.selectionStore.reset() - this.resetSearch() this.fetchContent() }, - dir(newDir, oldDir) { + directory(newDir, oldDir) { logger.debug('Directory changed', { newDir, oldDir }) // TODO: preserve selection on browsing? this.selectionStore.reset() - this.resetSearch() + if (window.OCA.Files.Sidebar?.close) { + window.OCA.Files.Sidebar.close() + } this.fetchContent() // Scroll to top, force virtual scroller to re-render - if (this.$refs?.filesListVirtual?.$el) { - this.$refs.filesListVirtual.$el.scrollTop = 0 + const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined + if (filesListVirtual?.$el) { + filesListVirtual.$el.scrollTop = 0 } }, dirContents(contents) { logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents }) emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents }) + // Also refresh the filtered content + this.filterDirContent() }, }, - mounted() { - this.fetchContent() + async mounted() { + subscribe('files:node:deleted', this.onNodeDeleted) subscribe('files:node:updated', this.onUpdatedNode) - subscribe('nextcloud:unified-search.search', this.onSearch) - subscribe('nextcloud:unified-search.reset', this.onSearch) // reload on settings change - this._unsubscribeStore = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true }) + subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } }, unmounted() { + unsubscribe('files:node:deleted', this.onNodeDeleted) unsubscribe('files:node:updated', this.onUpdatedNode) - unsubscribe('nextcloud:unified-search.search', this.onSearch) - unsubscribe('nextcloud:unified-search.reset', this.onSearch) - this._unsubscribeStore() + unsubscribe('files:config:updated', this.fetchContent) + unsubscribe('files:filters:changed', this.filterDirContent) + unsubscribe('files:search:updated', this.onUpdateSearch) }, methods: { + onUpdateSearch({ query, scope }) { + if (query && scope !== 'filter') { + this.debouncedFetchContent() + } + }, + async fetchContent() { this.loading = true - const dir = this.dir + this.error = null + const dir = this.directory const currentView = this.currentView if (!currentView) { - logger.debug('The current view doesn\'t exists or is not ready.', { currentView }) + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) return } + logger.debug('Fetching contents for directory', { dir, currentView }) + // If we have a cancellable promise ongoing, cancel it - if (typeof this.promise?.cancel === 'function') { + if (this.promise && 'cancel' in this.promise) { this.promise.cancel() logger.debug('Cancelled previous ongoing fetch') } @@ -496,7 +642,7 @@ export default defineComponent({ // Define current directory children // TODO: make it more official - this.$set(folder, '_children', contents.map(node => node.fileid)) + this.$set(folder, '_children', contents.map(node => node.source)) // If we're in the root dir, define the root if (dir === '/') { @@ -505,20 +651,21 @@ export default defineComponent({ // Otherwise, add the folder to the store if (folder.fileid) { this.filesStore.updateNodes([folder]) - this.pathsStore.addPath({ service: currentView.id, fileid: folder.fileid, path: dir }) + this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir }) } else { // If we're here, the view API messed up - logger.error('Invalid root folder returned', { dir, folder, currentView }) + logger.fatal('Invalid root folder returned', { dir, folder, currentView }) } } // Update paths store const folders = contents.filter(node => node.type === 'folder') - folders.forEach(node => { - this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) }) + folders.forEach((node) => { + this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) }) }) } catch (error) { logger.error('Error while fetching content', { error }) + this.error = humanizeWebDAVError(error) } finally { this.loading = false } @@ -526,13 +673,28 @@ export default defineComponent({ }, /** - * Get a cached note from the store - * - * @param {number} fileId the file id to get - * @return {Folder|File} + * Handle the node deleted event to reset open file + * @param node The deleted node */ - getNode(fileId) { - return this.filesStore.getNode(fileId) + onNodeDeleted(node: Node) { + if (node.fileid && node.fileid === this.fileId) { + if (node.fileid === this.currentFolder?.fileid) { + // Handle the edge case that the current directory is deleted + // in this case we need to keep the current view but move to the parent directory + window.OCP.Files.Router.goToRoute( + null, + { view: this.currentView!.id }, + { dir: this.currentFolder?.dirname ?? '/' }, + ) + } else { + // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: undefined }, + { ...this.$route.query, openfile: undefined }, + ) + } + } }, /** @@ -542,8 +704,7 @@ export default defineComponent({ onUpload(upload: Upload) { // Let's only refresh the current Folder // Navigating to a different folder will refresh it anyway - const destinationSource = dirname(upload.source) - const needsRefresh = destinationSource === this.currentFolder?.source + const needsRefresh = dirname(upload.source) === this.currentFolder!.source // TODO: fetch uploaded files data only // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid @@ -556,39 +717,46 @@ export default defineComponent({ async onUploadFail(upload: Upload) { const status = upload.response?.status || 0 + if (upload.status === UploadStatus.CANCELLED) { + showWarning(t('files', 'Upload was cancelled by user')) + return + } + // Check known status codes if (status === 507) { - showError(this.t('files', 'Not enough free space')) + showError(t('files', 'Not enough free space')) return } else if (status === 404 || status === 409) { - showError(this.t('files', 'Target folder does not exist any more')) + showError(t('files', 'Target folder does not exist any more')) return } else if (status === 403) { - showError(this.t('files', 'Operation is blocked by access control')) + showError(t('files', 'Operation is blocked by access control')) return } // Else we try to parse the response error message - try { - const parser = new Parser({ trim: true, explicitRoot: false }) - const response = await parser.parseStringPromise(upload.response?.data) - const message = response['s:message'][0] as string - if (typeof message === 'string' && message.trim() !== '') { - // The server message is also translated - showError(this.t('files', 'Error during upload: {message}', { message })) - return + if (typeof upload.response?.data === 'string') { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(upload.response.data, 'text/xml') + const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? '' + if (message.trim() !== '') { + // The server message is also translated + showError(t('files', 'Error during upload: {message}', { message })) + return + } + } catch (error) { + logger.error('Could not parse message', { error }) } - } catch (error) { - logger.error('Error while parsing', { error }) } // Finally, check the status code if we have one if (status !== 0) { - showError(this.t('files', 'Error during upload, status code {status}', { status })) + showError(t('files', 'Error during upload, status code {status}', { status })) return } - showError(this.t('files', 'Unknown error during upload')) + showError(t('files', 'Unknown error during upload')) }, /** @@ -601,22 +769,6 @@ export default defineComponent({ this.fetchContent() } }, - /** - * Handle search event from unified search. - * - * @param searchEvent is event object. - */ - onSearch: debounce(function(searchEvent) { - console.debug('Files app handling search event from unified search...', searchEvent) - this.filterText = searchEvent.query - }, 500), - - /** - * Reset the search query - */ - resetSearch() { - this.filterText = '' - }, openSharingSidebar() { if (!this.currentFolder) { @@ -627,19 +779,66 @@ export default defineComponent({ if (window?.OCA?.Files?.Sidebar?.setActiveTab) { window.OCA.Files.Sidebar.setActiveTab('sharing') } - sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path) + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) }, + toggleGridView() { this.userConfigStore.update('grid_view', !this.userConfig.grid_view) }, - t: translate, - n: translatePlural, + filterDirContent() { + let nodes: INode[] = this.dirContents + for (const filter of this.filtersStore.sortedFilters) { + nodes = filter.filter(nodes) + } + this.dirContentsFiltered = nodes + }, + + actionDisplayName(action: FileListAction): string { + let displayName = action.id + try { + displayName = action.displayName(this.currentView!) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + return displayName + }, + + async execFileListAction(action: FileListAction) { + this.loadingAction = action.id + + const displayName = this.actionDisplayName(action) + try { + const success = await action.exec(this.source, this.dirContents, this.currentDir) + // If the action returns null, we stay silent + if (success === null || success === undefined) { + return + } + + if (success) { + showSuccess(t('files', '{displayName}: done', { displayName })) + return + } + showError(t('files', '{displayName}: failed', { displayName })) + } catch (error) { + logger.error('Error while executing action', { action, error }) + showError(t('files', '{displayName}: failed', { displayName })) + } finally { + this.loadingAction = null + } + }, }, }) </script> <style scoped lang="scss"> +:global(.toast-loading-icon) { + // Reduce start margin (it was made for text but this is an icon) + margin-inline-start: -4px; + // 16px icon + 5px on both sides + min-width: 26px; +} + .app-content { // Virtual list needs to be full height and is scrollable display: flex; @@ -660,6 +859,11 @@ export default defineComponent({ margin-block: var(--app-navigation-padding, 4px); margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px); + &--public { + // There is no navigation toggle on public shares + margin-inline: 0 var(--app-navigation-padding, 4px); + } + >* { // Do not grow or shrink (horizontally) // Only the breadcrumbs shrinks @@ -673,12 +877,29 @@ export default defineComponent({ color: var(--color-main-text) !important; } } + + &-actions { + min-width: fit-content !important; + margin-inline: calc(var(--default-grid-baseline) * 2); + } + } + + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + + &__empty-view-wrapper { + display: flex; + height: 100%; } &__refresh-icon { - flex: 0 0 44px; - width: 44px; - height: 44px; + flex: 0 0 var(--default-clickable-area); + width: var(--default-clickable-area); + height: var(--default-clickable-area); } &__loading-icon { |