aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components')
-rw-r--r--apps/files/src/components/BreadCrumbs.vue242
-rw-r--r--apps/files/src/components/CustomElementRender.vue23
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue157
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue49
-rw-r--r--apps/files/src/components/FileEntry.vue477
-rw-r--r--apps/files/src/components/FileEntry/CollectivesIcon.vue4
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue33
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue347
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue101
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue282
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue164
-rw-r--r--apps/files/src/components/FileEntryGrid.vue364
-rw-r--r--apps/files/src/components/FileEntryMixin.ts509
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilter.vue53
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterModified.vue107
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterToSearch.vue47
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterType.vue122
-rw-r--r--apps/files/src/components/FileListFilters.vue74
-rw-r--r--apps/files/src/components/FilesListHeader.vue86
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue69
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue89
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue246
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue29
-rw-r--r--apps/files/src/components/FilesListVirtual.vue618
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue182
-rw-r--r--apps/files/src/components/FilesNavigationSearch.vue86
-rw-r--r--apps/files/src/components/LegacyView.vue27
-rw-r--r--apps/files/src/components/NavigationQuota.vue50
-rw-r--r--apps/files/src/components/NewNodeDialog.vue168
-rw-r--r--apps/files/src/components/PersonalSettings.vue22
-rw-r--r--apps/files/src/components/Setting.vue23
-rw-r--r--apps/files/src/components/SidebarTab.vue29
-rw-r--r--apps/files/src/components/TemplateFiller.vue122
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue68
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateRichTextField.vue77
-rw-r--r--apps/files/src/components/TemplatePreview.vue47
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue59
-rw-r--r--apps/files/src/components/VirtualList.vue281
38 files changed, 3651 insertions, 1882 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index f50a4d14fd8..8458fd65f3d 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -1,40 +1,28 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <NcBreadcrumbs
- data-cy-files-content-breadcrumbs
- :aria-label="t('files', 'Current directory path')">
+ <NcBreadcrumbs data-cy-files-content-breadcrumbs
+ :aria-label="t('files', 'Current directory path')"
+ class="files-list__breadcrumbs"
+ :class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }">
<!-- Current path sections -->
<NcBreadcrumb v-for="(section, index) in sections"
:key="section.dir"
v-bind="section"
dir="auto"
:to="section.to"
+ :force-icon-text="index === 0 && fileListWidth >= 486"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
- @click.native="onClick(section.to)">
+ @click.native="onClick(section.to)"
+ @dragover.native="onDragOver($event, section.dir)"
+ @drop="onDrop($event, section.dir)">
<template v-if="index === 0" #icon>
- <Home :size="20"/>
+ <NcIconSvgWrapper :size="20"
+ :svg="viewIcon" />
</template>
</NcBreadcrumb>
@@ -47,24 +35,35 @@
<script lang="ts">
import type { Node } from '@nextcloud/files'
+import type { FileSource } from '../types.ts'
-import { translate as t} from '@nextcloud/l10n'
import { basename } from 'path'
-import Home from 'vue-material-design-icons/Home.vue'
-import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
-import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
import { defineComponent } from 'vue'
+import { Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import HomeSvg from '@mdi/svg/svg/home.svg?raw'
+import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb'
+import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { useNavigation } from '../composables/useNavigation.ts'
+import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { showError } from '@nextcloud/dialogs'
+import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import { useUploaderStore } from '../store/uploader.ts'
+import logger from '../logger'
export default defineComponent({
name: 'BreadCrumbs',
components: {
- Home,
NcBreadcrumbs,
NcBreadcrumb,
+ NcIconSvgWrapper,
},
props: {
@@ -75,19 +74,28 @@ export default defineComponent({
},
setup() {
+ const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const pathsStore = usePathsStore()
+ const selectionStore = useSelectionStore()
+ const uploaderStore = useUploaderStore()
+ const fileListWidth = useFileListWidth()
+ const { currentView, views } = useNavigation()
+
return {
+ draggingStore,
filesStore,
pathsStore,
+ selectionStore,
+ uploaderStore,
+
+ currentView,
+ fileListWidth,
+ views,
}
},
computed: {
- currentView() {
- return this.$navigation.active
- },
-
dirs(): string[] {
const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`)
// Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
@@ -97,34 +105,83 @@ export default defineComponent({
},
sections() {
- return this.dirs.map((dir: string) => {
- const fileid = this.getFileIdFromPath(dir)
- const to = { ...this.$route, params: { fileid }, query: { dir } }
+ return this.dirs.map((dir: string, index: number) => {
+ const source = this.getFileSourceFromPath(dir)
+ const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
return {
dir,
exact: true,
name: this.getDirDisplayName(dir),
- to,
+ to: this.getTo(dir, node),
+ // disable drop on current directory
+ disableDrop: index === this.dirs.length - 1,
}
})
},
+
+ isUploadInProgress(): boolean {
+ return this.uploaderStore.queue.length !== 0
+ },
+
+ // Hide breadcrumbs if an upload is ongoing
+ wrapUploadProgressBar(): boolean {
+ // if an upload is ongoing, and on small screens / mobile, then
+ // show the progress bar for the upload below breadcrumbs
+ return this.isUploadInProgress && this.fileListWidth < 512
+ },
+
+ // used to show the views icon for the first breadcrumb
+ viewIcon(): string {
+ return this.currentView?.icon ?? HomeSvg
+ },
+
+ selectedFiles() {
+ return this.selectionStore.selected as FileSource[]
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging as FileSource[]
+ },
},
methods: {
- getNodeFromId(id: number): Node | undefined {
- return this.filesStore.getNode(id)
+ getNodeFromSource(source: FileSource): Node | undefined {
+ return this.filesStore.getNode(source)
},
- getFileIdFromPath(path: string): number | undefined {
- return this.pathsStore.getPath(this.currentView?.id, path)
+ getFileSourceFromPath(path: string): FileSource | null {
+ return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
},
getDirDisplayName(path: string): string {
if (path === '/') {
- return t('files', 'Home')
+ return this.currentView?.name || t('files', 'Home')
}
- const fileId: number | undefined = this.getFileIdFromPath(path)
- const node: Node | undefined = (fileId) ? this.getNodeFromId(fileId) : undefined
- return node?.attributes?.displayName || basename(path)
+ const source = this.getFileSourceFromPath(path)
+ const node = source ? this.getNodeFromSource(source) : undefined
+ return node?.displayname || basename(path)
+ },
+
+ getTo(dir: string, node?: Node): Record<string, unknown> {
+ if (dir === '/') {
+ return {
+ ...this.$route,
+ params: { view: this.currentView?.id },
+ query: {},
+ }
+ }
+ if (node === undefined) {
+ const view = this.views.find(view => view.params?.dir === dir)
+ return {
+ ...this.$route,
+ params: { fileid: view?.params?.fileid ?? '' },
+ query: { dir },
+ }
+ }
+ return {
+ ...this.$route,
+ params: { fileid: String(node.fileid) },
+ query: { dir: node.path },
+ }
},
onClick(to) {
@@ -133,6 +190,81 @@ export default defineComponent({
}
},
+ onDragOver(event: DragEvent, path: string) {
+ if (!event.dataTransfer) {
+ return
+ }
+
+ // Cannot drop on the current directory
+ if (path === this.dirs[this.dirs.length - 1]) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+
+ async onDrop(event: DragEvent, path: string) {
+ // skip if native drop like text drag and drop from files names
+ if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
+ return
+ }
+
+ // Do not stop propagation, so the main content
+ // drop event can be triggered too and clear the
+ // dragover state on the DragAndDropNotice component.
+ event.preventDefault()
+
+ // Caching the selection
+ const selection = this.draggingFiles
+ const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ const canDrop = (folder.permissions & Permission.CREATE) !== 0
+ const isCopy = event.ctrlKey
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (!canDrop || event.button !== 0) {
+ return
+ }
+
+ logger.debug('Dropped', { event, folder, selection, fileTree })
+
+ // Check whether we're uploading files
+ if (fileTree.contents.length > 0) {
+ await onDropExternalFiles(fileTree, folder, contents.contents)
+ return
+ }
+
+ // Else we're moving/copying files
+ const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
+ await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (selection.some(source => this.selectedFiles.includes(source))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
titleForSection(index, section) {
if (section?.to?.query?.dir === this.$route.query.dir) {
return t('files', 'Reload current directory')
@@ -155,14 +287,24 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
-.breadcrumb {
+.files-list__breadcrumbs {
// Take as much space as possible
flex: 1 1 100% !important;
width: 100%;
+ height: 100%;
+ margin-block: 0;
+ margin-inline: 10px;
+ min-width: 0;
+
+ :deep() {
+ a {
+ cursor: pointer !important;
+ }
+ }
- ::v-deep a {
- cursor: pointer !important;
+ &--with-progress {
+ flex-direction: column !important;
+ align-items: flex-start !important;
}
}
-
</style>
diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue
index 66774bb52a0..b08d3ba5ee5 100644
--- a/apps/files/src/components/CustomElementRender.vue
+++ b/apps/files/src/components/CustomElementRender.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<span />
</template>
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 22de0f662de..c7684d5c205 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -1,27 +1,10 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div v-show="dragover"
+ data-cy-files-drag-drop-area
class="files-list__drag-drop-notice"
@drop="onDrop">
<div class="files-list__drag-drop-notice-wrapper">
@@ -43,15 +26,21 @@
</template>
<script lang="ts">
-import { defineComponent } from 'vue'
-import { Folder, Permission } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
+import type { Folder } from '@nextcloud/files'
+
+import { Permission } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
+import { UploadStatus } from '@nextcloud/upload'
+import { defineComponent, type PropType } from 'vue'
+import debounce from 'debounce'
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
-import logger from '../logger.js'
-import { handleDrop } from '../services/DropService'
+import { useNavigation } from '../composables/useNavigation'
+import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
+import logger from '../logger.ts'
+import type { RawLocation } from 'vue-router'
export default defineComponent({
name: 'DragAndDropNotice',
@@ -62,11 +51,19 @@ export default defineComponent({
props: {
currentFolder: {
- type: Folder,
+ type: Object as PropType<Folder>,
required: true,
},
},
+ setup() {
+ const { currentView } = useNavigation()
+
+ return {
+ currentView,
+ }
+ },
+
data() {
return {
dragover: false,
@@ -88,22 +85,33 @@ export default defineComponent({
if (this.isQuotaExceeded) {
return this.t('files', 'Your have used your space quota and cannot upload files anymore')
} else if (!this.canUpload) {
- return this.t('files', 'You don’t have permission to upload or create files here')
+ return this.t('files', 'You do not have permission to upload or create files here.')
}
return null
},
+
+ /**
+ * Debounced function to reset the drag over state
+ * Required as Firefox has a bug where no dragleave is emitted:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=656164
+ */
+ resetDragOver() {
+ return debounce(() => {
+ this.dragover = false
+ }, 3000)
+ },
},
mounted() {
// Add events on parent to cover both the table and DragAndDrop notice
- const mainContent = window.document.querySelector('main.app-content') as HTMLElement
+ const mainContent = window.document.getElementById('app-content-vue') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
mainContent.addEventListener('dragleave', this.onDragLeave)
mainContent.addEventListener('drop', this.onContentDrop)
},
beforeDestroy() {
- const mainContent = window.document.querySelector('main.app-content') as HTMLElement
+ const mainContent = window.document.getElementById('app-content-vue') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
mainContent.removeEventListener('dragleave', this.onDragLeave)
mainContent.removeEventListener('drop', this.onContentDrop)
@@ -118,6 +126,7 @@ export default defineComponent({
if (isForeignFile) {
// Only handle uploading of outside files (not Nextcloud files)
this.dragover = true
+ this.resetDragOver()
}
},
@@ -126,12 +135,13 @@ export default defineComponent({
// only when we're leaving the current element
// Avoid flickering
const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) {
return
}
if (this.dragover) {
this.dragover = false
+ this.resetDragOver.clear()
}
},
@@ -140,13 +150,13 @@ export default defineComponent({
event.preventDefault()
if (this.dragover) {
this.dragover = false
+ this.resetDragOver.clear()
}
},
- onDrop(event: DragEvent) {
- logger.debug('Dropped on DragAndDropNotice', { event, error: this.cantUploadLabel })
-
- if (!this.canUpload || this.isQuotaExceeded) {
+ async onDrop(event: DragEvent) {
+ // cantUploadLabel is null if we can upload
+ if (this.cantUploadLabel) {
showError(this.cantUploadLabel)
return
}
@@ -158,30 +168,61 @@ export default defineComponent({
event.preventDefault()
event.stopPropagation()
- if (event.dataTransfer && event.dataTransfer.items.length > 0) {
- // Start upload
- logger.debug(`Uploading files to ${this.currentFolder.path}`)
- // Process finished uploads
- handleDrop(event.dataTransfer).then((uploads) => {
- logger.debug('Upload terminated', { uploads })
- showSuccess(t('files', 'Upload successful'))
-
- // Scroll to last upload in current directory if terminated
- const lastUpload = uploads.findLast((upload) => !upload.file.webkitRelativePath.includes('/') && upload.response?.headers?.['oc-fileid'])
- if (lastUpload !== undefined) {
- this.$router.push({
- ...this.$route,
- params: {
- view: this.$route.params?.view ?? 'files',
- // Remove instanceid from header response
- fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
- },
- })
- }
- })
+ // Caching the selection
+ const items: DataTransferItem[] = [...event.dataTransfer?.items || []]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(this.currentFolder.path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (event.button) {
+ return
+ }
+
+ logger.debug('Dropped', { event, folder, fileTree })
+
+ // Check whether we're uploading files
+ const uploads = await onDropExternalFiles(fileTree, folder, contents.contents)
+
+ // Scroll to last successful upload in current directory if terminated
+ const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
+ && !upload.file.webkitRelativePath.includes('/')
+ && upload.response?.headers?.['oc-fileid']
+ // Only use the last ID if it's in the current folder
+ && upload.source.replace(folder.source, '').split('/').length === 2)
+
+ if (lastUpload !== undefined) {
+ logger.debug('Scrolling to last upload in current folder', { lastUpload })
+ const location: RawLocation = {
+ path: this.$route.path,
+ // Keep params but change file id
+ params: {
+ ...this.$route.params,
+ fileid: String(lastUpload.response!.headers['oc-fileid']),
+ },
+ query: {
+ ...this.$route.query,
+ },
+ }
+ // Remove open file from query
+ delete location.query.openfile
+ this.$router.push(location)
}
+
this.dragover = false
+ this.resetDragOver.clear()
},
+
t,
},
})
@@ -194,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);
@@ -202,7 +243,7 @@ export default defineComponent({
border-color: black;
h3 {
- margin-left: 16px;
+ margin-inline-start: 16px;
color: inherit;
}
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
index 1284eed2566..72fd98d43fb 100644
--- a/apps/files/src/components/DragAndDropPreview.vue
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<div class="files-list-drag-image">
<span class="files-list-drag-image__icon">
@@ -79,7 +62,7 @@ export default Vue.extend({
summary(): string {
if (this.isSingleNode) {
const node = this.nodes[0]
- return node.attributes?.displayName || node.basename
+ return node.attributes?.displayname || node.basename
}
return getSummaryFor(this.nodes)
@@ -109,34 +92,34 @@ export default Vue.extend({
</script>
<style lang="scss">
-$size: 32px;
+$size: 28px;
$stack-shift: 6px;
.files-list-drag-image {
position: absolute;
top: -9999px;
- left: -9999px;
+ inset-inline-start: -9999px;
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-right: 12px;
+ margin-inline-end: $stack-shift * 2;
img {
max-width: 100%;
@@ -155,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-left: $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 8b4c7b71ef9..d66c3fa0ed7 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -1,27 +1,14 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+ <tr :class="{
+ 'files-list__row--dragover': dragover,
+ 'files-list__row--loading': isLoading,
+ 'files-list__row--active': isActive,
+ }"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
@@ -29,7 +16,7 @@
class="files-list__row"
v-on="rowListeners">
<!-- Failed indicator -->
- <span v-if="source.attributes.failed" class="files-list__row--failed" />
+ <span v-if="isFailedSource" class="files-list__row--failed" />
<!-- Checkbox -->
<FileEntryCheckbox :fileid="fileid"
@@ -43,26 +30,34 @@
<FileEntryPreview ref="preview"
:source="source"
:dragover="dragover"
+ @auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />
<FileEntryName ref="name"
- :display-name="displayName"
+ :basename="basename"
:extension="extension"
- :files-list-width="filesListWidth"
:nodes="nodes"
:source="source"
- @click="execDefaultAction" />
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
</td>
<!-- Actions -->
<FileEntryActions v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
- :files-list-width="filesListWidth"
- :loading.sync="loading"
: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"
@@ -78,13 +73,16 @@
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
- <NcDateTime :timestamp="source.mtime" :ignore-seconds="true" />
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
+ <span v-else>{{ t('files', 'Unknown date') }}</span>
</td>
<!-- View columns -->
<td v-for="column in columns"
:key="column.id"
- :class="`files-list__row-${currentView?.id}-${column.id}`"
+ :class="`files-list__row-${currentView.id}-${column.id}`"
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
@@ -96,37 +94,27 @@
</template>
<script lang="ts">
-import type { PropType } from 'vue'
-
-import { extname, join } from 'path'
-import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
-import { getUploader } from '@nextcloud/upload'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { vOnClickOutside } from '@vueuse/components'
-import moment from '@nextcloud/moment'
-import { generateUrl } from '@nextcloud/router'
-import Vue, { defineComponent } from 'vue'
-
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getDragAndDropPreview } from '../utils/dragUtils.ts'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
-import { hashCode } from '../utils/hashUtils.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+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'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
+
import CustomElementRender from './CustomElementRender.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
-import logger from '../logger.js'
-
-Vue.directive('onClickOutside', vOnClickOutside)
export default defineComponent({
name: 'FileEntry',
@@ -140,8 +128,12 @@ export default defineComponent({
NcDateTime,
},
+ mixins: [
+ FileEntryMixin,
+ ],
+
props: {
- isMtimeAvailable: {
+ isMimeAvailable: {
type: Boolean,
default: false,
},
@@ -149,22 +141,6 @@ export default defineComponent({
type: Boolean,
default: false,
},
- source: {
- type: [Folder, NcFile, Node] as PropType<Node>,
- required: true,
- },
- nodes: {
- type: Array as PropType<Node[]>,
- required: true,
- },
- filesListWidth: {
- type: Number,
- default: 0,
- },
- compact: {
- type: Boolean,
- default: false,
- },
},
setup() {
@@ -173,19 +149,25 @@ export default defineComponent({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
+ const filesListWidth = useFileListWidth()
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const {
+ directory: currentDir,
+ fileId: currentFileId,
+ } = useRouteParameters()
+
return {
actionsMenuStore,
draggingStore,
filesStore,
renamingStore,
selectionStore,
- }
- },
- data() {
- return {
- loading: '',
- dragover: false,
+ currentDir,
+ currentFileId,
+ currentView,
+ filesListWidth,
}
},
@@ -210,348 +192,85 @@ export default defineComponent({
drop: this.onDrop,
}
},
- currentView(): View {
- return this.$navigation.active as View
- },
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512 || this.compact) {
return []
}
- return this.currentView?.columns || []
- },
-
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentFileId() {
- return this.$route.params?.fileid || this.$route.query?.fileid || null
- },
- fileid() {
- return this.source?.fileid?.toString?.()
- },
- uniqueId() {
- return hashCode(this.source.source)
- },
- isLoading() {
- return this.source.status === NodeStatus.LOADING
+ return this.currentView.columns || []
},
- extension() {
- if (this.source.attributes?.displayName) {
- return extname(this.source.attributes.displayName)
+ mime() {
+ if (this.source.type === FileType.Folder) {
+ return this.t('files', 'Folder')
}
- return this.source.extension || ''
- },
- displayName() {
- const ext = this.extension
- const name = (this.source.attributes.displayName
- || this.source.basename)
- // Strip extension from name if defined
- return !ext ? name : name.slice(0, 0 - ext.length)
- },
-
- size() {
- const size = parseInt(this.source.size, 10) || 0
- if (typeof size !== 'number' || size < 0) {
- return t('files', 'Pending')
+ if (!this.source.mime || this.source.mime === 'application/octet-stream') {
+ return t('files', 'Unknown file type')
}
- return formatFileSize(size, true)
- },
- sizeOpacity() {
- const maxOpacitySize = 10 * 1024 * 1024
- const size = parseInt(this.source.size, 10) || 0
- if (!size || size < 0) {
- return {}
+ if (window.OC?.MimeTypeList?.names?.[this.source.mime]) {
+ return window.OC.MimeTypeList.names[this.source.mime]
}
- const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2)))
- return {
- color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
- }
- },
- mtimeOpacity() {
- const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
-
- const mtime = this.source.mtime?.getTime?.()
- if (!mtime) {
- return {}
+ const baseType = this.source.mime.split('/')[0]
+ const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || ''
+ if (baseType === 'image') {
+ return t('files', '{ext} image', { ext })
}
-
- // 1 = today, 0 = 31 days ago
- const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime))
- if (ratio < 0) {
- return {}
+ if (baseType === 'video') {
+ return t('files', '{ext} video', { ext })
}
- return {
- color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
+ if (baseType === 'audio') {
+ return t('files', '{ext} audio', { ext })
}
- },
- mtimeTitle() {
- if (this.source.mtime) {
- return moment(this.source.mtime).format('LLL')
+ if (baseType === 'text') {
+ return t('files', '{ext} text', { ext })
}
- return ''
- },
-
- draggingFiles() {
- return this.draggingStore.dragging
- },
- selectedFiles() {
- return this.selectionStore.selected
- },
- isSelected() {
- return this.selectedFiles.includes(this.fileid)
- },
- isRenaming() {
- return this.renamingStore.renamingNode === this.source
+ return this.source.mime
},
- isRenamingSmallScreen() {
- return this.isRenaming && this.filesListWidth < 512
- },
-
- isActive() {
- return this.fileid === this.currentFileId?.toString?.()
- },
-
- canDrag() {
- if (this.isRenaming) {
- return false
- }
-
- const canDrag = (node: Node): boolean => {
- return (node?.permissions & Permission.UPDATE) !== 0
- }
-
- // If we're dragging a selection, we need to check all files
- if (this.selectedFiles.length > 0) {
- const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- return nodes.every(canDrag)
+ size() {
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return this.t('files', 'Pending')
}
- return canDrag(this.source)
+ return formatFileSize(size, true)
},
- canDrop() {
- if (this.source.type !== FileType.Folder) {
- return false
- }
+ sizeOpacity() {
+ const maxOpacitySize = 10 * 1024 * 1024
- // If the current folder is also being dragged, we can't drop it on itself
- if (this.draggingFiles.includes(this.fileid)) {
- return false
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return {}
}
- return (this.source.permissions & Permission.CREATE) !== 0
- },
-
- openedMenu: {
- get() {
- return this.actionsMenuStore.opened === this.uniqueId
- },
- set(opened) {
- // Only reset when opening a new menu
- if (opened) {
- // Reset any right click position override on close
- // Wait for css animation to be done
- const root = this.$root.$el as HTMLElement
- root.style.removeProperty('--mouse-pos-x')
- root.style.removeProperty('--mouse-pos-y')
- }
-
- this.actionsMenuStore.opened = opened ? this.uniqueId : null
- },
- },
- },
-
- watch: {
- /**
- * When the source changes, reset the preview
- * and fetch the new one.
- */
- source() {
- this.resetState()
+ const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2)))
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
+ }
},
},
- beforeDestroy() {
- this.resetState()
+ created() {
+ useHotKey('Enter', this.triggerDefaultAction, {
+ stop: true,
+ prevent: true,
+ })
},
methods: {
- resetState() {
- // Reset loading state
- this.loading = ''
-
- this.$refs.preview.reset()
-
- // Close menu
- this.openedMenu = false
- },
-
- // Open the actions menu on right click
- onRightClick(event) {
- // If already opened, fallback to default browser
- if (this.openedMenu) {
- return
- }
-
- const root = this.$root.$el as HTMLElement
- const contentRect = root.getBoundingClientRect()
- // Using Math.min/max to prevent the menu from going out of the AppContent
- // 200 = max width of the menu
- root.style.setProperty('--mouse-pos-x', Math.max(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px')
- root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px')
-
- // If the clicked row is in the selection, open global menu
- const isMoreThanOneSelected = this.selectedFiles.length > 1
- this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
-
- // Prevent any browser defaults
- event.preventDefault()
- event.stopPropagation()
- },
-
- execDefaultAction(event) {
- if (event.ctrlKey || event.metaKey) {
- event.preventDefault()
- window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
- return false
- }
-
- this.$refs.actions.execDefaultAction(event)
- },
-
- openDetailsIfAvailable(event) {
- event.preventDefault()
- event.stopPropagation()
- if (sidebarAction?.enabled?.([this.source], this.currentView)) {
- sidebarAction.exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- onDragOver(event: DragEvent) {
- this.dragover = this.canDrop
- if (!this.canDrop) {
- event.dataTransfer.dropEffect = 'none'
- return
- }
-
- // Handle copy/move drag and drop
- if (event.ctrlKey) {
- event.dataTransfer.dropEffect = 'copy'
- } else {
- event.dataTransfer.dropEffect = 'move'
- }
- },
- onDragLeave(event: DragEvent) {
- // Counter bubbling, make sure we're ending the drag
- // only when we're leaving the current element
- const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
- return
- }
-
- this.dragover = false
- },
-
- async onDragStart(event: DragEvent) {
- event.stopPropagation()
- if (!this.canDrag) {
- event.preventDefault()
- event.stopPropagation()
- return
- }
-
- logger.debug('Drag started', { event })
-
- // Make sure that we're not dragging a file like the preview
- event.dataTransfer?.clearData?.()
-
- // Reset any renaming
- this.renamingStore.$reset()
-
- // Dragging set of files, if we're dragging a file
- // that is already selected, we use the entire selection
- if (this.selectedFiles.includes(this.fileid)) {
- this.draggingStore.set(this.selectedFiles)
- } else {
- this.draggingStore.set([this.fileid])
- }
-
- const nodes = this.draggingStore.dragging
- .map(fileid => this.filesStore.getNode(fileid)) as Node[]
-
- const image = await getDragAndDropPreview(nodes)
- event.dataTransfer?.setDragImage(image, -10, -10)
- },
- onDragEnd() {
- this.draggingStore.reset()
- this.dragover = false
- logger.debug('Drag ended')
- },
-
- async onDrop(event: DragEvent) {
- // skip if native drop like text drag and drop from files names
- if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
- return
- }
-
- event.preventDefault()
- event.stopPropagation()
-
- // If another button is pressed, cancel it
- // This allows cancelling the drag with the right click
- if (!this.canDrop || event.button !== 0) {
- return
- }
-
- const isCopy = event.ctrlKey
- this.dragover = false
-
- logger.debug('Dropped', { event, selection: this.draggingFiles })
+ formatFileSize,
- // Check whether we're uploading files
- if (event.dataTransfer?.files?.length > 0) {
- const uploader = getUploader()
- event.dataTransfer.files.forEach((file: File) => {
- uploader.upload(join(this.source.path, file.name), file)
- })
- logger.debug(`Uploading files to ${this.source.path}`)
+ triggerDefaultAction() {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
return
}
- const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- nodes.forEach(async (node: Node) => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- try {
- // TODO: resolve potential conflicts prior and force overwrite
- await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
- } catch (error) {
- logger.error('Error while moving file', { error })
- if (isCopy) {
- showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
- } else {
- showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
- }
- } finally {
- Vue.set(node, 'status', undefined)
- }
- })
-
- // Reset selection after we dropped the files
- // if the dropped files are within the selection
- if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
- logger.debug('Dropped selection, resetting select store...')
- this.selectionStore.reset()
- }
+ this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir)
},
-
- t,
- formatFileSize,
},
})
</script>
diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue
index 4316183e3ff..e22b30f4378 100644
--- a/apps/files/src/components/FileEntry/CollectivesIcon.vue
+++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
<span :aria-hidden="!title"
:aria-label="title"
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index 2d3373d7b42..c66cb8fbd7f 100644
--- a/apps/files/src/components/FileEntry/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" />
</template>
@@ -28,7 +11,7 @@ import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
/**
* A favorite icon to be used for overlaying favorite entries like the file preview / icon
@@ -53,7 +36,7 @@ export default defineComponent({
},
async mounted() {
await this.$nextTick()
- // MDI default viewbox is "0 0 24 24" but we add a stroke of 10px so we must adjust it
+ // MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it
const el = this.$el.querySelector('svg')
el?.setAttribute?.('viewBox', '-4 -4 30 30')
},
@@ -65,7 +48,7 @@ export default defineComponent({
<style lang="scss" scoped>
.favorite-marker-icon {
- color: #a08b00;
+ color: var(--color-favorite);
// Override NcIconSvgWrapper defaults (clickable area)
min-width: unset !important;
min-height: unset !important;
@@ -73,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 1e453fec706..5c537d878fe 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<td class="files-list__row-actions"
data-cy-files-list-row-actions>
@@ -35,40 +18,76 @@
<NcActions ref="actionsMenu"
:boundaries-element="getBoundariesElement"
:container="getBoundariesElement"
- :disabled="isLoading || loading !== ''"
:force-name="true"
type="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
- :open.sync="openedMenu"
- @close="openedSubmenu = null">
- <!-- Default actions list-->
- <NcActionButton v-for="action in enabledMenuActions"
+ :open="openedMenu"
+ @close="onMenuClose"
+ @closed="onMenuClosed">
+ <!-- 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--menu`]: isMenu(action.id)
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
}"
- :close-after-click="!isMenu(action.id)"
+ :close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
- :is-menu="isMenu(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="loading === action.id" :size="18" />
- <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
</template>
- {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
+ {{ 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 -->
- <NcActionButton class="files-list__row-action-back" @click="openedSubmenu = null">
+ <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
- {{ actionDisplayName(openedSubmenu) }}
+ {{ t('files', 'Back') }}
</NcActionButton>
<NcActionSeparator />
@@ -77,12 +96,13 @@
:key="action.id"
:class="`files-list__row-action-${action.id}`"
class="files-list__row-action--submenu"
- :close-after-click="false /* never close submenu, just go back */"
+ 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="loading === action.id" :size="18" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@@ -93,31 +113,35 @@
</template>
<script lang="ts">
-import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import Vue, { PropType } from 'vue'
+import type { PropType } from 'vue'
+import type { FileAction, Node } from '@nextcloud/files'
-import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
-import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import { DefaultType, NodeStatus } from '@nextcloud/files'
+import { defineComponent, inject } from 'vue'
+import { t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
-import logger from '../../logger.js'
-
-// The registered actions list
-const actions = getFileActions()
-
-export default Vue.extend({
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { executeAction } from '../../utils/actionUtils.ts'
+import { useActiveStore } from '../../store/active.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import actionsMixins from '../../mixins/actionsMixin.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
name: 'FileEntryActions',
components: {
ArrowLeftIcon,
- ChevronRightIcon,
CustomElementRender,
NcActionButton,
NcActions,
@@ -126,15 +150,9 @@ export default Vue.extend({
NcLoadingIcon,
},
+ mixins: [actionsMixins],
+
props: {
- filesListWidth: {
- type: Number,
- required: true,
- },
- loading: {
- type: String,
- required: true,
- },
opened: {
type: Boolean,
default: false,
@@ -149,41 +167,46 @@ export default Vue.extend({
},
},
- data() {
+ setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory: currentDir } = useRouteParameters()
+
+ const activeStore = useActiveStore()
+ const filesListWidth = useFileListWidth()
+ const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
- openedSubmenu: null as FileAction | null,
+ activeStore,
+ currentDir,
+ currentView,
+ enabledFileActions,
+ filesListWidth,
+ t,
}
},
computed: {
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentView(): View {
- return this.$navigation.active as View
+ isActive() {
+ return this.activeStore?.activeNode?.source === this.source.source
},
+
isLoading() {
return this.source.status === NodeStatus.LOADING
},
- // Sorted actions that are enabled for this node
- enabledActions() {
- if (this.source.attributes.failed) {
- return []
- }
-
- return actions
- .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- },
-
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
- return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ return this.enabledFileActions.filter(action => {
+ try {
+ return action?.inline?.(this.source, this.currentView)
+ } catch (error) {
+ logger.error('Error while checking if action is inline', { action, error })
+ return false
+ }
+ })
},
// Enabled action that are displayed inline with a custom render function
@@ -191,12 +214,7 @@ export default Vue.extend({
if (this.gridMode) {
return []
}
- return this.enabledActions.filter(action => typeof action.renderInline === 'function')
- },
-
- // Default actions
- enabledDefaultActions() {
- return this.enabledActions.filter(action => !!action?.default)
+ return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
},
// Actions shown in the menu
@@ -211,7 +229,7 @@ export default Vue.extend({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
- ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
+ ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
@@ -224,16 +242,12 @@ export default Vue.extend({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
- enabledSubmenuActions() {
- return this.enabledActions
- .filter(action => action.parent)
- .reduce((arr, action) => {
- if (!arr[action.parent]) {
- arr[action.parent] = []
- }
- arr[action.parent].push(action)
- return arr
- }, {} as Record<string, FileAction>)
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
},
openedMenu: {
@@ -253,76 +267,91 @@ export default Vue.extend({
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
+ },
- mountType() {
- return this.source._attributes['mount-type']
+ watch: {
+ // Close any submenu when the menu state changes
+ openedMenu() {
+ this.openedSubmenu = null
},
},
+ created() {
+ useHotKey('Escape', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey('a', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
methods: {
actionDisplayName(action: FileAction) {
- if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
- // if an inline action is rendered in the menu for
- // lack of space we use the title first if defined
- const title = action.title([this.source], this.currentView)
- if (title) return title
+ try {
+ if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
+ // if an inline action is rendered in the menu for
+ // lack of space we use the title first if defined
+ const title = action.title([this.source], this.currentView)
+ if (title) return title
+ }
+ return action.displayName([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ // Not ideal, but better than nothing
+ return action.id
}
- return action.displayName([this.source], this.currentView)
},
- async onActionClick(action, isSubmenu = false) {
+ isLoadingAction(action: FileAction) {
+ if (!this.isActive) {
+ return false
+ }
+ return this.activeStore?.activeAction?.id === action.id
+ },
+
+ async onActionClick(action) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
return
}
- const displayName = action.displayName([this.source], this.currentView)
- try {
- // Set the loading marker
- this.$emit('update:loading', action.id)
- Vue.set(this.source, 'status', NodeStatus.LOADING)
+ // Make sure we set the node as active
+ this.activeStore.activeNode = this.source
- const success = await action.exec(this.source, this.currentView, this.currentDir)
+ // Execute the action
+ await executeAction(action)
+ },
- // If the action returns null, we stay silent
- if (success === null || success === undefined) {
- return
- }
+ onKeyDown(event: KeyboardEvent) {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
+ }
- if (success) {
- showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
- return
- }
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } catch (e) {
- logger.error('Error while executing action', { action, e })
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } finally {
- // Reset the loading marker
- this.$emit('update:loading', '')
- Vue.set(this.source, 'status', undefined)
-
- // If that was a submenu, we just go back after the action
- if (isSubmenu) {
- this.openedSubmenu = null
- }
+ // ESC close the action menu if opened
+ if (event.key === 'Escape' && this.openedMenu) {
+ this.openedMenu = false
}
- },
- execDefaultAction(event) {
- if (this.enabledDefaultActions.length > 0) {
- event.preventDefault()
- event.stopPropagation()
- // Execute the first default action if any
- this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
+
+ // a open the action menu
+ if (event.key === 'a' && !this.openedMenu) {
+ this.openedMenu = true
}
},
- isMenu(id: string) {
- return this.enabledSubmenuActions[id]?.length > 0
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
+ this.openedSubmenu = null
},
- t,
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
},
})
</script>
@@ -330,12 +359,13 @@ export default Vue.extend({
<style lang="scss">
// Allow right click to define the position of the menu
// only if defined
-.app-content[style*="mouse-pos-x"] .v-popper__popper {
+main.app-content[style*="mouse-pos-x"] .v-popper__popper {
transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important;
// If the menu is too close to the bottom, we move it up
&[data-popper-placement="top"] {
- transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh), 0px) !important;
+ // 34px added to align with the top of the cursor
+ transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important;
}
// Hide arrow if floating
.v-popper__arrow-container {
@@ -344,13 +374,26 @@ export default Vue.extend({
}
</style>
-<style lang="scss" scoped>
-:deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) {
- .button-vue__text {
- color: var(--color-primary-element);
+<style scoped lang="scss">
+.files-list__row-action {
+ --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
+
+ // inline icons can have clickable area size so they still fit into the row
+ &.files-list__row-action--inline {
+ --max-icon-size: var(--default-clickable-area);
}
- .button-vue__icon {
- color: var(--color-primary-element);
+
+ // Some icons exceed the default size so we need to enforce a max width and height
+ .files-list__row-action-icon :deep(svg) {
+ 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/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
index bb851ed1e0e..5b80a971118 100644
--- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -1,48 +1,38 @@
<!--
- - @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>
<td class="files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
- <NcLoadingIcon v-if="isLoading" />
+ <NcLoadingIcon v-if="isLoading" :name="loadingLabel" />
<NcCheckboxRadioSwitch v-else
:aria-label="ariaLabel"
:checked="isSelected"
+ data-cy-files-list-row-checkbox
@update:checked="onSelectionChange" />
</td>
</template>
<script lang="ts">
-import { Node, FileType } from '@nextcloud/files'
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../../types.ts'
+
+import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import Vue, { PropType } from 'vue'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import { useActiveStore } from '../../store/active.ts'
import { useKeyboardStore } from '../../store/keyboard.ts'
import { useSelectionStore } from '../../store/selection.ts'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryCheckbox',
components: {
@@ -52,7 +42,7 @@ export default Vue.extend({
props: {
fileid: {
- type: String,
+ type: Number,
required: true,
},
isLoading: {
@@ -72,21 +62,29 @@ export default Vue.extend({
setup() {
const selectionStore = useSelectionStore()
const keyboardStore = useKeyboardStore()
+ const activeStore = useActiveStore()
+
return {
+ activeStore,
keyboardStore,
selectionStore,
+ t,
}
},
computed: {
+ isActive() {
+ return this.activeStore.activeNode?.source === this.source.source
+ },
+
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
- return this.selectedFiles.includes(this.fileid)
+ return this.selectedFiles.includes(this.source.source)
},
index() {
- return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid))
+ return this.nodes.findIndex((node: Node) => node.source === this.source.source)
},
isFile() {
return this.source.type === FileType.File
@@ -96,6 +94,28 @@ export default Vue.extend({
? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename })
: t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename })
},
+ loadingLabel() {
+ return this.isFile
+ ? t('files', 'File is loading')
+ : t('files', 'Folder is loading')
+ },
+ },
+
+ created() {
+ // ctrl+space toggle selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ })
+
+ // ctrl+shift+space toggle range selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ shift: true,
+ })
},
methods: {
@@ -105,19 +125,20 @@ export default Vue.extend({
// Get the last selected and select all files in between
if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
- const isAlreadySelected = this.selectedFiles.includes(this.fileid)
+ const isAlreadySelected = this.selectedFiles.includes(this.source.source)
const start = Math.min(newSelectedIndex, lastSelectedIndex)
const end = Math.max(lastSelectedIndex, newSelectedIndex)
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
- .map(file => file.fileid?.toString?.())
+ .map(file => file.source)
.slice(start, end + 1)
+ .filter(Boolean) as FileSource[]
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
- .filter(fileid => !isAlreadySelected || fileid !== this.fileid)
+ .filter(source => !isAlreadySelected || source !== this.source.source)
logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
// Keep previous lastSelectedIndex to be use for further shift selections
@@ -126,8 +147,8 @@ export default Vue.extend({
}
const selection = selected
- ? [...this.selectedFiles, this.fileid]
- : this.selectedFiles.filter(fileid => fileid !== this.fileid)
+ ? [...this.selectedFiles, this.source.source]
+ : this.selectedFiles.filter(source => source !== this.source.source)
logger.debug('Updating selection', { selection })
this.selectionStore.set(selection)
@@ -138,7 +159,15 @@ export default Vue.extend({
this.selectionStore.reset()
},
- t,
+ onToggleSelect() {
+ // Don't react if the node is not active
+ if (!this.isActive) {
+ return
+ }
+
+ logger.debug('Toggling selection for file', { source: this.source })
+ this.onSelectionChange(!this.isSelected)
+ },
},
})
</script>
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue
index 87859de353a..418f9581eb6 100644
--- a/apps/files/src/components/FileEntry/FileEntryName.vue
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -1,28 +1,12 @@
<!--
- - @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>
<!-- Rename input -->
<form v-if="isRenaming"
- v-on-click-outside="stopRenaming"
+ ref="renameForm"
+ v-on-click-outside="onRename"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
@@ -33,46 +17,44 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
- @keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<component :is="linkTo.is"
v-else
ref="basename"
- :aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
- v-bind="linkTo.params"
- @click="$emit('click', $event)">
- <!-- File name -->
- <span class="files-list__row-name-text">
- <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
- <span class="files-list__row-name-" v-text="displayName" />
- <span class="files-list__row-name-ext" v-text="extension" />
+ v-bind="linkTo.params">
+ <!-- Filename -->
+ <span class="files-list__row-name-text" dir="auto">
+ <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues-->
+ <span class="files-list__row-name-" v-text="basename" />
+ <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" />
</span>
</component>
</template>
<script lang="ts">
+import type { FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
-import { emit } from '@nextcloud/event-bus'
-import { FileType, NodeStatus, Permission } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
+import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-import Vue from 'vue'
+import { defineComponent, inject } from 'vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import { getFilenameValidity } from '../../utils/filenameValidity.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation.ts'
import { useRenamingStore } from '../../store/renaming.ts'
-import logger from '../../logger.js'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
-const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
-
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryName',
components: {
@@ -80,18 +62,20 @@ export default Vue.extend({
},
props: {
- displayName: {
+ /**
+ * The filename without extension
+ */
+ basename: {
type: String,
required: true,
},
+ /**
+ * The extension of the filename
+ */
extension: {
type: String,
required: true,
},
- filesListWidth: {
- type: Number,
- required: true,
- },
nodes: {
type: Array as PropType<Node[]>,
required: true,
@@ -107,9 +91,23 @@ export default Vue.extend({
},
setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory } = useRouteParameters()
+ const filesListWidth = useFileListWidth()
const renamingStore = useRenamingStore()
+ const userConfigStore = useUserConfigStore()
+
+ const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
+
return {
+ currentView,
+ defaultFileAction,
+ directory,
+ filesListWidth,
+
renamingStore,
+ userConfigStore,
}
},
@@ -121,24 +119,24 @@ export default Vue.extend({
return this.isRenaming && this.filesListWidth < 512
},
newName: {
- get() {
- return this.renamingStore.newName
+ get(): string {
+ return this.renamingStore.newNodeName
},
- set(newName) {
- this.renamingStore.newName = newName
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
},
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
- [FileType.File]: t('files', 'File name'),
+ [FileType.File]: t('files', 'Filename'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
},
linkTo() {
- if (this.source.attributes.failed) {
+ if (this.source.status === NodeStatus.FAILED) {
return {
is: 'span',
params: {
@@ -147,32 +145,20 @@ export default Vue.extend({
}
}
- const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
- if (enabledDefaultActions?.length > 0) {
- const action = enabledDefaultActions[0]
- const displayName = action.displayName([this.source], this.currentView)
+ if (this.defaultFileAction) {
+ const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
return {
- is: 'a',
+ is: 'button',
params: {
+ 'aria-label': displayName,
title: displayName,
- role: 'button',
- tabindex: '0',
- },
- }
- }
-
- if (this.source?.permissions & Permission.READ) {
- return {
- is: 'a',
- params: {
- download: this.source.basename,
- href: this.source.source,
- title: t('files', 'Download file {name}', { name: this.displayName }),
tabindex: '0',
},
}
}
+ // nothing interactive here, there is no default action
+ // so if not even the download action works we only can show the list entry
return {
is: 'span',
}
@@ -181,7 +167,7 @@ export default Vue.extend({
watch: {
/**
- * If renaming starts, select the file name
+ * If renaming starts, select the filename
* in the input, without the extension.
* @param renaming
*/
@@ -193,73 +179,51 @@ export default Vue.extend({
}
},
},
- },
- methods: {
- /**
- * Check if the file name is valid and update the
- * input validity using browser's native validation.
- * @param event the keyup event
- */
- checkInputValidity(event?: KeyboardEvent) {
- const input = event.target as HTMLInputElement
+ newName() {
+ // Check validity of the new name
const newName = this.newName.trim?.() || ''
- logger.debug('Checking input validity', { newName })
- try {
- this.isFileNameValid(newName)
- input.setCustomValidity('')
- input.title = ''
- } catch (e) {
- input.setCustomValidity(e.message)
- input.title = e.message
- } finally {
- input.reportValidity()
- }
- },
- isFileNameValid(name) {
- const trimmedName = name.trim()
- if (trimmedName === '.' || trimmedName === '..') {
- throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
- } else if (trimmedName.length === 0) {
- throw new Error(t('files', 'File name cannot be empty.'))
- } else if (trimmedName.indexOf('/') !== -1) {
- throw new Error(t('files', '"/" is not allowed inside a file name.'))
- } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
- throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
- } else if (this.checkIfNodeExists(name)) {
- throw new Error(t('files', '{newName} already exists.', { newName: name }))
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ return
}
- const toCheck = trimmedName.split('')
- toCheck.forEach(char => {
- if (forbiddenCharacters.indexOf(char) !== -1) {
- throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char }))
+ let validity = getFilenameValidity(newName)
+ // Checking if already exists
+ if (validity === '' && this.checkIfNodeExists(newName)) {
+ validity = t('files', 'Another entry with the same name already exists.')
+ }
+ this.$nextTick(() => {
+ if (this.isRenaming) {
+ input.setCustomValidity(validity)
+ input.reportValidity()
}
})
-
- return true
},
- checkIfNodeExists(name) {
+ },
+
+ methods: {
+ checkIfNodeExists(name: string) {
return this.nodes.find(node => node.basename === name && node !== this.source)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
- const extLength = (this.source.extension || '').split('').length
- const length = this.source.basename.split('').length - extLength
- const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
logger.error('Could not find the rename input')
return
}
- input.setSelectionRange(0, length)
input.focus()
+ const length = this.source.basename.length - (this.source.extension ?? '').length
+ input.setSelectionRange(0, length)
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
+
stopRenaming() {
if (!this.isRenaming) {
return
@@ -271,72 +235,37 @@ export default Vue.extend({
// Rename and move the file
async onRename() {
- const oldName = this.source.basename
- const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
- if (newName === '') {
- showError(t('files', 'Name cannot be empty'))
+ const form = this.$refs.renameForm as HTMLFormElement
+ if (!form.checkValidity()) {
+ showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
return
}
- if (oldName === newName) {
+ const oldName = this.source.basename
+ if (newName === oldName) {
this.stopRenaming()
return
}
- // Checking if already exists
- if (this.checkIfNodeExists(newName)) {
- showError(t('files', 'Another entry with the same name already exists'))
- return
- }
-
- // Set loading state
- this.loading = 'renaming'
- Vue.set(this.source, 'status', NodeStatus.LOADING)
-
- // Update node
- this.source.rename(newName)
-
- logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
- await axios({
- method: 'MOVE',
- url: oldEncodedSource,
- headers: {
- Destination: this.source.encodedSource,
- Overwrite: 'F',
- },
- })
-
- // Success 🎉
- emit('files:node:updated', this.source)
- emit('files:node:renamed', this.source)
- showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
-
- // Reset the renaming store
- this.stopRenaming()
- this.$nextTick(() => {
- this.$refs.basename.focus()
- })
- } catch (error) {
- logger.error('Error while renaming file', { error })
- this.source.rename(oldName)
- this.$refs.renameInput.focus()
-
- // TODO: 409 means current folder does not exist, redirect ?
- if (error?.response?.status === 404) {
- showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
- return
- } else if (error?.response?.status === 412) {
- showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
- return
+ const status = await this.renamingStore.rename()
+ if (status) {
+ showSuccess(
+ t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
+ )
+ this.$nextTick(() => {
+ const nameContainer = this.$refs.basename as HTMLElement | undefined
+ nameContainer?.focus()
+ })
+ } else {
+ // Was cancelled - meaning the renaming state is just reset
}
-
- // Unknown error
- showError(t('files', 'Could not rename "{oldName}"', { oldName }))
- } finally {
- this.loading = false
- Vue.set(this.source, 'status', undefined)
+ } catch (error) {
+ logger.error(error as Error)
+ showError((error as Error).message)
+ // And ensure we reset to the renaming state
+ this.startRenaming()
}
},
@@ -344,3 +273,16 @@ export default Vue.extend({
},
})
</script>
+
+<style scoped lang="scss">
+button.files-list__row-name-link {
+ background-color: unset;
+ border: none;
+ font-weight: normal;
+
+ &:active {
+ // No active styles - handled by the row entry
+ background-color: unset !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index 7f8926e2301..3d0fffe7584 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<span class="files-list__row-icon">
<template v-if="source.type === 'folder'">
@@ -31,16 +14,23 @@
</template>
</template>
- <!-- Decorative image, should not be aria documented -->
- <img v-else-if="previewUrl && backgroundFailed !== true"
- ref="previewImg"
- alt=""
- class="files-list__row-icon-preview"
- :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
- loading="lazy"
- :src="previewUrl"
- @error="backgroundFailed = true"
- @load="backgroundFailed = false">
+ <!-- Decorative images, should not be aria documented -->
+ <span v-else-if="previewUrl" class="files-list__row-icon-preview-container">
+ <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
+ ref="canvas"
+ 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"
+ :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
+ loading="lazy"
+ :src="previewUrl"
+ @error="onBackgroundError"
+ @load="onBackgroundLoad">
+ </span>
<FileIcon v-else v-once />
@@ -56,13 +46,16 @@
</template>
<script lang="ts">
+import type { PropType } from 'vue'
import type { UserConfig } from '../../types.ts'
-import { File, Folder, Node, FileType } from '@nextcloud/files'
-import { generateUrl } from '@nextcloud/router'
+import { Node, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
-import Vue, { PropType } from 'vue'
+import { generateUrl } from '@nextcloud/router'
+import { ShareType } from '@nextcloud/sharing'
+import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
+import { decode } from 'blurhash'
+import { defineComponent } from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
@@ -71,16 +64,18 @@ import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
-import NetworkIcon from 'vue-material-design-icons/Network.vue'
+import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
-import { useUserConfigStore } from '../../store/userconfig.ts'
import CollectivesIcon from './CollectivesIcon.vue'
import FavoriteIcon from './FavoriteIcon.vue'
+
import { isLivePhoto } from '../../services/LivePhotos'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryPreview',
components: {
@@ -114,21 +109,25 @@ export default Vue.extend({
setup() {
const userConfigStore = useUserConfigStore()
+ const isPublic = isPublicShare()
+ const publicSharingToken = getSharingToken()
+
return {
userConfigStore,
+
+ isPublic,
+ publicSharingToken,
}
},
data() {
return {
backgroundFailed: undefined as boolean | undefined,
+ backgroundLoaded: false,
}
},
computed: {
- fileid() {
- return this.source?.fileid?.toString?.()
- },
isFavorite(): boolean {
return this.source.attributes.favorite === 1
},
@@ -149,11 +148,28 @@ export default Vue.extend({
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
- || generateUrl('/core/preview?fileId={fileid}', {
- fileid: this.fileid,
- })
+ || (this.isPublic
+ ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
+ token: this.publicSharingToken,
+ file: this.source.path,
+ })
+ : generateUrl('/core/preview?fileId={fileid}', {
+ fileid: String(this.source.fileid),
+ })
+ )
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
@@ -161,6 +177,10 @@ export default Vue.extend({
url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
+ // Etag to force refresh preview on change
+ const etag = this.source?.attributes?.etag || ''
+ url.searchParams.set('v', etag.slice(0, 6))
+
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
@@ -194,7 +214,7 @@ export default Vue.extend({
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
- if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
+ if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
return LinkIcon
}
@@ -211,19 +231,67 @@ export default Vue.extend({
return AccountGroupIcon
case 'collective':
return CollectivesIcon
+ case 'shared':
+ return AccountPlusIcon
}
return null
},
+
+ hasBlurhash() {
+ return this.source.attributes['metadata-blurhash'] !== undefined
+ },
+ },
+
+ mounted() {
+ if (this.hasBlurhash && this.$refs.canvas) {
+ this.drawBlurhash()
+ }
},
methods: {
+ // Called from FileEntry
reset() {
- if (this.backgroundFailed === true && this.$refs.previewImg) {
- this.$refs.previewImg.src = ''
- }
- // Reset background state
+ // Reset background state to cancel any ongoing requests
this.backgroundFailed = undefined
+ this.backgroundLoaded = false
+ const previewImg = this.$refs.previewImg as HTMLImageElement | undefined
+ if (previewImg) {
+ previewImg.src = ''
+ }
+ },
+
+ onBackgroundLoad() {
+ this.backgroundFailed = false
+ this.backgroundLoaded = true
+ },
+
+ onBackgroundError(event) {
+ // Do not fail if we just reset the background
+ if (event.target?.src === '') {
+ return
+ }
+ this.backgroundFailed = true
+ this.backgroundLoaded = false
+ },
+
+ drawBlurhash() {
+ const canvas = this.$refs.canvas as HTMLCanvasElement
+
+ const width = canvas.width
+ const height = canvas.height
+
+ const pixels = decode(this.source.attributes['metadata-blurhash'], width, height)
+
+ const ctx = canvas.getContext('2d')
+ if (ctx === null) {
+ logger.error('Cannot create context for blurhash canvas')
+ return
+ }
+
+ const imageData = ctx.createImageData(width, height)
+ imageData.data.set(pixels)
+ ctx.putImageData(imageData, 0, 0)
},
t,
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
index 99fd45813ed..1bd0572f53b 100644
--- a/apps/files/src/components/FileEntryGrid.vue
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
@@ -34,7 +17,7 @@
@dragend="onDragEnd"
@drop="onDrop">
<!-- Failed indicator -->
- <span v-if="source.attributes.failed" class="files-list__row--failed" />
+ <span v-if="isFailedSource" class="files-list__row--failed" />
<!-- Checkbox -->
<FileEntryCheckbox :fileid="fileid"
@@ -49,60 +32,58 @@
:dragover="dragover"
:grid-mode="true"
:source="source"
+ @auxclick.native="execDefaultAction"
@click.native="execDefaultAction" />
<FileEntryName ref="name"
- :display-name="displayName"
+ :basename="basename"
:extension="extension"
- :files-list-width="filesListWidth"
:grid-mode="true"
:nodes="nodes"
:source="source"
- @click="execDefaultAction" />
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+ </td>
+
+ <!-- Mtime -->
+ <td v-if="!compact && isMtimeAvailable"
+ :style="mtimeOpacity"
+ class="files-list__row-mtime"
+ data-cy-files-list-row-mtime
+ @click="openDetailsIfAvailable">
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
</td>
<!-- Actions -->
<FileEntryActions ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
- :files-list-width="filesListWidth"
:grid-mode="true"
- :loading.sync="loading"
:opened.sync="openedMenu"
:source="source" />
</tr>
</template>
<script lang="ts">
-import type { PropType } from 'vue'
+import { defineComponent } from 'vue'
-import { extname, join } from 'path'
-import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
-import { getUploader } from '@nextcloud/upload'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
-import { vOnClickOutside } from '@vueuse/components'
-import Vue from 'vue'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getDragAndDropPreview } from '../utils/dragUtils.ts'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
-import { hashCode } from '../utils/hashUtils.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
+import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
-import logger from '../logger.js'
-
-Vue.directive('onClickOutside', vOnClickOutside)
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryGrid',
components: {
@@ -110,23 +91,14 @@ export default Vue.extend({
FileEntryCheckbox,
FileEntryName,
FileEntryPreview,
+ NcDateTime,
},
+ mixins: [
+ FileEntryMixin,
+ ],
+
inheritAttrs: false,
- props: {
- source: {
- type: [Folder, NcFile, Node] as PropType<Node>,
- required: true,
- },
- nodes: {
- type: Array as PropType<Node[]>,
- required: true,
- },
- filesListWidth: {
- type: Number,
- default: 0,
- },
- },
setup() {
const actionsMenuStore = useActionsMenuStore()
@@ -134,282 +106,30 @@ export default Vue.extend({
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const {
+ directory: currentDir,
+ fileId: currentFileId,
+ } = useRouteParameters()
+
return {
actionsMenuStore,
draggingStore,
filesStore,
renamingStore,
selectionStore,
+
+ currentDir,
+ currentFileId,
+ currentView,
}
},
data() {
return {
- loading: '',
- dragover: false,
+ gridMode: true,
}
},
-
- computed: {
- currentView(): View {
- return this.$navigation.active as View
- },
-
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentFileId() {
- return this.$route.params?.fileid || this.$route.query?.fileid || null
- },
- fileid() {
- return this.source?.fileid?.toString?.()
- },
- uniqueId() {
- return hashCode(this.source.source)
- },
- isLoading() {
- return this.source.status === NodeStatus.LOADING
- },
-
- extension() {
- if (this.source.attributes?.displayName) {
- return extname(this.source.attributes.displayName)
- }
- return this.source.extension || ''
- },
- displayName() {
- const ext = this.extension
- const name = (this.source.attributes.displayName
- || this.source.basename)
-
- // Strip extension from name if defined
- return !ext ? name : name.slice(0, 0 - ext.length)
- },
-
- draggingFiles() {
- return this.draggingStore.dragging
- },
- selectedFiles() {
- return this.selectionStore.selected
- },
- isSelected() {
- return this.selectedFiles.includes(this.fileid)
- },
-
- isRenaming() {
- return this.renamingStore.renamingNode === this.source
- },
-
- isActive() {
- return this.fileid === this.currentFileId?.toString?.()
- },
-
- canDrag() {
- const canDrag = (node: Node): boolean => {
- return (node?.permissions & Permission.UPDATE) !== 0
- }
-
- // If we're dragging a selection, we need to check all files
- if (this.selectedFiles.length > 0) {
- const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- return nodes.every(canDrag)
- }
- return canDrag(this.source)
- },
-
- canDrop() {
- if (this.source.type !== FileType.Folder) {
- return false
- }
-
- // If the current folder is also being dragged, we can't drop it on itself
- if (this.draggingFiles.includes(this.fileid)) {
- return false
- }
-
- return (this.source.permissions & Permission.CREATE) !== 0
- },
-
- openedMenu: {
- get() {
- return this.actionsMenuStore.opened === this.uniqueId
- },
- set(opened) {
- this.actionsMenuStore.opened = opened ? this.uniqueId : null
- },
- },
- },
-
- watch: {
- /**
- * When the source changes, reset the preview
- * and fetch the new one.
- */
- source() {
- this.resetState()
- },
- },
-
- beforeDestroy() {
- this.resetState()
- },
-
- methods: {
- resetState() {
- // Reset loading state
- this.loading = ''
-
- this.$refs.preview.reset()
-
- // Close menu
- this.openedMenu = false
- },
-
- // Open the actions menu on right click
- onRightClick(event) {
- // If already opened, fallback to default browser
- if (this.openedMenu) {
- return
- }
-
- // If the clicked row is in the selection, open global menu
- const isMoreThanOneSelected = this.selectedFiles.length > 1
- this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
-
- // Prevent any browser defaults
- event.preventDefault()
- event.stopPropagation()
- },
-
- execDefaultAction(event) {
- if (event.ctrlKey || event.metaKey) {
- event.preventDefault()
- window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
- return false
- }
-
- this.$refs.actions.execDefaultAction(event)
- },
-
- openDetailsIfAvailable(event) {
- event.preventDefault()
- event.stopPropagation()
- if (sidebarAction?.enabled?.([this.source], this.currentView)) {
- sidebarAction.exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- onDragOver(event: DragEvent) {
- this.dragover = this.canDrop
- if (!this.canDrop) {
- event.dataTransfer.dropEffect = 'none'
- return
- }
-
- // Handle copy/move drag and drop
- if (event.ctrlKey) {
- event.dataTransfer.dropEffect = 'copy'
- } else {
- event.dataTransfer.dropEffect = 'move'
- }
- },
- onDragLeave(event: DragEvent) {
- // Counter bubbling, make sure we're ending the drag
- // only when we're leaving the current element
- const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
- return
- }
-
- this.dragover = false
- },
-
- async onDragStart(event: DragEvent) {
- event.stopPropagation()
- if (!this.canDrag) {
- event.preventDefault()
- event.stopPropagation()
- return
- }
-
- logger.debug('Drag started')
-
- // Reset any renaming
- this.renamingStore.$reset()
-
- // Dragging set of files, if we're dragging a file
- // that is already selected, we use the entire selection
- if (this.selectedFiles.includes(this.fileid)) {
- this.draggingStore.set(this.selectedFiles)
- } else {
- this.draggingStore.set([this.fileid])
- }
-
- const nodes = this.draggingStore.dragging
- .map(fileid => this.filesStore.getNode(fileid)) as Node[]
-
- const image = await getDragAndDropPreview(nodes)
- event.dataTransfer?.setDragImage(image, -10, -10)
- },
- onDragEnd() {
- this.draggingStore.reset()
- this.dragover = false
- logger.debug('Drag ended')
- },
-
- async onDrop(event) {
- event.preventDefault()
- event.stopPropagation()
-
- // If another button is pressed, cancel it
- // This allows cancelling the drag with the right click
- if (!this.canDrop || event.button !== 0) {
- return
- }
-
- const isCopy = event.ctrlKey
- this.dragover = false
-
- logger.debug('Dropped', { event, selection: this.draggingFiles })
-
- // Check whether we're uploading files
- if (event.dataTransfer?.files?.length > 0) {
- const uploader = getUploader()
- event.dataTransfer.files.forEach((file: File) => {
- uploader.upload(join(this.source.path, file.name), file)
- })
- logger.debug(`Uploading files to ${this.source.path}`)
- return
- }
-
- const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- nodes.forEach(async (node: Node) => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- try {
- // TODO: resolve potential conflicts prior and force overwrite
- await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
- } catch (error) {
- logger.error('Error while moving file', { error })
- if (isCopy) {
- showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
- } else {
- showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
- }
- } finally {
- Vue.set(node, 'status', undefined)
- }
- })
-
- // Reset selection after we dropped the files
- // if the dropped files are within the selection
- if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
- logger.debug('Dropped selection, resetting select store...')
- this.selectionStore.reset()
- }
- },
-
- t,
- },
})
</script>
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
new file mode 100644
index 00000000000..735490c45b3
--- /dev/null
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -0,0 +1,509 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { PropType } from 'vue'
+import type { FileSource } from '../types.ts'
+
+import { extname } from 'path'
+import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { isPublicShare } from '@nextcloud/sharing/public'
+import { showError } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import { vOnClickOutside } from '@vueuse/components'
+import Vue, { computed, defineComponent } from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
+import { getDragAndDropPreview } from '../utils/dragUtils.ts'
+import { hashCode } from '../utils/hashUtils.ts'
+import { isDownloadable } from '../utils/permissions.ts'
+import logger from '../logger.ts'
+
+Vue.directive('onClickOutside', vOnClickOutside)
+
+const actions = getFileActions()
+
+export default defineComponent({
+ props: {
+ source: {
+ type: [Folder, NcFile, Node] as PropType<Node>,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ compact: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ provide() {
+ return {
+ defaultFileAction: computed(() => this.defaultFileAction),
+ enabledFileActions: computed(() => this.enabledFileActions),
+ }
+ },
+
+ data() {
+ return {
+ dragover: false,
+ gridMode: false,
+ }
+ },
+
+ computed: {
+ fileid() {
+ return this.source.fileid ?? 0
+ },
+
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ /**
+ * The display name of the current node
+ * Either the nodes filename or a custom display name (e.g. for shares)
+ */
+ displayName() {
+ // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0
+ return this.source.displayname || this.source.basename
+ },
+ /**
+ * The display name without extension
+ */
+ basename() {
+ if (this.extension === '') {
+ return this.displayName
+ }
+ return this.displayName.slice(0, 0 - this.extension.length)
+ },
+ /**
+ * The extension of the file
+ */
+ extension() {
+ if (this.source.type === FileType.Folder) {
+ return ''
+ }
+
+ return extname(this.displayName)
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging as FileSource[]
+ },
+ selectedFiles() {
+ return this.selectionStore.selected as FileSource[]
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.source.source)
+ },
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+
+ isActive() {
+ return String(this.fileid) === String(this.currentFileId)
+ },
+
+ /**
+ * Check if the source is in a failed state after an API request
+ */
+ isFailedSource() {
+ return this.source.status === NodeStatus.FAILED
+ },
+
+ canDrag(): boolean {
+ if (this.isRenaming) {
+ return false
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
+ const canDrag = (node: Node): boolean => {
+ return (node?.permissions & Permission.UPDATE) !== 0
+ }
+
+ // If we're dragging a selection, we need to check all files
+ if (this.selectedFiles.length > 0) {
+ const nodes = this.selectedFiles.map(source => this.filesStore.getNode(source)) as Node[]
+ return nodes.every(canDrag)
+ }
+ return canDrag(this.source)
+ },
+
+ canDrop(): boolean {
+ if (this.source.type !== FileType.Folder) {
+ return false
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
+ // If the current folder is also being dragged, we can't drop it on itself
+ if (this.draggingFiles.includes(this.source.source)) {
+ return false
+ }
+
+ return (this.source.permissions & Permission.CREATE) !== 0
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId.toString()
+ },
+ set(opened) {
+ // If the menu is opened on another file entry, we ignore closed events
+ if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) {
+ return
+ }
+
+ // If opened, we specify the current file id
+ // else we set it to null to close the menu
+ this.actionsMenuStore.opened = opened
+ ? this.uniqueId.toString()
+ : null
+ },
+ },
+
+ mtime() {
+ // If the mtime is not a valid date, return it as is
+ if (this.source.mtime && !isNaN(this.source.mtime.getDate())) {
+ return this.source.mtime
+ }
+
+ if (this.source.crtime && !isNaN(this.source.crtime.getDate())) {
+ return this.source.crtime
+ }
+
+ return null
+ },
+
+ mtimeOpacity() {
+ if (!this.mtime) {
+ return {}
+ }
+
+ // The time when we start reducing the opacity
+ const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
+ // everything older than the maxOpacityTime will have the same value
+ const timeDiff = Date.now() - this.mtime.getTime()
+ if (timeDiff < 0) {
+ // this means we have an invalid mtime which is in the future!
+ return {}
+ }
+
+ // inversed time difference from 0 to maxOpacityTime (which would mean today)
+ const opacityTime = Math.max(0, maxOpacityTime - timeDiff)
+ // 100 = today, 0 = 31 days ago or older
+ const percentage = Math.round(opacityTime * 100 / maxOpacityTime)
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`,
+ }
+ },
+
+ /**
+ * Sorted actions that are enabled for this node
+ */
+ enabledFileActions() {
+ if (this.source.status === NodeStatus.FAILED) {
+ return []
+ }
+
+ return actions
+ .filter(action => {
+ if (!action.enabled) {
+ return true
+ }
+
+ // In case something goes wrong, since we don't want to break
+ // the entire list, we filter out actions that throw an error.
+ try {
+ return action.enabled([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while checking action', { action, error })
+ return false
+ }
+ })
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ defaultFileAction() {
+ return this.enabledFileActions.find((action) => action.default !== undefined)
+ },
+ },
+
+ watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ * @param newSource The new value of the source prop
+ * @param oldSource The previous value
+ */
+ source(newSource: Node, oldSource: Node) {
+ if (newSource.source !== oldSource.source) {
+ this.resetState()
+ }
+ },
+
+ openedMenu() {
+ // Checking if the menu is really closed and not
+ // just a change in the open state to another file entry.
+ if (this.actionsMenuStore.opened === null) {
+ // Reset any right menu position potentially set
+ logger.debug('All actions menu closed, resetting right menu position...')
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ if (root !== null) {
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
+ }
+ },
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ resetState() {
+ // Reset the preview state
+ this.$refs?.preview?.reset?.()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // Ignore right click if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
+ // The grid mode is compact enough to not care about
+ // the actions menu mouse position
+ if (!this.gridMode) {
+ // Actions menu is contained within the app content
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ const contentRect = root.getBoundingClientRect()
+ // Using Math.min/max to prevent the menu from going out of the AppContent
+ // 200 = max width of the menu
+ logger.debug('Setting actions menu position...')
+ root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px')
+ root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px')
+ } else {
+ // Reset any right menu position potentially set
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId.toString()
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ execDefaultAction(event: MouseEvent) {
+ // Ignore click if we are renaming
+ if (this.isRenaming) {
+ return
+ }
+
+ // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4)
+ if (Boolean(event.button & 2) || event.button > 4) {
+ return
+ }
+
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
+ // 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 || 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)) {
+ return
+ }
+
+ const url = isPublicShare()
+ ? this.source.encodedSource
+ : generateUrl('/f/{fileId}', { fileId: this.fileid })
+ event.preventDefault()
+ event.stopPropagation()
+
+ // 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
+ }
+
+ // every special case handled so just execute the default action
+ event.preventDefault()
+ event.stopPropagation()
+ // Execute the first default action if any
+ this.defaultFileAction.exec(this.source, this.currentView, this.currentDir)
+ },
+
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ onDragOver(event: DragEvent) {
+ this.dragover = this.canDrop
+ if (!this.canDrop) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ return
+ }
+
+ this.dragover = false
+ },
+
+ async onDragStart(event: DragEvent) {
+ event.stopPropagation()
+ if (!this.canDrag || !this.fileid) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
+ logger.debug('Drag started', { event })
+
+ // Make sure that we're not dragging a file like the preview
+ event.dataTransfer?.clearData?.()
+
+ // Reset any renaming
+ this.renamingStore.$reset()
+
+ // Dragging set of files, if we're dragging a file
+ // that is already selected, we use the entire selection
+ if (this.selectedFiles.includes(this.source.source)) {
+ this.draggingStore.set(this.selectedFiles)
+ } else {
+ this.draggingStore.set([this.source.source])
+ }
+
+ const nodes = this.draggingStore.dragging
+ .map(source => this.filesStore.getNode(source)) as Node[]
+
+ const image = await getDragAndDropPreview(nodes)
+ event.dataTransfer?.setDragImage(image, -10, -10)
+ },
+ onDragEnd() {
+ this.draggingStore.reset()
+ this.dragover = false
+ logger.debug('Drag ended')
+ },
+
+ async onDrop(event: DragEvent) {
+ // skip if native drop like text drag and drop from files names
+ if (!this.draggingFiles && !event.dataTransfer?.items?.length) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ // Caching the selection
+ const selection = this.draggingFiles
+ const items = [...event.dataTransfer?.items || []] as DataTransferItem[]
+
+ // We need to process the dataTransfer ASAP before the
+ // browser clears it. This is why we cache the items too.
+ const fileTree = await dataTransferToFileTree(items)
+
+ // We might not have the target directory fetched yet
+ const contents = await this.currentView?.getContents(this.source.path)
+ const folder = contents?.folder
+ if (!folder) {
+ showError(this.t('files', 'Target folder does not exist any more'))
+ return
+ }
+
+ // If another button is pressed, cancel it. This
+ // allows cancelling the drag with the right click.
+ if (!this.canDrop || event.button) {
+ return
+ }
+
+ const isCopy = event.ctrlKey
+ this.dragover = false
+
+ logger.debug('Dropped', { event, folder, selection, fileTree })
+
+ // Check whether we're uploading files
+ if (selection.length === 0 && fileTree.contents.length > 0) {
+ await onDropExternalFiles(fileTree, folder, contents.contents)
+ return
+ }
+
+ // Else we're moving/copying files
+ const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[]
+ await onDropInternalFiles(nodes, folder, contents.contents, isCopy)
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (selection.some(source => this.selectedFiles.includes(source))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ t,
+ },
+})
diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue
new file mode 100644
index 00000000000..bd3ac867ed5
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilter.vue
@@ -0,0 +1,53 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcActions force-menu
+ :type="isActive ? 'secondary' : 'tertiary'"
+ :menu-name="filterName">
+ <template #icon>
+ <slot name="icon" />
+ </template>
+ <slot />
+
+ <template v-if="isActive">
+ <NcActionSeparator />
+ <NcActionButton class="files-list-filter__clear-button"
+ close-after-click
+ @click="$emit('reset-filter')">
+ {{ t('files', 'Clear filter') }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+
+defineProps<{
+ isActive: boolean
+ filterName: string
+}>()
+
+defineEmits<{
+ (event: 'reset-filter'): void
+}>()
+</script>
+
+<style scoped>
+.files-list-filter__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+}
+
+:deep(.button-vue) {
+ font-weight: normal !important;
+
+ * {
+ font-weight: normal !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
new file mode 100644
index 00000000000..3a843b2bc3e
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
@@ -0,0 +1,107 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter :is-active="isActive"
+ :filter-name="t('files', 'Modified')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCalendarRangeOutline" />
+ </template>
+ <NcActionButton v-for="preset of timePresets"
+ :key="preset.id"
+ type="radio"
+ close-after-click
+ :model-value.sync="selectedOption"
+ :value="preset.id">
+ {{ preset.label }}
+ </NcActionButton>
+ <!-- TODO: Custom time range -->
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
+
+import { mdiCalendarRangeOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ timePresets: {
+ type: Array as PropType<ITimePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ // icons used in template
+ mdiCalendarRangeOutline,
+ }
+ },
+
+ data() {
+ return {
+ selectedOption: null as string | null,
+ timeRangeEnd: null as number | null,
+ timeRangeStart: null as number | null,
+ }
+ },
+
+ computed: {
+ /**
+ * Is the filter currently active
+ */
+ isActive() {
+ return this.selectedOption !== null
+ },
+
+ currentPreset() {
+ return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
+ },
+ },
+
+ watch: {
+ selectedOption() {
+ if (this.selectedOption === null) {
+ this.$emit('update:preset')
+ } else {
+ const preset = this.currentPreset
+ this.$emit('update:preset', preset)
+ }
+ },
+ },
+
+ methods: {
+ t,
+
+ resetFilter() {
+ this.selectedOption = null
+ this.timeRangeEnd = null
+ this.timeRangeStart = null
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list-filter-time {
+ &__clear-button :deep(.action-button__text) {
+ color: var(--color-error-text);
+ }
+}
+</style>
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/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue
new file mode 100644
index 00000000000..d3ad791513f
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue
@@ -0,0 +1,122 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter class="file-list-filter-type"
+ :is-active="isActive"
+ :filter-name="t('files', 'Type')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiFileOutline" />
+ </template>
+ <NcActionButton v-for="fileType of typePresets"
+ :key="fileType.id"
+ type="checkbox"
+ :model-value="selectedOptions.includes(fileType)"
+ @click="toggleOption(fileType)">
+ <template #icon>
+ <NcIconSvgWrapper :svg="fileType.icon" />
+ </template>
+ {{ fileType.label }}
+ </NcActionButton>
+ </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITypePreset } from '../../filters/TypeFilter.ts'
+
+import { mdiFileOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+ name: 'FileListFilterType',
+
+ components: {
+ FileListFilter,
+ NcActionButton,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ presets: {
+ type: Array as PropType<ITypePreset[]>,
+ default: () => [],
+ },
+ typePresets: {
+ type: Array as PropType<ITypePreset[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ mdiFileOutline,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ selectedOptions: [] as ITypePreset[],
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.selectedOptions.length > 0
+ },
+ },
+
+ watch: {
+ /** Reset selected options if property is changed */
+ presets() {
+ this.selectedOptions = this.presets ?? []
+ },
+ selectedOptions(newValue, oldValue) {
+ if (this.selectedOptions.length === 0) {
+ if (oldValue.length !== 0) {
+ this.$emit('update:presets')
+ }
+ } else {
+ this.$emit('update:presets', this.selectedOptions)
+ }
+ },
+ },
+
+ mounted() {
+ this.selectedOptions = this.presets ?? []
+ },
+
+ methods: {
+ resetFilter() {
+ this.selectedOptions = []
+ },
+
+ /**
+ * Toggle option from selected option
+ * @param option The option to toggle
+ */
+ toggleOption(option: ITypePreset) {
+ const idx = this.selectedOptions.indexOf(option)
+ if (idx !== -1) {
+ this.selectedOptions.splice(idx, 1)
+ } else {
+ this.selectedOptions.push(option)
+ }
+ },
+ },
+})
+</script>
+
+<style>
+.file-list-filter-type {
+ max-width: 220px;
+}
+</style>
diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue
new file mode 100644
index 00000000000..7f0d71fd85a
--- /dev/null
+++ b/apps/files/src/components/FileListFilters.vue
@@ -0,0 +1,74 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="file-list-filters">
+ <div class="file-list-filters__filter" data-cy-files-filters>
+ <span v-for="filter of visualFilters"
+ :key="filter.id"
+ ref="filterElements" />
+ </div>
+ <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')">
+ <li v-for="(chip, index) of activeChips" :key="index">
+ <NcChip :aria-label-close="t('files', 'Remove filter')"
+ :icon-svg="chip.icon"
+ :text="chip.text"
+ @close="chip.onclick">
+ <template v-if="chip.user" #icon>
+ <NcAvatar disable-menu
+ :show-user-status="false"
+ :size="24"
+ :user="chip.user" />
+ </template>
+ </NcChip>
+ </li>
+ </ul>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { computed, ref, watchEffect } from 'vue'
+import { useFiltersStore } from '../store/filters.ts'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcChip from '@nextcloud/vue/components/NcChip'
+
+const filterStore = useFiltersStore()
+const visualFilters = computed(() => filterStore.filtersWithUI)
+const activeChips = computed(() => filterStore.activeChips)
+
+const filterElements = ref<HTMLElement[]>([])
+watchEffect(() => {
+ filterElements.value
+ .forEach((el, index) => visualFilters.value[index].mount(el))
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filters {
+ display: flex;
+ flex-direction: column;
+ gap: var(--default-grid-baseline);
+ height: 100%;
+ width: 100%;
+
+ &__filter {
+ display: flex;
+ align-items: start;
+ justify-content: start;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+
+ > * {
+ flex: 0 1 fit-content;
+ }
+ }
+
+ &__active {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--default-grid-baseline, 4px) * 2);
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
index 3639571411a..31458398028 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<div v-show="enabled" :class="`files-list__header-${header.id}`">
<span ref="mount" />
@@ -26,6 +9,13 @@
</template>
<script lang="ts">
+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
@@ -36,21 +26,29 @@ export default {
name: 'FilesListHeader',
props: {
header: {
- type: Object,
+ type: Object as PropType<Header>,
required: true,
},
currentFolder: {
- type: Object,
+ type: Object as PropType<Folder>,
required: true,
},
currentView: {
- type: Object,
+ type: Object as PropType<View>,
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)
+ return this.header.enabled?.(this.currentFolder, this.currentView) ?? true
},
},
watch: {
@@ -58,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, 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 9580de29919..9e8cdc159ee 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<tr>
<th class="files-list__row-checkbox">
@@ -38,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">
@@ -58,20 +45,29 @@
</template>
<script lang="ts">
-import { formatFileSize } from '@nextcloud/files'
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { View, formatFileSize } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
-import Vue from 'vue'
+import { defineComponent } from 'vue'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FilesListTableFooter',
- components: {
- },
-
props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -81,7 +77,7 @@ export default Vue.extend({
default: false,
},
nodes: {
- type: Array,
+ type: Array as PropType<Node[]>,
required: true,
},
summary: {
@@ -97,31 +93,24 @@ export default Vue.extend({
setup() {
const pathsStore = usePathsStore()
const filesStore = useFilesStore()
+ const { directory } = useRouteParameters()
return {
filesStore,
pathsStore,
+ directory,
}
},
computed: {
- currentView() {
- return this.$navigation.active
- },
-
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
-
currentFolder() {
if (!this.currentView?.id) {
return
}
- if (this.dir === '/') {
+ if (this.directory === '/') {
return this.filesStore.getRoot(this.currentView.id)
}
- const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)!
return this.filesStore.getNode(fileId)
},
@@ -140,7 +129,7 @@ export default Vue.extend({
}
// Otherwise let's compute it
- return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true)
+ return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true)
},
},
@@ -160,7 +149,7 @@ export default Vue.extend({
<style scoped lang="scss">
// Scoped row
tr {
- margin-bottom: 300px;
+ margin-bottom: var(--body-container-margin);
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
index 148ce3bc4e5..23e631199eb 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -1,29 +1,12 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
- <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
+ <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" />
</th>
<!-- Columns display -->
@@ -41,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"
@@ -71,24 +62,28 @@
</template>
<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../types.ts'
+
import { translate as t } from '@nextcloud/l10n'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import Vue from 'vue'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { useFilesStore } from '../store/files.ts'
+import { useNavigation } from '../composables/useNavigation'
import { useSelectionStore } from '../store/selection.ts'
-import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
-import logger from '../logger.js'
+import logger from '../logger.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FilesListTableHeader',
components: {
FilesListTableHeaderButton,
NcCheckboxRadioSwitch,
- FilesListTableHeaderActions,
},
mixins: [
@@ -96,6 +91,10 @@ export default Vue.extend({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -105,7 +104,7 @@ export default Vue.extend({
default: false,
},
nodes: {
- type: Array,
+ type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
@@ -117,17 +116,17 @@ export default Vue.extend({
setup() {
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
+ const { currentView } = useNavigation()
+
return {
filesStore,
selectionStore,
+
+ currentView,
}
},
computed: {
- currentView() {
- return this.$navigation.active
- },
-
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
@@ -168,8 +167,23 @@ export default Vue.extend({
},
},
+ created() {
+ // ctrl+a selects all
+ useHotKey('a', this.onToggleAll, {
+ ctrl: true,
+ stop: true,
+ prevent: true,
+ })
+
+ // Escape key cancels selection
+ useHotKey('Escape', this.resetSelection, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
methods: {
- ariaSortForMode(mode: string): ARIAMixin['ariaSort'] {
+ ariaSortForMode(mode: string): 'ascending'|'descending'|null {
if (this.sortingMode === mode) {
return this.isAscSorting ? 'ascending' : 'descending'
}
@@ -181,13 +195,13 @@ export default Vue.extend({
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
- [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ [`files-list__row-${this.currentView?.id}-${column.id}`]: true,
}
},
- onToggleAll(selected) {
+ onToggleAll(selected = true) {
if (selected) {
- const selection = this.nodes.map(node => node.fileid.toString())
+ const selection = this.nodes.map(node => node.source).filter(Boolean) as FileSource[]
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
@@ -198,6 +212,9 @@ export default Vue.extend({
},
resetSelection() {
+ if (this.isNoneSelected) {
+ return
+ }
this.selectionStore.reset()
},
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index 296be604820..6a808355c58 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -1,35 +1,31 @@
<!--
- - @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>
- <div class="files-list__column files-list__row-actions-batch">
+ <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="inlineActions"
- :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
- :open.sync="openedMenu">
- <NcActionButton v-for="action in enabledActions"
+ :inline="enabledInlineActions.length"
+ :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
+ :open.sync="openedMenu"
+ @close="openedSubmenu = null">
+ <!-- Default actions list-->
+ <NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
- :class="'files-list__row-actions-batch-' + action.id"
+ :ref="`action-batch-${action.id}`"
+ :class="{
+ [`files-list__row-actions-batch-${action.id}`]: true,
+ [`files-list__row-actions-batch--menu`]: isValidMenu(action)
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-selection-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
@@ -37,50 +33,86 @@
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
+
+ <!-- Submenu actions list-->
+ <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
+ <!-- Back to top-level button -->
+ <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
+ <template #icon>
+ <ArrowLeftIcon />
+ </template>
+ {{ t('files', 'Back') }}
+ </NcActionButton>
+ <NcActionSeparator />
+
+ <!-- Submenu actions -->
+ <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
+ :key="action.id"
+ :class="`files-list__row-actions-batch-${action.id}`"
+ class="files-list__row-actions-batch--submenu"
+ close-after-click
+ :data-cy-files-list-selection-action="action.id"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </template>
+ {{ action.displayName(nodes, currentView) }}
+ </NcActionButton>
+ </template>
</NcActions>
</div>
</template>
<script lang="ts">
-import { NodeStatus, getFileActions } from '@nextcloud/files'
+import type { FileAction, Node, View } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../types'
+
+import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import Vue from 'vue'
+import { defineComponent } from 'vue'
+
+import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
-import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import logger from '../logger.js'
+import actionsMixins from '../mixins/actionsMixin.ts'
+import logger from '../logger.ts'
// The registered actions list
const actions = getFileActions()
-export default Vue.extend({
+export default defineComponent({
name: 'FilesListTableHeaderActions',
components: {
+ ArrowLeftIcon,
NcActions,
NcActionButton,
NcIconSvgWrapper,
NcLoadingIcon,
},
- mixins: [
- filesListWidthMixin,
- ],
+ mixins: [actionsMixins],
props: {
currentView: {
- type: Object,
+ type: Object as PropType<View>,
required: true,
},
selectedNodes: {
- type: Array,
+ type: Array as PropType<FileSource[]>,
default: () => ([]),
},
},
@@ -89,10 +121,20 @@ export default Vue.extend({
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
+ const fileListWidth = useFileListWidth()
+ const { directory } = useRouteParameters()
+
+ const boundariesElement = document.getElementById('app-content-vue')
+
return {
+ directory,
+ fileListWidth,
+
actionsMenuStore,
filesStore,
selectionStore,
+
+ boundariesElement,
}
},
@@ -103,21 +145,82 @@ export default Vue.extend({
},
computed: {
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
- enabledActions() {
+ enabledFileActions(): FileAction[] {
return actions
- .filter(action => action.execBatch)
+ // We don't handle renderInline actions in this component
+ .filter(action => !action.renderInline)
+ // We don't handle actions that are not visible
+ .filter(action => action.default !== DefaultType.HIDDEN)
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
+ /**
+ * Return the list of enabled actions that are
+ * allowed to be rendered inlined.
+ * This means that they are not within a menu, nor
+ * being the parent of submenu actions.
+ */
+ enabledInlineActions(): FileAction[] {
+ return this.enabledFileActions
+ // Remove all actions that are not top-level actions
+ .filter(action => action.parent === undefined)
+ // Remove all actions that are not batch actions
+ .filter(action => action.execBatch !== undefined)
+ // Remove all top-menu entries
+ .filter(action => !this.isValidMenu(action))
+ // Return a maximum actions to fit the screen
+ .slice(0, this.inlineActions)
+ },
+
+ /**
+ * Return the rest of enabled actions that are not
+ * rendered inlined.
+ */
+ enabledMenuActions(): FileAction[] {
+ // If we're in a submenu, only render the inline
+ // actions before the filtered submenu
+ if (this.openedSubmenu) {
+ return this.enabledInlineActions
+ }
+
+ // We filter duplicates to prevent inline actions to be shown twice
+ const actions = this.enabledFileActions.filter((value, index, self) => {
+ return index === self.findIndex(action => action.id === value.id)
+ })
+
+ // Generate list of all top-level actions ids
+ const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[]
+
+ const menuActions = actions
+ .filter(action => {
+ // If the action is not a batch action, we need
+ // to make sure it's a top-level parent entry
+ // and that we have some children actions bound to it
+ if (!action.execBatch) {
+ return childrenActionsIds.includes(action.id)
+ }
+
+ // Rendering second-level actions is done in the template
+ // when openedSubmenu is set.
+ if (action.parent) {
+ return false
+ }
+
+ return true
+ })
+ .filter(action => !this.enabledInlineActions.includes(action))
+
+ // Make sure we render the inline actions first
+ // and then the rest of the actions.
+ // We do NOT want nested actions to be rendered inlined
+ return [...this.enabledInlineActions, ...menuActions]
+ },
+
nodes() {
return this.selectedNodes
- .map(fileid => this.getNode(fileid))
- .filter(node => node)
+ .map(source => this.getNode(source))
+ .filter(Boolean) as Node[]
},
areSomeNodesLoading() {
@@ -134,13 +237,13 @@ export default Vue.extend({
},
inlineActions() {
- if (this.filesListWidth < 512) {
+ if (this.fileListWidth < 512) {
return 0
}
- if (this.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return 1
}
- if (this.filesListWidth < 1024) {
+ if (this.fileListWidth < 1024) {
return 2
}
return 3
@@ -151,25 +254,36 @@ export default Vue.extend({
/**
* Get a cached note from the store
*
- * @param {number} fileId the file id to get
- * @return {Folder|File}
+ * @param source The source of the node to get
*/
- getNode(fileId) {
- return this.filesStore.getNode(fileId)
+ getNode(source: string): Node|undefined {
+ return this.filesStore.getNode(source)
},
async onActionClick(action) {
- const displayName = action.displayName(this.nodes, this.currentView)
- const selectionIds = this.selectedNodes
+ // If the action is a submenu, we open it
+ if (this.enabledSubmenuActions[action.id]) {
+ this.openedSubmenu = action
+ return
+ }
+
+ let displayName = action.id
+ try {
+ displayName = action.displayName(this.nodes, this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+
+ const selectionSources = this.selectedNodes
try {
// Set loading markers
this.loading = action.id
this.nodes.forEach(node => {
- Vue.set(node, 'status', NodeStatus.LOADING)
+ this.$set(node, 'status', NodeStatus.LOADING)
})
// Dispatch action execution
- const results = await action.execBatch(this.nodes, this.currentView, this.dir)
+ const results = await action.execBatch(this.nodes, this.currentView, this.directory)
// Check if all actions returned null
if (!results.some(result => result !== null)) {
@@ -181,9 +295,9 @@ export default Vue.extend({
// Handle potential failures
if (results.some(result => result === false)) {
// Remove the failed ids from the selection
- const failedIds = selectionIds
- .filter((fileid, index) => results[index] === false)
- this.selectionStore.set(failedIds)
+ const failedSources = selectionSources
+ .filter((source, index) => results[index] === false)
+ this.selectionStore.set(failedSources)
if (results.some(result => result === null)) {
// If some actions returned null, we assume that the dev
@@ -191,21 +305,21 @@ export default Vue.extend({
return
}
- showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
+ showError(this.t('files', '{displayName}: failed on some elements', { displayName }))
return
}
// Show success message and clear selection
- showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
+ showSuccess(this.t('files', '{displayName}: done', { displayName }))
this.selectionStore.reset()
} catch (e) {
logger.error('Error while executing action', { action, e })
- showError(this.t('files', '"{displayName}" action failed', { displayName }))
+ showError(this.t('files', '{displayName}: failed', { displayName }))
} finally {
// Remove loading markers
this.loading = null
this.nodes.forEach(node => {
- Vue.set(node, 'status', undefined)
+ this.$set(node, 'status', undefined)
})
}
},
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
index 955f25016ab..d2e14a5495f 100644
--- a/apps/files/src/components/FilesListTableHeaderButton.vue
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -1,25 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcButton :class="['files-list__column-sort-button', {
'files-list__column-sort-button--active': sortingMode === mode,
@@ -27,6 +9,7 @@
}]"
:alignment="mode === 'size' ? 'end' : 'start-reverse'"
type="tertiary"
+ :title="name"
@click="toggleSortBy(mode)">
<template #icon>
<MenuUp v-if="sortingMode !== mode || isAscSorting" class="files-list__column-sort-button-icon" />
@@ -42,7 +25,7 @@ import { defineComponent } from 'vue'
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
import MenuUp from 'vue-material-design-icons/MenuUp.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import filesSortingMixin from '../mixins/filesSorting.ts'
@@ -79,7 +62,7 @@ export default defineComponent({
<style scoped lang="scss">
.files-list__column-sort-button {
// Compensate for cells margin
- margin: 0 calc(var(--cell-margin) * -1);
+ margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1);
min-width: calc(100% - 3 * var(--cell-margin))!important;
&-text {
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index ed0096e9792..47b8ef19b19 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<VirtualList ref="table"
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
@@ -26,21 +9,28 @@
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
+ isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
- filesListWidth,
}"
:scroll-to-index="scrollToIndex"
:caption="caption">
+ <template #filters>
+ <FileListFilters />
+ </template>
+
<template v-if="!isNoneSelected" #header-overlay>
+ <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>
<template #before>
<!-- Headers -->
- <FilesListHeader v-for="header in sortedHeaders"
+ <FilesListHeader v-for="header in headers"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
@@ -51,15 +41,23 @@
<template #header>
<!-- Table header and sort buttons -->
<FilesListTableHeader ref="thead"
- :files-list-width="filesListWidth"
+ :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 :files-list-width="filesListWidth"
+ <FilesListTableFooter :current-view="currentView"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
@@ -69,35 +67,40 @@
</template>
<script lang="ts">
-import type { Node as NcNode } from '@nextcloud/files'
-import type { PropType } from 'vue'
import type { UserConfig } from '../types'
+import type { Node as NcNode } from '@nextcloud/files'
+import type { ComponentPublicInstance, PropType } from 'vue'
-import { getFileListHeaders, Folder, View, getFileActions } from '@nextcloud/files'
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
-import { loadState } from '@nextcloud/initial-state'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { n, t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getSummaryFor } from '../utils/fileUtils'
+import { useActiveStore } from '../store/active.ts'
+import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
+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'
+import FileListFilters from './FileListFilters.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
-import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import VirtualList from './VirtualList.vue'
-import logger from '../logger.js'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
+import VirtualList from './VirtualList.vue'
export default defineComponent({
name: 'FilesListVirtual',
components: {
+ FileListFilters,
FilesListHeader,
FilesListTableFooter,
FilesListTableHeader,
@@ -105,10 +108,6 @@ export default defineComponent({
FilesListTableHeaderActions,
},
- mixins: [
- filesListWidthMixin,
- ],
-
props: {
currentView: {
type: View,
@@ -122,14 +121,33 @@ export default defineComponent({
type: Array as PropType<NcNode[]>,
required: true,
},
+ summary: {
+ type: String,
+ required: true,
+ },
},
setup() {
- const userConfigStore = useUserConfigStore()
+ const activeStore = useActiveStore()
const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+
+ const fileListWidth = useFileListWidth()
+ const { fileId, openDetails, openFile } = useRouteParameters()
+
return {
- userConfigStore,
+ fileId,
+ fileListWidth,
+ headers: useFileListHeaders(),
+ openDetails,
+ openFile,
+
+ activeStore,
selectionStore,
+ userConfigStore,
+
+ n,
+ t,
}
},
@@ -137,7 +155,6 @@ export default defineComponent({
return {
FileEntry,
FileEntryGrid,
- headers: getFileListHeaders(),
scrollToIndex: 0,
}
},
@@ -147,43 +164,53 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
- fileId() {
- return parseInt(this.$route.params.fileid) || null
- },
-
- summary() {
- return getSummaryFor(this.nodes)
+ 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.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return false
}
return this.nodes.some(node => node.mtime !== undefined)
},
isSizeAvailable() {
// Hide size column on narrow screens
- if (this.filesListWidth < 768) {
+ if (this.fileListWidth < 768) {
return false
}
- return this.nodes.some(node => node.attributes.size !== undefined)
+ return this.nodes.some(node => node.size !== undefined)
},
- sortedHeaders() {
- if (!this.currentFolder || !this.currentView) {
- return []
- }
+ cantUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
+ },
- return [...this.headers].sort((a, b) => a.order - b.order)
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
},
caption() {
const defaultCaption = t('files', 'List of files and folders.')
const viewCaption = this.currentView.caption || defaultCaption
+ const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null
+ const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null
const sortableCaption = t('files', 'Column headers with buttons are sortable.')
const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.')
- return `${viewCaption}\n${sortableCaption}\n${virtualListNote}`
+ return [
+ viewCaption,
+ cantUploadCaption,
+ quotaExceededCaption,
+ sortableCaption,
+ virtualListNote,
+ ].filter(Boolean).join('\n')
},
selectedNodes() {
@@ -193,74 +220,179 @@ export default defineComponent({
isNoneSelected() {
return this.selectedNodes.length === 0
},
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
},
watch: {
- fileId(fileId) {
- this.scrollToFile(fileId, false)
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
+ },
+ fileId() {
+ this.handleOpenQueries()
+ },
+ openFile() {
+ this.handleOpenQueries()
+ },
+ openDetails() {
+ this.handleOpenQueries()
},
},
+ created() {
+ useHotKey('Escape', this.unselectFile, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
mounted() {
// Add events on parent to cover both the table and DragAndDrop notice
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
-
- this.scrollToFile(this.fileId)
- this.openSidebarForFile(this.fileId)
- this.handleOpenFile()
+ subscribe('files:sidebar:closed', this.onSidebarClosed)
},
beforeDestroy() {
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
+ unsubscribe('files:sidebar:closed', this.onSidebarClosed)
},
methods: {
- // Open the file sidebar if we have the room for it
- // but don't open the sidebar for the current folder
+ 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) {
- if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== fileId) {
- // Open the sidebar for the given URL fileid
- // iif we just loaded the app.
- const node = this.nodes.find(n => n.fileid === fileId) as NcNode
- if (node && sidebarAction?.enabled?.([node], this.currentView)) {
- logger.debug('Opening sidebar on file ' + node.path, { node })
- sidebarAction.exec(node, this.currentView, this.currentFolder.path)
- }
+ // Open the sidebar for the given URL fileid
+ // iif we just loaded the app.
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node && sidebarAction?.enabled?.([node], this.currentView)) {
+ logger.debug('Opening sidebar on file ' + node.path, { node })
+ sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ return
}
+ logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
},
scrollToFile(fileId: number|null, warn = true) {
if (fileId) {
+ // Do not uselessly scroll to the top of the list.
+ if (fileId === this.currentFolder.fileid) {
+ return
+ }
+
const index = this.nodes.findIndex(node => node.fileid === fileId)
if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
- showError(this.t('files', 'File not found'))
+ showError(t('files', 'File not found'))
}
+
this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
- handleOpenFile() {
- const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number })
- if (openFileInfo === undefined) {
- return
+ /**
+ * Unselect the current file and clear open parameters from the URL
+ */
+ unselectFile() {
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = undefined
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
+ query,
+ true,
+ )
+ },
+
+ // When sidebar is closed, we remove the openDetails parameter from the URL
+ onSidebarClosed() {
+ if (this.openDetails) {
+ const query = { ...this.$route.query }
+ delete query.opendetails
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ query,
+ )
}
+ },
- const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode
+ /**
+ * Handle opening a file (e.g. by ?openfile=true)
+ * @param fileId File to open
+ */
+ async handleOpenFile(fileId: number) {
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
if (node === undefined) {
return
}
- logger.debug('Opening file ' + node.path, { node })
- getFileActions()
- .filter(action => !action.enabled || action.enabled([node], this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- .filter(action => !!action?.default)[0].exec(node, this.currentView, this.currentFolder.path)
- },
-
- getFileId(node) {
- return node.fileid
+ if (node.type === FileType.File) {
+ const defaultAction = getFileActions()
+ // Get only default actions (visible and hidden)
+ .filter((action) => !!action?.default)
+ // Find actions that are either always enabled or enabled for the current node
+ .filter((action) => !action.enabled || action.enabled([node], this.currentView))
+ .filter((action) => action.id !== 'download')
+ // Sort enabled default actions by order
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ // Get the first one
+ .at(0)
+
+ // Some file types do not have a default action (e.g. they can only be downloaded)
+ // So if there is an enabled default action, so execute it
+ if (defaultAction) {
+ logger.debug('Opening file ' + node.path, { node })
+ return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
+ }
+ }
+ // 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
+ logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ { ...this.$route.query, openfile: undefined, opendetails: '' },
+ true, // silent update of the URL
+ )
},
onDragOver(event: DragEvent) {
@@ -275,41 +407,99 @@ export default defineComponent({
event.preventDefault()
event.stopPropagation()
- const tableTop = this.$refs.table.$el.getBoundingClientRect().top
- const tableBottom = tableTop + this.$refs.table.$el.getBoundingClientRect().height
+ const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el
+ const tableTop = tableElement.getBoundingClientRect().top
+ const tableBottom = tableTop + tableElement.getBoundingClientRect().height
// If reaching top, scroll up. Using 100 because of the floating header
if (event.clientY < tableTop + 100) {
- this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop - 25
+ tableElement.scrollTop = tableElement.scrollTop - 25
return
}
// If reaching bottom, scroll down
if (event.clientY > tableBottom - 50) {
- this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop + 25
+ tableElement.scrollTop = tableElement.scrollTop + 25
}
},
- t,
+ onKeyDown(event: KeyboardEvent) {
+ // Up and down arrow keys
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
+ const columnCount = this.$refs.table?.columnCount ?? 1
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+
+ // if grid mode, left and right arrow keys
+ if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0
+ const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
+ if (nextIndex < 0 || nextIndex >= this.nodes.length) {
+ return
+ }
+
+ const nextNode = this.nodes[nextIndex]
+
+ if (nextNode && nextNode?.fileid) {
+ this.setActiveNode(nextNode)
+ }
+ }
+ },
+
+ setActiveNode(node: NcNode & { fileid: number }) {
+ logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
+ this.scrollToFile(node.fileid)
+
+ // Remove openfile and opendetails from the URL
+ const query = { ...this.$route.query }
+ delete query.openfile
+ delete query.opendetails
+
+ this.activeStore.activeNode = node
+
+ // Silent update of the URL
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: String(node.fileid) },
+ query,
+ true,
+ )
+ },
},
})
</script>
<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: 44px;
- --icon-preview-size: 32px;
+ --clickable-area: var(--default-clickable-area);
+ --icon-preview-size: 24px;
- position: relative;
+ --fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
+ &:has(.file-list-filters__active) {
+ --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
+ }
+
& :deep() {
// Table head, body and footer
tbody {
@@ -337,24 +527,57 @@ export default defineComponent({
flex-direction: column;
}
+ .files-list__selected {
+ padding-inline-end: 12px;
+ white-space: nowrap;
+ }
+
.files-list__table {
display: block;
+
+ &.files-list__table--with-thead-overlay {
+ // 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__thead-overlay {
- position: absolute;
+ .files-list__filters {
+ // Pinned on top when scrolling above table header
+ position: sticky;
top: 0;
- left: var(--row-height); // Save space for a row checkbox
- right: 0;
- z-index: 1000;
+ // ensure there is a background to hide the file list on scroll
+ background-color: var(--color-main-background);
+ z-index: 10;
+ // fixed the size
+ padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
+ height: var(--fixed-block-start-position);
+ width: 100%;
+ }
+
+ .files-list__thead-overlay {
+ // Pinned on top when scrolling
+ position: sticky;
+ top: var(--fixed-block-start-position);
+ // Save space for a row checkbox
+ margin-inline-start: var(--row-height);
+ // More than .files-list__thead
+ z-index: 20;
display: flex;
align-items: center;
// Reuse row styles
background-color: var(--color-main-background);
- border-bottom: 1px solid var(--color-border);
+ border-block-end: 1px solid var(--color-border);
height: var(--row-height);
+ flex: 0 0 var(--row-height);
}
.files-list__thead,
@@ -363,7 +586,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);
-
}
// Table header
@@ -371,12 +593,17 @@ export default defineComponent({
// Pinned on top when scrolling
position: sticky;
z-index: 10;
- top: 0;
+ top: var(--fixed-block-start-position);
}
- // Table footer
- .files-list__tfoot {
- min-height: 300px;
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
}
tr {
@@ -384,8 +611,7 @@ export default defineComponent({
display: flex;
align-items: center;
width: 100%;
- user-select: none;
- border-bottom: 1px solid var(--color-border);
+ border-block-end: 1px solid var(--color-border);
box-sizing: border-box;
user-select: none;
height: var(--row-height);
@@ -395,7 +621,7 @@ export default defineComponent({
display: flex;
align-items: center;
flex: 0 0 auto;
- justify-content: left;
+ justify-content: start;
width: var(--row-height);
height: var(--row-height);
margin: 0;
@@ -417,8 +643,7 @@ export default defineComponent({
position: absolute;
display: block;
top: 0;
- left: 0;
- right: 0;
+ inset-inline: 0;
bottom: 0;
opacity: .1;
z-index: -1;
@@ -482,7 +707,7 @@ export default defineComponent({
width: var(--icon-preview-size);
height: 100%;
// Show same padding as the checkbox right padding for visual balance
- margin-right: var(--checkbox-padding);
+ margin-inline-end: var(--checkbox-padding);
color: var(--color-primary-element);
// Icon is also clickable
@@ -509,15 +734,31 @@ export default defineComponent({
}
}
- &-preview {
+ &-preview-container {
+ position: relative; // Needed for the blurshash to be positioned correctly
overflow: hidden;
width: var(--icon-preview-size);
height: var(--icon-preview-size);
border-radius: var(--border-radius);
+ }
+
+ &-blurhash {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ }
+
+ &-preview {
// Center and contain the preview
object-fit: contain;
object-position: center;
+ height: 100%;
+ width: 100%;
+
/* Preview not loaded animation effect */
&:not(.files-list__row-icon-preview--loaded) {
background: var(--color-loading-dark);
@@ -528,17 +769,17 @@ export default defineComponent({
&-favorite {
position: absolute;
top: 0px;
- right: -10px;
+ inset-inline-end: -10px;
}
// 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-top: 2px;
+ margin-block-start: 2px;
// Improve icon contrast with a background for files
&--file {
@@ -556,24 +797,27 @@ export default defineComponent({
// Take as much space as possible
flex: 1 1 auto;
- a {
+ button.files-list__row-name-link {
display: flex;
align-items: center;
+ text-align: start;
// Fill cell height and width
width: 100%;
height: 100%;
// Necessary for flex grow to work
min-width: 0;
+ margin: 0;
+ padding: 0;
// Already added to the inner text, see rule below
&:focus-visible {
- outline: none;
+ outline: none !important;
}
// Keyboard indicator a11y
&:focus .files-list__row-name-text {
- outline: 2px solid var(--color-main-text) !important;
- border-radius: 20px;
+ outline: var(--border-width-input-focused) solid var(--color-main-text) !important;
+ border-radius: var(--border-radius-element);
}
&:focus:not(:focus-visible) .files-list__row-name-text {
outline: none !important;
@@ -583,8 +827,8 @@ export default defineComponent({
.files-list__row-name-text {
color: var(--color-main-text);
// Make some space for the outline
- padding: 5px 10px;
- margin-left: -10px;
+ padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
+ padding-inline-start: -10px;
// Align two name and ext
display: inline-flex;
}
@@ -603,7 +847,7 @@ export default defineComponent({
input {
width: 100%;
// Align with text, 0 - padding - border
- margin-left: -8px;
+ margin-inline-start: -8px;
padding: 2px 6px;
border-width: 2px;
@@ -634,44 +878,56 @@ export default defineComponent({
}
.files-list__row-action--inline {
- margin-right: 7px;
+ 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);
+ width: calc(var(--row-height) * 2);
// Right align content/text
justify-content: flex-end;
}
.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) * 3.5);
}
.files-list__row-column-custom {
- width: calc(var(--row-height) * 2);
+ width: calc(var(--row-height) * 2.5);
}
}
}
+
+@media screen and (max-width: 512px) {
+ .files-list :deep(.files-list__filters) {
+ // Reduce padding on mobile
+ padding-inline: var(--default-grid-baseline, 4px);
+ }
+}
+
</style>
<style lang="scss">
// Grid mode
-tbody.files-list__tbody.files-list__tbody--grid {
- --half-clickable-area: calc(var(--clickable-area) / 2);
- --row-width: 160px;
- // We use half of the clickable area as visual balance margin
- --row-height: calc(var(--row-width) - var(--half-clickable-area));
- --icon-preview-size: calc(var(--row-width) - var(--clickable-area));
+.files-list--grid tbody.files-list__tbody {
+ --item-padding: 16px;
+ --icon-preview-size: 166px;
+ --name-height: var(--default-clickable-area);
+ --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline));
+ --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2);
+ --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2);
--checkbox-padding: 0px;
-
display: grid;
grid-template-columns: repeat(auto-fill, var(--row-width));
- grid-gap: 15px;
- row-gap: 15px;
align-content: center;
align-items: center;
@@ -679,29 +935,44 @@ tbody.files-list__tbody.files-list__tbody--grid {
justify-items: center;
tr {
+ display: flex;
+ flex-direction: column;
width: var(--row-width);
- height: calc(var(--row-height) + var(--clickable-area));
+ height: var(--row-height);
border: none;
- border-radius: var(--border-radius);
+ border-radius: var(--border-radius-large);
+ padding: var(--item-padding);
}
// Checkbox in the top left
.files-list__row-checkbox {
position: absolute;
z-index: 9;
- top: 0;
- left: 0;
+ top: calc(var(--item-padding) / 2);
+ inset-inline-start: calc(var(--item-padding) / 2);
overflow: hidden;
- width: var(--clickable-area);
- height: var(--clickable-area);
- border-radius: var(--half-clickable-area);
+ --checkbox-container-size: 44px;
+ width: var(--checkbox-container-size);
+ height: var(--checkbox-container-size);
+
+ // Add a background to the checkbox so we do not see the image through it.
+ .checkbox-radio-switch__content::after {
+ content: '';
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ inset-inline-start: 50%;
+ margin-inline-start: -8px;
+ z-index: -1;
+ background: var(--color-main-background);
+ }
}
// Star icon in the top right
.files-list__row-icon-favorite {
position: absolute;
top: 0;
- right: 0;
+ inset-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -710,38 +981,55 @@ tbody.files-list__tbody.files-list__tbody--grid {
}
.files-list__row-name {
- display: grid;
- justify-content: stretch;
- width: 100%;
- height: 100%;
- grid-auto-rows: var(--row-height) var(--clickable-area);
+ display: flex;
+ flex-direction: column;
+ width: var(--icon-preview-size);
+ height: calc(var(--icon-preview-size) + var(--name-height));
+ // Ensure that the name outline is visible.
+ overflow: visible;
span.files-list__row-icon {
- width: 100%;
- height: 100%;
- // Visual balance, we use half of the clickable area
- // as a margin around the preview
- padding-top: var(--half-clickable-area);
- }
-
- a.files-list__row-name-link {
- // Minus action menu
- width: calc(100% - var(--clickable-area));
- height: var(--clickable-area);
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
}
.files-list__row-name-text {
margin: 0;
- padding-right: 0;
+ // Ensure that the outline is not too close to the text.
+ margin-inline-start: -4px;
+ padding: 0px 4px;
}
}
+ .files-list__row-mtime {
+ width: var(--icon-preview-size);
+ height: var(--mtime-height);
+ font-size: var(--font-size-small);
+ }
+
.files-list__row-actions {
position: absolute;
- right: 0;
- bottom: 0;
+ inset-inline-end: calc(var(--clickable-area) / 4);
+ inset-block-end: calc(var(--mtime-height) / 2);
width: var(--clickable-area);
height: var(--clickable-area);
}
}
+
+@media screen and (max-width: 768px) {
+ // there is no mtime
+ .files-list--grid tbody.files-list__tbody {
+ --mtime-height: 0px;
+
+ // so we move the action to the name
+ .files-list__row-actions {
+ inset-block-end: var(--item-padding);
+ }
+
+ // and we need to keep space on the name for the actions
+ .files-list__row-name-text {
+ padding-inline-end: var(--clickable-area) !important;
+ }
+ }
+}
</style>
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
new file mode 100644
index 00000000000..c29bc00c67f
--- /dev/null
+++ b/apps/files/src/components/FilesNavigationItem.vue
@@ -0,0 +1,182 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <Fragment>
+ <NcAppNavigationItem v-for="view in currentViews"
+ :key="view.id"
+ class="files-navigation__item"
+ allow-collapse
+ :loading="view.loading"
+ :data-cy-files-navigation-item="view.id"
+ :exact="useExactRouteMatching(view)"
+ :icon="view.iconClass"
+ :name="view.name"
+ :open="isExpanded(view)"
+ :pinned="view.sticky"
+ :to="generateToNavigation(view)"
+ :style="style"
+ @update:open="(open) => onOpen(open, view)">
+ <template v-if="view.icon" #icon>
+ <NcIconSvgWrapper :svg="view.icon" />
+ </template>
+
+ <!-- Hack to force the collapse icon to be displayed -->
+ <li v-if="view.loadChildViews && !view.loaded" style="display: none" />
+
+ <!-- Recursively nest child views -->
+ <FilesNavigationItem v-if="hasChildViews(view)"
+ :parent="view"
+ :level="level + 1"
+ :views="filterView(views, parent.id)" />
+ </NcAppNavigationItem>
+ </Fragment>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { View } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { Fragment } from 'vue-frag'
+
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import { useNavigation } from '../composables/useNavigation.js'
+import { useViewConfigStore } from '../store/viewConfig.js'
+
+const maxLevel = 7 // Limit nesting to not exceed max call stack size
+
+export default defineComponent({
+ name: 'FilesNavigationItem',
+
+ components: {
+ Fragment,
+ NcAppNavigationItem,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ parent: {
+ type: Object as PropType<View>,
+ default: () => ({}),
+ },
+ level: {
+ type: Number,
+ default: 0,
+ },
+ views: {
+ type: Object as PropType<Record<string, View[]>>,
+ default: () => ({}),
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+ const viewConfigStore = useViewConfigStore()
+ return {
+ currentView,
+ viewConfigStore,
+ }
+ },
+
+ computed: {
+ currentViews(): View[] {
+ if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
+ return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
+ .filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
+ }
+ return this.filterVisible(this.views[this.parent.id] ?? [])
+ },
+
+ style() {
+ if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level
+ return null
+ }
+ return {
+ 'padding-left': '16px',
+ }
+ },
+ },
+
+ 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.filterVisible(this.views[view.id] ?? []).length > 0
+ },
+
+ /**
+ * Only use exact route matching on routes with child views
+ * Because if a view does not have children (like the files view) then multiple routes might be matched for it
+ * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
+ * @param view The view to check
+ */
+ useExactRouteMatching(view: View): boolean {
+ return this.hasChildViews(view)
+ },
+
+ /**
+ * Generate the route to a view
+ * @param view View to generate "to" navigation for
+ */
+ generateToNavigation(view: View) {
+ if (view.params) {
+ const { dir } = view.params
+ return { name: 'filelist', params: { ...view.params }, query: { dir } }
+ }
+ return { name: 'filelist', params: { view: view.id } }
+ },
+
+ /**
+ * Check if a view is expanded by user config
+ * or fallback to the default value.
+ * @param view View to check if expanded
+ */
+ isExpanded(view: View): boolean {
+ return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
+ ? this.viewConfigStore.getConfig(view.id).expanded === true
+ : view.expanded === true
+ },
+
+ /**
+ * Expand/collapse a a view with children and permanently
+ * save this setting in the server.
+ * @param open True if open
+ * @param view View
+ */
+ async onOpen(open: boolean, view: View) {
+ // Invert state
+ const isExpanded = this.isExpanded(view)
+ // Update the view expanded state, might not be necessary
+ view.expanded = !isExpanded
+ this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
+ if (open && view.loadChildViews) {
+ await view.loadChildViews(view)
+ }
+ },
+
+ /**
+ * Return the view map with the specified view id removed
+ *
+ * @param viewMap Map of views
+ * @param id View id
+ */
+ filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
+ return Object.fromEntries(
+ Object.entries(viewMap)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ .filter(([viewId, _views]) => viewId !== id),
+ )
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue
new file mode 100644
index 00000000000..0890dffcb39
--- /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 everywhere …')
+ }
+ return t('files', 'Search here …')
+})
+</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', 'Search here') }}
+ </NcActionButton>
+ <NcActionButton close-after-click @click="searchStore.scope = 'globally'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiSearchWeb" />
+ </template>
+ {{ t('files', 'Search everywhere') }}
+ </NcActionButton>
+ </NcActions>
+ </template>
+ </NcAppNavigationSearch>
+</template>
diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue
index 4a50ed558f0..b5a792d9029 100644
--- a/apps/files/src/components/LegacyView.vue
+++ b/apps/files/src/components/LegacyView.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<div />
@@ -50,10 +33,8 @@ export default {
},
methods: {
setFileInfo(fileInfo) {
- this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
+ this.component.setFileInfo(fileInfo)
},
},
}
</script>
-<style>
-</style>
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index 943d61cf0f5..46c8e5c9af4 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -1,6 +1,10 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
<NcAppNavigationItem v-if="storageStats"
- :aria-label="t('files', 'Storage informations')"
+ :aria-description="t('files', 'Storage information')"
:class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}"
:loading="loadingStorageStats"
:name="storageStatsTitle"
@@ -13,6 +17,7 @@
<!-- Progress bar -->
<NcProgressBar v-if="storageStats.quota >= 0"
slot="extra"
+ :aria-label="t('files', 'Storage quota')"
:error="storageStats.relative > 80"
:value="Math.min(storageStats.relative, 100)" />
</NcAppNavigationItem>
@@ -28,11 +33,11 @@ import { subscribe } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import ChartPie from 'vue-material-design-icons/ChartPie.vue'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
+import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
-import logger from '../logger.js'
+import logger from '../logger.ts'
export default {
name: 'NavigationQuota',
@@ -53,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) {
@@ -88,8 +93,17 @@ export default {
},
mounted() {
- // Warn the user if the available storage is 0 on page load
- if (this.storageStats?.free <= 0) {
+ // If the user has a quota set, warn if the available account storage is <=0
+ //
+ // NOTE: This doesn't catch situations where actual *server*
+ // disk (non-quota) space is low, but those should probably
+ // be handled differently anyway since a regular user can't
+ // can't do much about them (If we did want to indicate server disk
+ // space matters to users, we'd probably want to use a warning
+ // specific to that situation anyhow. So this covers warning covers
+ // our primary day-to-day concern (individual account quota usage).
+ //
+ if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) {
this.showStorageFullWarning()
}
},
@@ -108,7 +122,7 @@ export default {
* Update the storage stats
* Throttled at max 1 refresh per minute
*
- * @param {Event} [event = null] if user interaction
+ * @param {Event} [event] if user interaction
*/
async updateStorageStats(event = null) {
if (this.loadingStorageStats) {
@@ -122,8 +136,9 @@ export default {
throw new Error('Invalid storage stats')
}
- // Warn the user if the available storage changed from > 0 to 0
- if (this.storageStats?.free > 0 && response.data.data?.free <= 0) {
+ // Warn the user if the available account storage changed from > 0 to 0
+ // (unless only because quota was intentionally set to 0 by admin in the interim)
+ if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) {
this.showStorageFullWarning()
}
@@ -152,15 +167,18 @@ export default {
// User storage stats display
.app-navigation-entry__settings-quota {
// Align title with progress and icon
- &--not-unlimited::v-deep .app-navigation-entry__name {
- margin-top: -6px;
+ --app-navigation-quota-margin: calc((var(--default-clickable-area) - 24px) / 2); // 20px icon size and 4px progress bar
+
+ &--not-unlimited :deep(.app-navigation-entry__name) {
+ line-height: 1;
+ margin-top: var(--app-navigation-quota-margin);
}
progress {
position: absolute;
- bottom: 12px;
- margin-left: 44px;
- width: calc(100% - 44px - 22px);
+ bottom: var(--app-navigation-quota-margin);
+ margin-inline-start: var(--default-clickable-area);
+ width: calc(100% - (1.5 * var(--default-clickable-area)));
}
}
</style>
diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
new file mode 100644
index 00000000000..ca10935940d
--- /dev/null
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -0,0 +1,168 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog data-cy-files-new-node-dialog
+ :name="name"
+ :open="open"
+ close-on-click-outside
+ out-transition
+ @update:open="emit('close', null)">
+ <template #actions>
+ <NcButton data-cy-files-new-node-dialog-submit
+ type="primary"
+ :disabled="validity !== ''"
+ @click="submit">
+ {{ t('files', 'Create') }}
+ </NcButton>
+ </template>
+ <form ref="formElement"
+ class="new-node-dialog__form"
+ @submit.prevent="emit('close', localDefaultName)">
+ <NcTextField ref="nameInput"
+ data-cy-files-new-node-dialog-input
+ :error="validity !== ''"
+ :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>
+
+<script setup lang="ts">
+import type { ComponentPublicInstance, PropType } from 'vue'
+import { getUniqueName } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { extname } from 'path'
+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({
+ /**
+ * The name to be used by default
+ */
+ defaultName: {
+ type: String,
+ default: t('files', 'New folder'),
+ },
+ /**
+ * Other files that are in the current directory
+ */
+ otherNames: {
+ type: Array as PropType<string[]>,
+ default: () => [],
+ },
+ /**
+ * Open state of the dialog
+ */
+ open: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * Dialog name
+ */
+ name: {
+ type: String,
+ default: t('files', 'Create new folder'),
+ },
+ /**
+ * Input label
+ */
+ label: {
+ type: String,
+ default: t('files', 'Folder name'),
+ },
+})
+
+const emit = defineEmits<{
+ (event: 'close', name: string | null): void
+}>()
+
+const localDefaultName = ref<string>(props.defaultName)
+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
+ */
+function focusInput() {
+ nextTick(() => {
+ // get the input element
+ const input = nameInput.value?.$el.querySelector('input')
+ if (!props.open || !input) {
+ return
+ }
+
+ // length of the basename
+ const length = localDefaultName.value.length - extname(localDefaultName.value).length
+ // focus the input
+ input.focus()
+ // and set the selection to the basename (name without extension)
+ input.setSelectionRange(0, length)
+ })
+}
+
+/**
+ * Trigger submit on the form
+ */
+function submit() {
+ formElement.value?.requestSubmit()
+}
+
+// Reset local name on props change
+watch(() => [props.defaultName, props.otherNames], () => {
+ localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim()
+})
+
+// Validate the local name
+watchEffect(() => {
+ if (props.otherNames.includes(localDefaultName.value.trim())) {
+ validity.value = t('files', 'This name is already in use.')
+ } else {
+ validity.value = getFilenameValidity(localDefaultName.value.trim())
+ }
+ const input = nameInput.value?.$el.querySelector('input')
+ if (input) {
+ input.setCustomValidity(validity.value)
+ input.reportValidity()
+ }
+})
+
+// Ensure the input is focussed even if the dialog is already mounted but not open
+watch(() => props.open, () => {
+ nextTick(() => {
+ focusInput()
+ })
+})
+
+onMounted(() => {
+ // on mounted lets use the unique name
+ localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim()
+ nextTick(() => focusInput())
+})
+</script>
+
+<style scoped>
+.new-node-dialog__form {
+ /* Ensure the dialog does not jump when there is a validity error */
+ min-height: calc(2 * var(--default-clickable-area));
+}
+</style>
diff --git a/apps/files/src/components/PersonalSettings.vue b/apps/files/src/components/PersonalSettings.vue
index 5f5dc31eafb..b076b0c1e3d 100644
--- a/apps/files/src/components/PersonalSettings.vue
+++ b/apps/files/src/components/PersonalSettings.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @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>
<div id="files-personal-settings" class="section">
diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue
index cb22dc3e477..7a9ffb137a2 100644
--- a/apps/files/src/components/Setting.vue
+++ b/apps/files/src/components/Setting.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev>
- -
- - @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>
<div />
diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue
index 78c050048f1..d86e5da9d20 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<NcAppSidebarTab :id="id"
ref="tab"
@@ -38,8 +21,8 @@
</template>
<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
@@ -65,7 +48,7 @@ export default {
},
icon: {
type: String,
- required: false,
+ default: '',
},
/**
diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue
new file mode 100644
index 00000000000..3f1db8dfd58
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller.vue
@@ -0,0 +1,122 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcModal label-id="template-field-modal__label">
+ <div class="template-field-modal__content">
+ <form>
+ <h3 id="template-field-modal__label">
+ {{ t('files', 'Fill template fields') }}
+ </h3>
+
+ <div v-for="field in fields" :key="field.index">
+ <component :is="getFieldComponent(field.type)"
+ v-if="fieldHasLabel(field)"
+ :field="field"
+ @input="trackInput" />
+ </div>
+ </form>
+ </div>
+
+ <div class="template-field-modal__buttons">
+ <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" />
+ <NcButton aria-label="Submit button"
+ type="primary"
+ @click="submit">
+ {{ t('files', 'Submit') }}
+ </NcButton>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue'
+import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue'
+
+export default defineComponent({
+ name: 'TemplateFiller',
+
+ components: {
+ NcModal,
+ NcButton,
+ NcLoadingIcon,
+ TemplateRichTextField,
+ TemplateCheckboxField,
+ },
+
+ props: {
+ fields: {
+ type: Array,
+ default: () => [],
+ },
+ onSubmit: {
+ type: Function,
+ default: async () => {},
+ },
+ },
+
+ data() {
+ return {
+ localFields: {},
+ loading: false,
+ }
+ },
+
+ methods: {
+ t,
+ trackInput({ index, property, value }) {
+ if (!this.localFields[index]) {
+ this.localFields[index] = {}
+ }
+
+ this.localFields[index][property] = value
+ },
+ getFieldComponent(fieldType) {
+ const fieldComponentType = fieldType.split('-')
+ .map((str) => {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ })
+ .join('')
+
+ return `Template${fieldComponentType}Field`
+ },
+ fieldHasLabel(field) {
+ return field.name || field.alias
+ },
+ async submit() {
+ this.loading = true
+
+ await this.onSubmit(this.localFields)
+
+ this.$emit('close')
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+$modal-margin: calc(var(--default-grid-baseline) * 4);
+
+.template-field-modal__content {
+ padding: $modal-margin;
+
+ h3 {
+ text-align: center;
+ }
+}
+
+.template-field-modal__buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--default-grid-baseline);
+ margin: $modal-margin;
+ margin-top: 0;
+}
+</style>
diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
new file mode 100644
index 00000000000..18536171bd2
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
@@ -0,0 +1,68 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="template-field__checkbox">
+ <NcCheckboxRadioSwitch :id="fieldId"
+ :checked.sync="value"
+ type="switch"
+ @update:checked="input">
+ {{ fieldLabel }}
+ </NcCheckboxRadioSwitch>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+export default defineComponent({
+ name: 'TemplateCheckboxField',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ },
+
+ props: {
+ field: {
+ type: Object,
+ default: () => {},
+ },
+ },
+
+ data() {
+ return {
+ value: this.field.checked ?? false,
+ }
+ },
+
+ computed: {
+ fieldLabel() {
+ const label = this.field.name || this.field.alias
+
+ return label.charAt(0).toUpperCase() + label.slice(1)
+ },
+ fieldId() {
+ return 'checkbox-field' + this.field.index
+ },
+ },
+
+ methods: {
+ input() {
+ this.$emit('input', {
+ index: this.field.index,
+ property: 'checked',
+ value: this.value,
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.template-field__checkbox {
+ margin: 20px 0;
+}
+</style>
diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
new file mode 100644
index 00000000000..f49819f7e7c
--- /dev/null
+++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
@@ -0,0 +1,77 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="template-field__text">
+ <label :for="fieldId">
+ {{ fieldLabel }}
+ </label>
+
+ <NcTextField :id="fieldId"
+ type="text"
+ :value.sync="value"
+ :label="fieldLabel"
+ :label-outside="true"
+ :placeholder="field.content"
+ @input="input" />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+export default defineComponent({
+ name: 'TemplateRichTextField',
+
+ components: {
+ NcTextField,
+ },
+
+ props: {
+ field: {
+ type: Object,
+ default: () => {},
+ },
+ },
+
+ data() {
+ return {
+ value: '',
+ }
+ },
+
+ computed: {
+ fieldLabel() {
+ const label = this.field.name || this.field.alias
+
+ return (label.charAt(0).toUpperCase() + label.slice(1))
+ },
+ fieldId() {
+ return 'text-field' + this.field.index
+ },
+ },
+
+ methods: {
+ input() {
+ this.$emit('input', {
+ index: this.field.index,
+ property: 'content',
+ value: this.value,
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.template-field__text {
+ margin: 20px 0;
+
+ label {
+ font-weight: bold;
+ }
+}
+</style>
diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue
index 53195d028c6..7927948d3af 100644
--- a/apps/files/src/components/TemplatePreview.vue
+++ b/apps/files/src/components/TemplatePreview.vue
@@ -1,35 +1,19 @@
<!--
- - @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>
<li class="template-picker__item">
<input :id="id"
+ ref="input"
:checked="checked"
type="radio"
class="radio"
name="template-picker"
@change="onCheck">
- <label :for="id" class="template-picker__label">
+ <label :for="id" class="template-picker__label" @click="onClick">
<div class="template-picker__preview"
:class="failedPreview ? 'template-picker__preview--failed' : ''">
<img class="template-picker__image"
@@ -47,9 +31,9 @@
</template>
<script>
+import { encodePath } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
-import { encodeFilePath } from '../utils/fileUtils.ts'
-import { getToken, isPublic } from '../utils/davUtils.js'
+import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public'
// preview width generation
const previewWidth = 256
@@ -123,8 +107,8 @@ export default {
return this.previewUrl
}
// TODO: find a nicer standard way of doing this?
- if (isPublic()) {
- return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`)
+ if (isPublicShare()) {
+ return generateUrl(`/apps/files_sharing/publicpreview/${getSharingToken()}?fileId=${this.fileid}&file=${encodePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`)
}
return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`)
},
@@ -141,6 +125,14 @@ export default {
onFailure() {
this.failedPreview = true
},
+ focus() {
+ this.$refs.input?.focus()
+ },
+ onClick() {
+ if (this.checked) {
+ this.$emit('confirm-click', this.fileid)
+ }
+ },
},
}
</script>
@@ -209,12 +201,9 @@ export default {
}
&__title {
- overflow: hidden;
// also count preview border
- max-width: calc(var(--width) + 2*2px);
+ max-width: calc(var(--width) + 2 * 2px);
padding: var(--margin);
- white-space: nowrap;
- text-overflow: ellipsis;
}
}
diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue
index e7cecb5224c..3d668da8144 100644
--- a/apps/files/src/components/TransferOwnershipDialogue.vue
+++ b/apps/files/src/components/TransferOwnershipDialogue.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @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>
<div>
@@ -25,7 +9,7 @@
<form @submit.prevent="submit">
<p class="transfer-select-row">
<span>{{ readableDirectory }}</span>
- <NcButton v-if="directory === undefined"
+ <NcButton v-if="directory === undefined"
class="transfer-select-row__choose_button"
@click.prevent="start">
{{ t('files', 'Choose file or folder to transfer') }}
@@ -34,18 +18,16 @@
{{ t('files', 'Change') }}
</NcButton>
</p>
- <p class="new-owner-row">
+ <p class="new-owner">
<label for="targetUser">
<span>{{ t('files', 'New owner') }}</span>
</label>
- <NcSelect input-id="targetUser"
- v-model="selectedUser"
+ <NcSelect v-model="selectedUser"
+ input-id="targetUser"
:options="formatedUserSuggestions"
:multiple="false"
:loading="loadingUsers"
- label="displayName"
:user-select="true"
- class="middle-align"
@search="findUserDebounced" />
</p>
<p>
@@ -64,11 +46,11 @@ import axios from '@nextcloud/axios'
import debounce from 'debounce'
import { generateOcsUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import Vue from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
-import logger from '../logger.js'
+import logger from '../logger.ts'
const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer'))
.setMultiSelect(false)
@@ -106,6 +88,7 @@ export default {
user: user.uid,
displayName: user.displayName,
icon: 'icon-user',
+ subname: user.shareWithDisplayNameUnique,
}
})
},
@@ -172,6 +155,7 @@ export default {
Vue.set(this.userSuggestions, user.value.shareWith, {
uid: user.value.shareWith,
displayName: user.label,
+ shareWithDisplayNameUnique: user.shareWithDisplayNameUnique,
})
})
} catch (error) {
@@ -219,16 +203,15 @@ export default {
</script>
<style scoped lang="scss">
-.middle-align {
- vertical-align: middle;
-}
p {
margin-top: 12px;
margin-bottom: 12px;
}
-.new-owner-row {
+
+.new-owner {
display: flex;
- flex-wrap: wrap;
+ flex-direction: column;
+ max-width: 400px;
label {
display: flex;
@@ -236,18 +219,14 @@ p {
margin-bottom: calc(var(--default-grid-baseline) * 2);
span {
- margin-right: 8px;
+ margin-inline-end: 8px;
}
}
-
- .multiselect {
- flex-grow: 1;
- max-width: 280px;
- }
}
+
.transfer-select-row {
span {
- margin-right: 8px;
+ margin-inline-end: 8px;
}
&__choose_button {
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 77454772f55..4746fedf863 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -1,15 +1,37 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
- <div class="files-list" data-cy-files-list>
+ <div class="files-list"
+ :class="{ 'files-list--grid': gridMode }"
+ data-cy-files-list
+ @scroll.passive="onScroll">
+ <!-- Header -->
+ <div ref="before" class="files-list__before">
+ <slot name="before" />
+ </div>
+
+ <div ref="filters" class="files-list__filters">
+ <slot name="filters" />
+ </div>
+
<div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay">
<slot name="header-overlay" />
</div>
- <!-- Header -->
- <div ref="before" class="files-list__before">
- <slot name="before" />
+ <div v-if="dataSources.length === 0"
+ class="files-list__empty">
+ <slot name="empty" />
</div>
- <table class="files-list__table">
+ <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 }}
@@ -23,7 +45,6 @@
<!-- Body -->
<tbody :style="tbodyStyle"
class="files-list__tbody"
- :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="({key, item}, i) in renderedItems"
@@ -34,7 +55,7 @@
</tbody>
<!-- Footer -->
- <tfoot v-show="isReady"
+ <tfoot ref="footer"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@@ -47,21 +68,22 @@
import type { File, Folder, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
-import { debounce } from 'debounce'
-import Vue from 'vue'
+import { defineComponent } from 'vue'
+import debounce from 'debounce'
-import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import logger from '../logger.js'
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import logger from '../logger.ts'
interface RecycledPoolItem {
key: string,
item: Node,
}
-export default Vue.extend({
- name: 'VirtualList',
+type DataSource = File | Folder
+type DataSourceKey = keyof DataSource
- mixins: [filesListWidthMixin],
+export default defineComponent({
+ name: 'VirtualList',
props: {
dataComponent: {
@@ -69,11 +91,11 @@ export default Vue.extend({
required: true,
},
dataKey: {
- type: String,
+ type: String as PropType<DataSourceKey>,
required: true,
},
dataSources: {
- type: Array as PropType<(File | Folder)[]>,
+ type: Array as PropType<DataSource[]>,
required: true,
},
extraProps: {
@@ -89,7 +111,7 @@ export default Vue.extend({
default: false,
},
/**
- * Visually hidden caption for the table accesibility
+ * Visually hidden caption for the table accessibility
*/
caption: {
type: String,
@@ -97,10 +119,19 @@ export default Vue.extend({
},
},
+ setup() {
+ const fileListWidth = useFileListWidth()
+
+ return {
+ fileListWidth,
+ }
+ },
+
data() {
return {
index: this.scrollToIndex,
beforeHeight: 0,
+ footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
@@ -116,35 +147,66 @@ export default Vue.extend({
// Items to render before and after the visible area
bufferItems() {
if (this.gridMode) {
+ // 1 row before and after in grid mode
return this.columnCount
}
+ // 3 rows before and after
return 3
},
itemHeight() {
// Align with css in FilesListVirtual
- // 138px + 44px (name) + 15px (grid gap)
- return this.gridMode ? (138 + 44 + 15) : 55
+ // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
},
+
// Grid mode only
itemWidth() {
- // 160px + 15px grid gap
- return 160 + 15
+ // 166px + 16px x 2 (padding left and right)
+ return 166 + 16 + 16
},
- rowCount() {
- return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
},
- columnCount() {
+
+ /**
+ * Number of rows that will be rendered.
+ * This includes only visible + buffer rows.
+ */
+ rowCount(): number {
+ return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
+ },
+
+ /**
+ * Number of columns.
+ * 1 for list view otherwise depending on the file list width.
+ */
+ columnCount(): number {
if (!this.gridMode) {
return 1
}
- return Math.floor(this.filesListWidth / this.itemWidth)
+ return Math.floor(this.fileListWidth / this.itemWidth)
},
+ /**
+ * Index of the first item to be rendered
+ * The index can be any file, not just the first one
+ * But the start index is the first item to be rendered,
+ * which needs to align with the column count
+ */
startIndex() {
- return Math.max(0, this.index - this.bufferItems)
+ const firstColumnIndex = this.index - (this.index % this.columnCount)
+ return Math.max(0, firstColumnIndex - this.bufferItems)
},
+
+ /**
+ * Number of items to be rendered at the same time
+ * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount`
+ */
shownItems() {
// If in grid mode, we need to multiply the number of rows by the number of columns
if (this.gridMode) {
@@ -153,6 +215,7 @@ export default Vue.extend({
return this.rowCount
},
+
renderedItems(): RecycledPoolItem[] {
if (!this.isReady) {
return []
@@ -181,13 +244,23 @@ export default Vue.extend({
})
},
+ /**
+ * The total number of rows that are available
+ */
+ totalRowCount() {
+ return Math.ceil(this.dataSources.length / this.columnCount)
+ },
+
tbodyStyle() {
- const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
- const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
- const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
+ // The number of (virtual) rows above the currently rendered ones.
+ // start index is aligned so this should always be an integer
+ const rowsAbove = Math.round(this.startIndex / this.columnCount)
+ // The number of (virtual) rows below the currently rendered ones.
+ const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount)
+
return {
- paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
- paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
+ minHeight: `${this.totalRowCount * this.itemHeight}px`,
}
},
},
@@ -195,11 +268,17 @@ export default Vue.extend({
scrollToIndex(index) {
this.scrollTo(index)
},
+
+ totalRowCount() {
+ if (this.scrollToIndex) {
+ this.scrollTo(this.scrollToIndex)
+ }
+ },
+
columnCount(columnCount, oldColumnCount) {
if (oldColumnCount === 0) {
- // We're initializing, the scroll position
- // is handled on mounted
- console.debug('VirtualList: columnCount is 0, skipping scroll')
+ // We're initializing, the scroll position is handled on mounted
+ logger.debug('VirtualList: columnCount is 0, skipping scroll')
return
}
// If the column count changes in grid view,
@@ -209,30 +288,28 @@ export default Vue.extend({
},
mounted() {
- const before = this.$refs?.before as HTMLElement
- const root = this.$el as HTMLElement
- const thead = this.$refs?.thead as HTMLElement
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
this.resizeObserver = new ResizeObserver(debounce(() => {
- this.beforeHeight = before?.clientHeight ?? 0
- this.headerHeight = thead?.clientHeight ?? 0
- this.tableHeight = root?.clientHeight ?? 0
+ this.updateHeightVariables()
logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
- }, 100, false))
-
- this.resizeObserver.observe(before)
- this.resizeObserver.observe(root)
- this.resizeObserver.observe(thead)
-
- if (this.scrollToIndex) {
- this.scrollTo(this.scrollToIndex)
- }
-
- // Adding scroll listener AFTER the initial scroll to index
- this.$el.addEventListener('scroll', this.onScroll, { passive: true })
-
- this.$_recycledPool = {} as Record<string, any>
+ }, 100))
+ this.resizeObserver.observe(this.$el)
+ this.resizeObserver.observe(this.$refs.before as HTMLElement)
+ this.resizeObserver.observe(this.$refs.filters as HTMLElement)
+ this.resizeObserver.observe(this.$refs.footer as HTMLElement)
+
+ this.$nextTick(() => {
+ // Make sure height values are initialized
+ this.updateHeightVariables()
+ // If we need to scroll to an index we do so in the next tick.
+ // This is needed to apply updates from the initialization of the height variables
+ // which will update the tbody styles until next tick.
+ if (this.scrollToIndex) {
+ this.scrollTo(this.scrollToIndex)
+ }
+ })
},
beforeDestroy() {
@@ -243,23 +320,105 @@ export default Vue.extend({
methods: {
scrollTo(index: number) {
+ if (!this.$el || this.index === index) {
+ return
+ }
+
+ // Check if the content is smaller (not equal! keep the footer in mind) than the viewport
+ // meaning there is no scrollbar
+ if (this.totalRowCount < this.visibleRows) {
+ logger.debug('VirtualList: Skip scrolling, nothing to scroll', {
+ index,
+ totalRows: this.totalRowCount,
+ visibleRows: this.visibleRows,
+ })
+ return
+ }
+
+ // We can not scroll further as the last page of rows
+ // For the grid view we also need to account for all columns in that row (columnCount - 1)
+ const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1)
+ // The scroll position
+ let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex))
+
+ // First we need to update the internal index for rendering.
+ // This will cause the <tbody> element to be resized allowing us to set the correct scroll position.
this.index = index
- // Scroll to one row and a half before the index
- const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
- logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount })
- this.$el.scrollTop = scrollTop
+
+ // If this is not the first row we can add a half row from above.
+ // This is to help users understand the table is scrolled and not items did not just disappear.
+ // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area)
+ if (index >= this.columnCount && index <= clampedIndex) {
+ scrollTop -= (this.itemHeight / 2)
+ // As we render one half row more we also need to adjust the internal index
+ this.index = index - this.columnCount
+ } else if (index > clampedIndex) {
+ // If we are on the last page we cannot scroll any further
+ // but we can at least scroll the footer into view
+ if (index <= (clampedIndex + this.columnCount)) {
+ // We only show have of the footer for the first of the last page
+ // To still show the previous row partly. Same reasoning as above:
+ // help the user understand that the table is scrolled not "magically trimmed"
+ scrollTop += this.footerHeight / 2
+ } else {
+ // We reached the very end of the files list and we are focussing not the first visible row
+ // so all we now can do is scroll to the end (footer)
+ scrollTop += this.footerHeight
+ }
+ }
+
+ // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position
+ this.$nextTick(() => {
+ this.$el.scrollTop = scrollTop
+ logger.debug(`VirtualList: scrolling to index ${index}`, {
+ clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight,
+ })
+ })
},
onScroll() {
this._onScrollHandle ??= requestAnimationFrame(() => {
this._onScrollHandle = null
- const topScroll = this.$el.scrollTop - this.beforeHeight
- const index = Math.floor(topScroll / this.itemHeight) * this.columnCount
+
+ const index = this.scrollPosToIndex(this.$el.scrollTop)
+ if (index === this.index) {
+ return
+ }
+
// Max 0 to prevent negative index
- this.index = Math.max(0, index)
+ this.index = Math.max(0, Math.floor(index))
this.$emit('scroll')
})
},
+
+ // Convert scroll position to index
+ // It should be the opposite of `indexToScrollPos`
+ scrollPosToIndex(scrollPos: number): number {
+ const topScroll = scrollPos - this.beforeHeight
+ // Max 0 to prevent negative index
+ return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount
+ },
+
+ // Convert index to scroll position
+ // It should be the opposite of `scrollPosToIndex`
+ indexToScrollPos(index: number): number {
+ return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight
+ },
+
+ /**
+ * Update the height variables.
+ * To be called by resize observer and `onMount`
+ */
+ updateHeightVariables(): void {
+ this.tableHeight = this.$el?.clientHeight ?? 0
+ this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0
+ this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0
+
+ // Get the header height which consists of table header and filters
+ const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0
+ const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0
+ this.headerHeight = theadHeight + filterHeight
+ },
},
})
</script>