diff options
Diffstat (limited to 'apps/files/src/components')
18 files changed, 472 insertions, 82 deletions
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 38d07c94d80..c7684d5c205 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -235,7 +235,7 @@ export default defineComponent({ justify-content: center; width: 100%; // Breadcrumbs height + row thead height - min-height: calc(58px + 55px); + min-height: calc(58px + 44px); margin: 0; user-select: none; color: var(--color-text-maxcontrast); diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue index 7c9c6f4f1a7..72fd98d43fb 100644 --- a/apps/files/src/components/DragAndDropPreview.vue +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -92,7 +92,7 @@ export default Vue.extend({ </script> <style lang="scss"> -$size: 32px; +$size: 28px; $stack-shift: 6px; .files-list-drag-image { @@ -102,24 +102,24 @@ $stack-shift: 6px; display: flex; overflow: hidden; align-items: center; - height: 44px; - padding: 6px 12px; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; background: var(--color-main-background); &__icon, - .files-list__row-icon { + .files-list__row-icon-preview-container { display: flex; overflow: hidden; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: $size - $stack-shift; + height: $size - $stack-shift;; border-radius: var(--border-radius); } &__icon { overflow: visible; - margin-inline-end: 12px; + margin-inline-end: $stack-shift * 2; img { max-width: 100%; @@ -138,13 +138,15 @@ $stack-shift: 6px; display: flex; // Stack effect if more than one element - .files-list__row-icon + .files-list__row-icon { + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { margin-top: $stack-shift; - margin-inline-start: $stack-shift - $size; - & + .files-list__row-icon { + margin-inline-start: $stack-shift * 2 - $size; + & + .files-list__row-icon-preview-container { margin-top: $stack-shift * 2; } } + // If we have manually clone the preview, // let's hide any fallback icons &:not(:empty) + * { diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 9642c4709d8..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -49,6 +49,15 @@ :opened.sync="openedMenu" :source="source" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + :title="mime" + class="files-list__row-mime" + data-cy-files-list-row-mime + @click="openDetailsIfAvailable"> + <span>{{ mime }}</span> + </td> + <!-- Size --> <td v-if="!compact && isSizeAvailable" :style="sizeOpacity" @@ -85,9 +94,10 @@ </template> <script lang="ts"> -import { formatFileSize } from '@nextcloud/files' +import { FileType, formatFileSize } from '@nextcloud/files' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' import NcDateTime from '@nextcloud/vue/components/NcDateTime' import { useNavigation } from '../composables/useNavigation.ts' @@ -123,6 +133,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, @@ -186,6 +200,36 @@ export default defineComponent({ return this.currentView.columns || [] }, + mime() { + if (this.source.type === FileType.Folder) { + return this.t('files', 'Folder') + } + + if (!this.source.mime || this.source.mime === 'application/octet-stream') { + return t('files', 'Unknown file type') + } + + if (window.OC?.MimeTypeList?.names?.[this.source.mime]) { + return window.OC.MimeTypeList.names[this.source.mime] + } + + const baseType = this.source.mime.split('/')[0] + const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || '' + if (baseType === 'image') { + return t('files', '{ext} image', { ext }) + } + if (baseType === 'video') { + return t('files', '{ext} video', { ext }) + } + if (baseType === 'audio') { + return t('files', '{ext} audio', { ext }) + } + if (baseType === 'text') { + return t('files', '{ext} text', { ext }) + } + + return this.source.mime + }, size() { const size = this.source.size if (size === undefined || isNaN(size) || size < 0) { diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue index 87684758b43..c66cb8fbd7f 100644 --- a/apps/files/src/components/FileEntry/FavoriteIcon.vue +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -56,8 +56,8 @@ export default defineComponent({ :deep() { svg { // We added a stroke for a11y so we must increase the size to include the stroke - width: 26px !important; - height: 26px !important; + width: 20px !important; + height: 20px !important; // Override NcIconSvgWrapper defaults of 20px max-width: unset !important; diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index c130ab49c0a..5c537d878fe 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -25,15 +25,16 @@ :open="openedMenu" @close="onMenuClose" @closed="onMenuClosed"> - <!-- Default actions list--> - <NcActionButton v-for="action, index in enabledMenuActions" + <!-- Non-destructive actions list --> + <!-- Please keep this block in sync with the destructive actions block below --> + <NcActionButton v-for="action, index in renderedNonDestructiveActions" :key="action.id" :ref="`action-${action.id}`" class="files-list__row-action" :class="{ [`files-list__row-action-${action.id}`]: true, 'files-list__row-action--inline': index < enabledInlineActions.length, - 'files-list__row-action--menu': isValidMenu(action) + 'files-list__row-action--menu': isValidMenu(action), }" :close-after-click="!isValidMenu(action)" :data-cy-files-list-row-action="action.id" @@ -50,6 +51,35 @@ {{ actionDisplayName(action) }} </NcActionButton> + <!-- Destructive actions list --> + <template v-if="renderedDestructiveActions.length > 0"> + <NcActionSeparator /> + <NcActionButton v-for="action, index in renderedDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + 'files-list__row-action--destructive': true, + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + <!-- Submenu actions list--> <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> <!-- Back to top-level button --> @@ -68,10 +98,11 @@ class="files-list__row-action--submenu" close-after-click :data-cy-files-list-row-action="action.id" + :aria-label="action.title?.([source], currentView)" :title="action.title?.([source], currentView)" @click="onActionClick(action)"> <template #icon> - <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> </template> {{ actionDisplayName(action) }} @@ -211,6 +242,14 @@ export default defineComponent({ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) }, + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) + }, + openedMenu: { get() { return this.opened @@ -281,7 +320,7 @@ export default defineComponent({ } // Make sure we set the node as active - this.activeStore.setActiveNode(this.source) + this.activeStore.activeNode = this.source // Execute the action await executeAction(action) @@ -349,5 +388,12 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper { max-height: var(--max-icon-size) !important; max-width: var(--max-icon-size) !important; } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } + } } + </style> diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 2d5844f851f..506677b49af 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -21,6 +21,7 @@ class="files-list__row-icon-blurhash" aria-hidden="true" /> <img v-if="backgroundFailed !== true" + :key="source.fileid" ref="previewImg" alt="" class="files-list__row-icon-preview" @@ -147,6 +148,17 @@ export default defineComponent({ return null } + if (this.source.attributes['has-preview'] !== true + && this.source.mime !== undefined + && this.source.mime !== 'application/octet-stream' + ) { + const previewUrl = generateUrl('/core/mimeicon?mime={mime}', { + mime: this.source.mime, + }) + const url = new URL(window.location.origin + previewUrl) + return url.href + } + try { const previewUrl = this.source.attributes.previewUrl || (this.isPublic diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index 589073e7b9a..735490c45b3 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -356,7 +356,7 @@ export default defineComponent({ // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab // also if there is no default action use this as a fallback - const metaKeyPressed = event.ctrlKey || event.metaKey || Boolean(event.button & 4) + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 if (metaKeyPressed || !this.defaultFileAction) { // If no download permission, then we can not allow to download (direct link) the files if (isPublicShare() && !isDownloadable(this.source)) { @@ -368,7 +368,9 @@ export default defineComponent({ : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() event.stopPropagation() - window.open(url, metaKeyPressed ? '_self' : undefined) + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') return } diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue new file mode 100644 index 00000000000..938be171f6d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton v-show="isVisible" @click="onClick"> + {{ t('files', 'Search everywhere') }} + </NcButton> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getPinia } from '../../store/index.ts' +import { useSearchStore } from '../../store/search.ts' + +const isVisible = ref(false) + +defineExpose({ + hideButton, + showButton, +}) + +/** + * Hide the button - called by the filter class + */ +function hideButton() { + isVisible.value = false +} + +/** + * Show the button - called by the filter class + */ +function showButton() { + isVisible.value = true +} + +/** + * Button click handler to make the filtering a global search. + */ +function onClick() { + const searchStore = useSearchStore(getPinia()) + searchStore.scope = 'globally' +} +</script> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index cc8dafe344e..31458398028 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -12,6 +12,10 @@ import type { Folder, Header, View } from '@nextcloud/files' import type { PropType } from 'vue' +import PQueue from 'p-queue' + +import logger from '../logger.ts' + /** * This component is used to render custom * elements provided by an API. Vue doesn't allow @@ -34,6 +38,14 @@ export default { required: true, }, }, + setup() { + // Create a queue to ensure that the header is only rendered once at a time + const queue = new PQueue({ concurrency: 1 }) + + return { + queue, + } + }, computed: { enabled() { return this.header.enabled?.(this.currentFolder, this.currentView) ?? true @@ -44,15 +56,45 @@ export default { if (!enabled) { return } - this.header.updated(this.currentFolder, this.currentView) + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) }, - currentFolder() { - this.header.updated(this.currentFolder, this.currentView) + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) }, }, + mounted() { - console.debug('Mounted', this.header.id) - this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, }, } </script> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 63d692c100d..9e8cdc159ee 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -21,6 +21,10 @@ <!-- Actions --> <td class="files-list__row-actions" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + <!-- Size --> <td v-if="isSizeAvailable" class="files-list__column files-list__row-size"> @@ -60,6 +64,10 @@ export default defineComponent({ type: View, required: true, }, + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 341d5e1347d..23e631199eb 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -24,6 +24,14 @@ <!-- Actions --> <th class="files-list__row-actions" /> + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + <!-- Size --> <th v-if="isSizeAvailable" class="files-list__column files-list__row-size" @@ -83,6 +91,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 4d2f2f361e6..53b7e7ef21b 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -6,6 +6,7 @@ <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions> <NcActions ref="actionsMenu" container="#app-content-vue" + :boundaries-element="boundariesElement" :disabled="!!loading || areSomeNodesLoading" :force-name="true" :inline="enabledInlineActions.length" @@ -123,6 +124,8 @@ export default defineComponent({ const fileListWidth = useFileListWidth() const { directory } = useRouteParameters() + const boundariesElement = document.getElementById('app-content-vue') + return { directory, fileListWidth, @@ -130,6 +133,8 @@ export default defineComponent({ actionsMenuStore, filesStore, selectionStore, + + boundariesElement, } }, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 93f567f25a4..fbf614caede 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -9,6 +9,7 @@ :data-sources="nodes" :grid-mode="userConfig.grid_view" :extra-props="{ + isMimeAvailable, isMtimeAvailable, isSizeAvailable, nodes, @@ -20,7 +21,9 @@ </template> <template v-if="!isNoneSelected" #header-overlay> - <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> <FilesListTableHeaderActions :current-view="currentView" :selected-nodes="selectedNodes" /> </template> @@ -39,15 +42,22 @@ <!-- Table header and sort buttons --> <FilesListTableHeader ref="thead" :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" /> </template> + <!-- Body replacement if no files are available --> + <template #empty> + <slot name="empty" /> + </template> + <!-- Tfoot--> <template #footer> <FilesListTableFooter :current-view="currentView" :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" @@ -60,12 +70,11 @@ import type { UserConfig } from '../types' import type { Node as NcNode } from '@nextcloud/files' import type { ComponentPublicInstance, PropType } from 'vue' -import type { Location } from 'vue-router' import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' +import { n, t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' @@ -76,6 +85,7 @@ import { useFileListWidth } from '../composables/useFileListWidth.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useSelectionStore } from '../store/selection.js' import { useUserConfigStore } from '../store/userconfig.ts' +import logger from '../logger.ts' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' @@ -85,7 +95,6 @@ import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import VirtualList from './VirtualList.vue' -import logger from '../logger.ts' export default defineComponent({ name: 'FilesListVirtual', @@ -137,6 +146,7 @@ export default defineComponent({ selectionStore, userConfigStore, + n, t, } }, @@ -146,7 +156,6 @@ export default defineComponent({ FileEntry, FileEntryGrid, scrollToIndex: 0, - openFileId: null as number|null, } }, @@ -155,6 +164,16 @@ export default defineComponent({ return this.userConfigStore.userConfig }, + isMimeAvailable() { + if (!this.userConfig.show_mime_column) { + return false + } + // Hide mime column on narrow screens + if (this.fileListWidth < 1024) { + return false + } + return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream') + }, isMtimeAvailable() { // Hide mtime column on narrow screens if (this.fileListWidth < 768) { @@ -201,39 +220,26 @@ export default defineComponent({ isNoneSelected() { return this.selectedNodes.length === 0 }, + + isEmpty() { + return this.nodes.length === 0 + }, }, watch: { - fileId: { - handler(fileId) { - this.scrollToFile(fileId, false) - }, - immediate: true, + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() }, - - openFile: { - handler(openFile) { - if (!openFile || !this.fileId) { - return - } - - this.handleOpenFile(this.fileId) - }, - immediate: true, + fileId() { + this.handleOpenQueries() }, - - openDetails: { - handler(openDetails) { - // wait for scrolling and updating the actions to settle - this.$nextTick(() => { - if (!openDetails || !this.fileId) { - return - } - - this.openSidebarForFile(this.fileId) - }) - }, - immediate: true, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() }, }, @@ -263,6 +269,33 @@ export default defineComponent({ }, methods: { + handleOpenQueries() { + // If the list is empty, or we don't have a fileId, + // there's nothing to be done. + if (this.isEmpty || !this.fileId) { + return + } + + logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', { + nodes: this.nodes, + fileId: this.fileId, + openFile: this.openFile, + openDetails: this.openDetails, + }) + + if (this.openFile) { + this.handleOpenFile(this.fileId) + } + + if (this.openDetails) { + this.openSidebarForFile(this.fileId) + } + + if (this.fileId) { + this.scrollToFile(this.fileId, false) + } + }, + openSidebarForFile(fileId) { // Open the sidebar for the given URL fileid // iif we just loaded the app. @@ -272,7 +305,7 @@ export default defineComponent({ sidebarAction.exec(node, this.currentView, this.currentFolder.path) return } - logger.error(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) + logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) }, scrollToFile(fileId: number|null, warn = true) { @@ -288,6 +321,7 @@ export default defineComponent({ } this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) } }, @@ -299,7 +333,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.clearActiveNode() + this.activeStore.activeNode = undefined window.OCP.Files.Router.goToRoute( null, { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, @@ -352,15 +386,13 @@ export default defineComponent({ } // The file is either a folder or has no default action other than downloading // in this case we need to open the details instead and remove the route from the history - const query = this.$route.query - delete query.openfile - query.opendetails = '' - logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node }) - await this.$router.replace({ - ...(this.$route as Location), - query, - }) + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + { ...this.$route.query, openfile: undefined, opendetails: '' }, + true, // silent update of the URL + ) }, onDragOver(event: DragEvent) { @@ -433,7 +465,7 @@ export default defineComponent({ delete query.openfile delete query.opendetails - this.activeStore.setActiveNode(node) + this.activeStore.activeNode = node // Silent update of the URL window.OCP.Files.Router.goToRoute( @@ -449,15 +481,17 @@ export default defineComponent({ <style scoped lang="scss"> .files-list { - --row-height: 55px; + --row-height: 44px; --cell-margin: 14px; --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); --checkbox-size: 24px; --clickable-area: var(--default-clickable-area); - --icon-preview-size: 32px; + --icon-preview-size: 24px; --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; @@ -505,6 +539,13 @@ export default defineComponent({ // Hide the table header below the overlay margin-block-start: calc(-1 * var(--row-height)); } + + // Visually hide the table when there are no files + &--hidden { + visibility: hidden; + z-index: -1; + opacity: 0; + } } .files-list__filters { @@ -536,6 +577,7 @@ export default defineComponent({ background-color: var(--color-main-background); border-block-end: 1px solid var(--color-border); height: var(--row-height); + flex: 0 0 var(--row-height); } .files-list__thead, @@ -554,6 +596,16 @@ export default defineComponent({ top: var(--fixed-block-start-position); } + // Empty content + .files-list__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + tr { position: relative; display: flex; @@ -723,8 +775,8 @@ export default defineComponent({ // File and folder overlay &-overlay { position: absolute; - max-height: calc(var(--icon-preview-size) * 0.5); - max-width: calc(var(--icon-preview-size) * 0.5); + max-height: calc(var(--icon-preview-size) * 0.6); + max-width: calc(var(--icon-preview-size) * 0.6); color: var(--color-primary-element-text); // better alignment with the folder icon margin-block-start: 2px; @@ -829,10 +881,12 @@ export default defineComponent({ margin-inline-end: 7px; } + .files-list__row-mime, .files-list__row-mtime, .files-list__row-size { color: var(--color-text-maxcontrast); } + .files-list__row-size { width: calc(var(--row-height) * 1.5); // Right align content/text @@ -840,7 +894,11 @@ export default defineComponent({ } .files-list__row-mtime { - width: calc(var(--row-height) * 2); + width: calc(var(--row-height) * 2.5); + } + + .files-list__row-mime { + width: calc(var(--row-height) * 2.5); } .files-list__row-column-custom { diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue index 372a83e1441..c29bc00c67f 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -89,7 +89,7 @@ export default defineComponent({ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) } - return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids + return this.filterVisible(this.views[this.parent.id] ?? []) }, style() { @@ -103,11 +103,15 @@ export default defineComponent({ }, methods: { + filterVisible(views: View[]) { + return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + }, + hasChildViews(view: View): boolean { if (this.level >= maxLevel) { return false } - return this.views[view.id]?.length > 0 + return this.filterVisible(this.views[view.id] ?? []).length > 0 }, /** diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..e34d4bf0971 --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnify, mdiSearchWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useSearchStore } from '../store/search.ts' +import { VIEW_ID } from '../views/search.ts' + +const { currentView } = useNavigation(true) +const searchStore = useSearchStore() + +/** + * When the route is changed from search view to something different + * we need to clear the search box. + */ +onBeforeNavigation((to, from, next) => { + if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) { + // we are leaving the search view so unset the query + searchStore.query = '' + searchStore.scope = 'filter' + } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) { + // fix the query if the user refreshed the view + if (searchStore.query && !to.query.query) { + // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3) + return next({ + ...to, + query: { + ...to.query, + query: searchStore.query, + }, + }) + } + } + next() +}) + +/** + * Are we currently on the search view. + * Needed to disable the action menu (we cannot change the search mode there) + */ +const isSearchView = computed(() => currentView.value.id === VIEW_ID) + +/** + * Different searchbox label depending if filtering or searching + */ +const searchLabel = computed(() => { + if (searchStore.scope === 'globally') { + return t('files', 'Search globally by filename …') + } + return t('files', 'Search here by filename …') +}) +</script> + +<template> + <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel"> + <template #actions> + <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView"> + <template #icon> + <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" /> + </template> + <NcActionButton close-after-click @click="searchStore.scope = 'filter'"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + {{ t('files', 'Filter and search from this location') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search globally') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index f1d2738e81d..fd10af1c495 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -58,7 +58,7 @@ export default { computed: { storageStatsTitle() { const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) - const quotaByte = formatFileSize(this.storageStats?.quota, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue index 76555db1536..ca10935940d 100644 --- a/apps/files/src/components/NewNodeDialog.vue +++ b/apps/files/src/components/NewNodeDialog.vue @@ -26,6 +26,11 @@ :helper-text="validity" :label="label" :value.sync="localDefaultName" /> + + <!-- Hidden file warning --> + <NcNoteCard v-if="isHiddenFileName" + type="warning" + :text="t('files', 'Files starting with a dot are hidden by default')" /> </form> </NcDialog> </template> @@ -35,12 +40,13 @@ import type { ComponentPublicInstance, PropType } from 'vue' import { getUniqueName } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { extname } from 'path' -import { nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' import { getFilenameValidity } from '../utils/filenameValidity.ts' import NcButton from '@nextcloud/vue/components/NcButton' import NcDialog from '@nextcloud/vue/components/NcDialog' import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' const props = defineProps({ /** @@ -89,6 +95,11 @@ const nameInput = ref<ComponentPublicInstance>() const formElement = ref<HTMLFormElement>() const validity = ref('') +const isHiddenFileName = computed(() => { + // Check if the name starts with a dot, which indicates a hidden file + return localDefaultName.value.trim().startsWith('.') +}) + /** * Focus the filename input field */ diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 5ae8220d594..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -20,7 +20,18 @@ <slot name="header-overlay" /> </div> - <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }"> + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> + </div> + + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -146,7 +157,7 @@ export default defineComponent({ itemHeight() { // Align with css in FilesListVirtual // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) - return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55 + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, // Grid mode only @@ -309,7 +320,7 @@ export default defineComponent({ methods: { scrollTo(index: number) { - if (!this.$el) { + if (!this.$el || this.index === index) { return } |