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.vue310
-rw-r--r--apps/files/src/components/CustomElementRender.vue54
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue262
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue165
-rw-r--r--apps/files/src/components/FileEntry.vue276
-rw-r--r--apps/files/src/components/FileEntry/CollectivesIcon.vue45
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue76
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue399
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue173
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue288
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue300
-rw-r--r--apps/files/src/components/FileEntryGrid.vue135
-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.vue100
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue164
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue237
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue337
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue91
-rw-r--r--apps/files/src/components/FilesListVirtual.vue1035
-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.vue77
-rw-r--r--apps/files/src/components/NewNodeDialog.vue168
-rw-r--r--apps/files/src/components/PersonalSettings.vue24
-rw-r--r--apps/files/src/components/Setting.vue23
-rw-r--r--apps/files/src/components/SidebarTab.vue30
-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.vue49
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue90
-rw-r--r--apps/files/src/components/VirtualList.vue424
38 files changed, 6611 insertions, 195 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
new file mode 100644
index 00000000000..8458fd65f3d
--- /dev/null
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -0,0 +1,310 @@
+<!--
+ - 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')"
+ 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)"
+ @dragover.native="onDragOver($event, section.dir)"
+ @drop="onDrop($event, section.dir)">
+ <template v-if="index === 0" #icon>
+ <NcIconSvgWrapper :size="20"
+ :svg="viewIcon" />
+ </template>
+ </NcBreadcrumb>
+
+ <!-- Forward the actions slot -->
+ <template #actions>
+ <slot name="actions" />
+ </template>
+ </NcBreadcrumbs>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { FileSource } from '../types.ts'
+
+import { basename } from 'path'
+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: {
+ NcBreadcrumbs,
+ NcBreadcrumb,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ path: {
+ type: String,
+ default: '/',
+ },
+ },
+
+ 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: {
+ dirs(): string[] {
+ const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`)
+ // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
+ const paths: string[] = this.path.split('/').filter(Boolean).map(cumulativePath('/'))
+ // Strip away trailing slash
+ return ['/', ...paths.map((path: string) => path.replace(/^(.+)\/$/, '$1'))]
+ },
+
+ sections() {
+ 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: 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: {
+ getNodeFromSource(source: FileSource): Node | undefined {
+ return this.filesStore.getNode(source)
+ },
+ getFileSourceFromPath(path: string): FileSource | null {
+ return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
+ },
+ getDirDisplayName(path: string): string {
+ if (path === '/') {
+ return this.currentView?.name || t('files', 'Home')
+ }
+
+ 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) {
+ if (to?.query?.dir === this.$route.query.dir) {
+ this.$emit('reload')
+ }
+ },
+
+ 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')
+ } else if (index === 0) {
+ return t('files', 'Go to the "{dir}" directory', section)
+ }
+ return null
+ },
+
+ ariaForSection(section) {
+ if (section?.to?.query?.dir === this.$route.query.dir) {
+ return t('files', 'Reload current directory')
+ }
+ return null
+ },
+
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.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;
+ }
+ }
+
+ &--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
new file mode 100644
index 00000000000..b08d3ba5ee5
--- /dev/null
+++ b/apps/files/src/components/CustomElementRender.vue
@@ -0,0 +1,54 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <span />
+</template>
+
+<script lang="ts">
+/**
+ * This component is used to render custom
+ * elements provided by an API. Vue doesn't allow
+ * to directly render an HTMLElement, so we can do
+ * this magic here.
+ */
+export default {
+ name: 'CustomElementRender',
+ props: {
+ source: {
+ type: Object,
+ required: true,
+ },
+ currentView: {
+ type: Object,
+ required: true,
+ },
+ render: {
+ type: Function,
+ required: true,
+ },
+ },
+ watch: {
+ source() {
+ this.updateRootElement()
+ },
+ currentView() {
+ this.updateRootElement()
+ },
+ },
+ mounted() {
+ this.updateRootElement()
+ },
+ methods: {
+ async updateRootElement() {
+ const element = await this.render(this.source, this.currentView)
+ if (element) {
+ this.$el.replaceChildren(element)
+ } else {
+ this.$el.replaceChildren()
+ }
+ },
+ },
+}
+</script>
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
new file mode 100644
index 00000000000..c7684d5c205
--- /dev/null
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -0,0 +1,262 @@
+<!--
+ - 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">
+ <template v-if="canUpload && !isQuotaExceeded">
+ <TrayArrowDownIcon :size="48" />
+ <h3 class="files-list-drag-drop-notice__title">
+ {{ t('files', 'Drag and drop files here to upload') }}
+ </h3>
+ </template>
+
+ <!-- Not permitted to drop files here -->
+ <template v-else>
+ <h3 class="files-list-drag-drop-notice__title">
+ {{ cantUploadLabel }}
+ </h3>
+ </template>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+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 { 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',
+
+ components: {
+ TrayArrowDownIcon,
+ },
+
+ props: {
+ currentFolder: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+
+ return {
+ currentView,
+ }
+ },
+
+ data() {
+ return {
+ dragover: false,
+ }
+ },
+
+ computed: {
+ /**
+ * Check if the current folder has create permissions
+ */
+ canUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+ },
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ cantUploadLabel() {
+ 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 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.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.getElementById('app-content-vue') as HTMLElement
+ mainContent.removeEventListener('dragover', this.onDragOver)
+ mainContent.removeEventListener('dragleave', this.onDragLeave)
+ mainContent.removeEventListener('drop', this.onContentDrop)
+ },
+
+ methods: {
+ onDragOver(event: DragEvent) {
+ // Needed to keep the drag/drop events chain working
+ event.preventDefault()
+
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ // Only handle uploading of outside files (not Nextcloud files)
+ this.dragover = true
+ this.resetDragOver()
+ }
+ },
+
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ // Avoid flickering
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) {
+ return
+ }
+
+ if (this.dragover) {
+ this.dragover = false
+ this.resetDragOver.clear()
+ }
+ },
+
+ onContentDrop(event: DragEvent) {
+ logger.debug('Drag and drop cancelled, dropped on empty space', { event })
+ event.preventDefault()
+ if (this.dragover) {
+ this.dragover = false
+ this.resetDragOver.clear()
+ }
+ },
+
+ async onDrop(event: DragEvent) {
+ // cantUploadLabel is null if we can upload
+ if (this.cantUploadLabel) {
+ showError(this.cantUploadLabel)
+ return
+ }
+
+ if (this.$el.querySelector('tbody')?.contains(event.target as Node)) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ // 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,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.files-list__drag-drop-notice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ // Breadcrumbs height + row thead height
+ min-height: calc(58px + 44px);
+ margin: 0;
+ user-select: none;
+ color: var(--color-text-maxcontrast);
+ background-color: var(--color-main-background);
+ border-color: black;
+
+ h3 {
+ margin-inline-start: 16px;
+ color: inherit;
+ }
+
+ &-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 15vh;
+ max-height: 70%;
+ padding: 0 5vw;
+ border: 2px var(--color-border-dark) dashed;
+ border-radius: var(--border-radius-large);
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
new file mode 100644
index 00000000000..72fd98d43fb
--- /dev/null
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -0,0 +1,165 @@
+<!--
+ - 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">
+ <span ref="previewImg" />
+ <FolderIcon v-if="isSingleFolder" />
+ <FileMultipleIcon v-else />
+ </span>
+ <span class="files-list-drag-image__name">{{ name }}</span>
+ </div>
+</template>
+
+<script lang="ts">
+import { FileType, Node, formatFileSize } from '@nextcloud/files'
+import Vue from 'vue'
+
+import FileMultipleIcon from 'vue-material-design-icons/FileMultiple.vue'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+
+import { getSummaryFor } from '../utils/fileUtils.ts'
+
+export default Vue.extend({
+ name: 'DragAndDropPreview',
+
+ components: {
+ FileMultipleIcon,
+ FolderIcon,
+ },
+
+ data() {
+ return {
+ nodes: [] as Node[],
+ }
+ },
+
+ computed: {
+ isSingleNode() {
+ return this.nodes.length === 1
+ },
+ isSingleFolder() {
+ return this.isSingleNode
+ && this.nodes[0].type === FileType.Folder
+ },
+
+ name() {
+ if (!this.size) {
+ return this.summary
+ }
+ return `${this.summary} – ${this.size}`
+ },
+ size() {
+ const totalSize = this.nodes.reduce((total, node) => total + node.size || 0, 0)
+ const size = parseInt(totalSize, 10) || 0
+ if (typeof size !== 'number' || size < 0) {
+ return null
+ }
+ return formatFileSize(size, true)
+ },
+ summary(): string {
+ if (this.isSingleNode) {
+ const node = this.nodes[0]
+ return node.attributes?.displayname || node.basename
+ }
+
+ return getSummaryFor(this.nodes)
+ },
+ },
+
+ methods: {
+ update(nodes: Node[]) {
+ this.nodes = nodes
+ this.$refs.previewImg.replaceChildren()
+
+ // Clone icon node from the list
+ nodes.slice(0, 3).forEach(node => {
+ const preview = document.querySelector(`[data-cy-files-list-row-fileid="${node.fileid}"] .files-list__row-icon img`)
+ if (preview) {
+ const previewElmt = this.$refs.previewImg as HTMLElement
+ previewElmt.appendChild(preview.parentNode.cloneNode(true))
+ }
+ })
+
+ this.$nextTick(() => {
+ this.$emit('loaded', this.$el)
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+$size: 28px;
+$stack-shift: 6px;
+
+.files-list-drag-image {
+ position: absolute;
+ top: -9999px;
+ inset-inline-start: -9999px;
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ height: $size + $stack-shift;
+ padding: $stack-shift $stack-shift * 2;
+ background: var(--color-main-background);
+
+ &__icon,
+ .files-list__row-icon-preview-container {
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ justify-content: center;
+ width: $size - $stack-shift;
+ height: $size - $stack-shift;;
+ border-radius: var(--border-radius);
+ }
+
+ &__icon {
+ overflow: visible;
+ margin-inline-end: $stack-shift * 2;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ .material-design-icon {
+ color: var(--color-text-maxcontrast);
+ &.folder-icon {
+ color: var(--color-primary-element);
+ }
+ }
+
+ // Previews container
+ > span {
+ display: flex;
+
+ // Stack effect if more than one element
+ // Max 3 elements
+ > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container {
+ margin-top: $stack-shift;
+ 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) + * {
+ display: none;
+ }
+ }
+ }
+
+ &__name {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
new file mode 100644
index 00000000000..d66c3fa0ed7
--- /dev/null
+++ b/apps/files/src/components/FileEntry.vue
@@ -0,0 +1,276 @@
+<!--
+ - 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,
+ '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"
+ :draggable="canDrag"
+ class="files-list__row"
+ v-on="rowListeners">
+ <!-- Failed indicator -->
+ <span v-if="isFailedSource" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes"
+ :source="source" />
+
+ <!-- Link to file -->
+ <td class="files-list__row-name" data-cy-files-list-row-name>
+ <!-- Icon or preview -->
+ <FileEntryPreview ref="preview"
+ :source="source"
+ :dragover="dragover"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :basename="basename"
+ :extension="extension"
+ :nodes="nodes"
+ :source="source"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+ </td>
+
+ <!-- Actions -->
+ <FileEntryActions v-show="!isRenamingSmallScreen"
+ ref="actions"
+ :class="`files-list__row-actions-${uniqueId}`"
+ :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"
+ class="files-list__row-size"
+ data-cy-files-list-row-size
+ @click="openDetailsIfAvailable">
+ <span>{{ size }}</span>
+ </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" />
+ <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-column-custom"
+ :data-cy-files-list-row-column-custom="column.id"
+ @click="openDetailsIfAvailable">
+ <CustomElementRender :current-view="currentView"
+ :render="column.render"
+ :source="source" />
+ </td>
+ </tr>
+</template>
+
+<script lang="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 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'
+
+export default defineComponent({
+ name: 'FileEntry',
+
+ components: {
+ CustomElementRender,
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ NcDateTime,
+ },
+
+ mixins: [
+ FileEntryMixin,
+ ],
+
+ props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ 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,
+
+ currentDir,
+ currentFileId,
+ currentView,
+ filesListWidth,
+ }
+ },
+
+ computed: {
+ /**
+ * Conditionally add drag and drop listeners
+ * Do not add drag start and over listeners on renaming to allow to drag and drop text
+ */
+ rowListeners() {
+ const conditionals = this.isRenaming
+ ? {}
+ : {
+ dragstart: this.onDragStart,
+ dragover: this.onDragOver,
+ }
+
+ return {
+ ...conditionals,
+ contextmenu: this.onRightClick,
+ dragleave: this.onDragLeave,
+ dragend: this.onDragEnd,
+ drop: this.onDrop,
+ }
+ },
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512 || this.compact) {
+ return []
+ }
+ return this.currentView.columns || []
+ },
+
+ mime() {
+ if (this.source.type === FileType.Folder) {
+ return this.t('files', 'Folder')
+ }
+
+ if (!this.source.mime || this.source.mime === 'application/octet-stream') {
+ return t('files', 'Unknown file type')
+ }
+
+ if (window.OC?.MimeTypeList?.names?.[this.source.mime]) {
+ return window.OC.MimeTypeList.names[this.source.mime]
+ }
+
+ const baseType = this.source.mime.split('/')[0]
+ const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || ''
+ if (baseType === 'image') {
+ return t('files', '{ext} image', { ext })
+ }
+ if (baseType === 'video') {
+ return t('files', '{ext} video', { ext })
+ }
+ if (baseType === 'audio') {
+ return t('files', '{ext} audio', { ext })
+ }
+ if (baseType === 'text') {
+ return t('files', '{ext} text', { ext })
+ }
+
+ return this.source.mime
+ },
+ size() {
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return this.t('files', 'Pending')
+ }
+ return formatFileSize(size, true)
+ },
+
+ sizeOpacity() {
+ const maxOpacitySize = 10 * 1024 * 1024
+
+ const size = this.source.size
+ if (size === undefined || isNaN(size) || size < 0) {
+ return {}
+ }
+
+ 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))`,
+ }
+ },
+ },
+
+ created() {
+ useHotKey('Enter', this.triggerDefaultAction, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
+ methods: {
+ formatFileSize,
+
+ triggerDefaultAction() {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
+ }
+
+ this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir)
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue
new file mode 100644
index 00000000000..e22b30f4378
--- /dev/null
+++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue
@@ -0,0 +1,45 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <span :aria-hidden="!title"
+ :aria-label="title"
+ class="material-design-icon collectives-icon"
+ role="img"
+ v-bind="$attrs"
+ @click="$emit('click', $event)">
+ <svg :fill="fillColor"
+ class="material-design-icon__svg"
+ :width="size"
+ :height="size"
+ viewBox="0 0 16 16">
+ <path d="M2.9,8.8c0-1.2,0.4-2.4,1.2-3.3L0.3,6c-0.2,0-0.3,0.3-0.1,0.4l2.7,2.6C2.9,9,2.9,8.9,2.9,8.8z" />
+ <path d="M8,3.7c0.7,0,1.3,0.1,1.9,0.4L8.2,0.6c-0.1-0.2-0.3-0.2-0.4,0L6.1,4C6.7,3.8,7.3,3.7,8,3.7z" />
+ <path d="M3.7,11.5L3,15.2c0,0.2,0.2,0.4,0.4,0.3l3.3-1.7C5.4,13.4,4.4,12.6,3.7,11.5z" />
+ <path d="M15.7,6l-3.7-0.5c0.7,0.9,1.2,2,1.2,3.3c0,0.1,0,0.2,0,0.3l2.7-2.6C15.9,6.3,15.9,6.1,15.7,6z" />
+ <path d="M12.3,11.5c-0.7,1.1-1.8,1.9-3,2.2l3.3,1.7c0.2,0.1,0.4-0.1,0.4-0.3L12.3,11.5z" />
+ <path d="M9.6,10.1c-0.4,0.5-1,0.8-1.6,0.8c-1.1,0-2-0.9-2.1-2C5.9,7.7,6.8,6.7,8,6.7c0.6,0,1.1,0.3,1.5,0.7 c0.1,0.1,0.1,0.1,0.2,0.1h1.4c0.2,0,0.4-0.2,0.3-0.5c-0.7-1.3-2.1-2.2-3.8-2.1C5.8,5,4.3,6.6,4.1,8.5C4,10.8,5.8,12.7,8,12.7 c1.6,0,2.9-0.9,3.5-2.3c0.1-0.2-0.1-0.4-0.3-0.4H9.9C9.8,10,9.7,10,9.6,10.1z" />
+ </svg>
+ </span>
+</template>
+
+<script>
+export default {
+ name: 'CollectivesIcon',
+ props: {
+ title: {
+ type: String,
+ default: '',
+ },
+ fillColor: {
+ type: String,
+ default: 'currentColor',
+ },
+ size: {
+ type: Number,
+ default: 24,
+ },
+ },
+}
+</script>
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
new file mode 100644
index 00000000000..c66cb8fbd7f
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -0,0 +1,76 @@
+<!--
+ - 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>
+
+<script lang="ts">
+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/components/NcIconSvgWrapper'
+
+/**
+ * A favorite icon to be used for overlaying favorite entries like the file preview / icon
+ * It has a stroke around the star icon to ensure enough contrast for accessibility.
+ *
+ * If the background has a hover state you might want to also apply it to the stroke like this:
+ * ```scss
+ * .parent:hover :deep(.favorite-marker-icon svg path) {
+ * stroke: var(--color-background-hover);
+ * }
+ * ```
+ */
+export default defineComponent({
+ name: 'FavoriteIcon',
+ components: {
+ NcIconSvgWrapper,
+ },
+ data() {
+ return {
+ StarSvg,
+ }
+ },
+ 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
+ const el = this.$el.querySelector('svg')
+ el?.setAttribute?.('viewBox', '-4 -4 30 30')
+ },
+ methods: {
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.favorite-marker-icon {
+ color: var(--color-favorite);
+ // Override NcIconSvgWrapper defaults (clickable area)
+ min-width: unset !important;
+ min-height: unset !important;
+
+ :deep() {
+ svg {
+ // We added a stroke for a11y so we must increase the size to include the stroke
+ width: 20px !important;
+ height: 20px !important;
+
+ // Override NcIconSvgWrapper defaults of 20px
+ max-width: unset !important;
+ max-height: unset !important;
+
+ // Sow a border around the icon for better contrast
+ path {
+ stroke: var(--color-main-background);
+ stroke-width: 8px;
+ stroke-linejoin: round;
+ paint-order: stroke;
+ }
+ }
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
new file mode 100644
index 00000000000..5c537d878fe
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -0,0 +1,399 @@
+<!--
+ - 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>
+ <!-- Render actions -->
+ <CustomElementRender v-for="action in enabledRenderActions"
+ :key="action.id"
+ :class="'files-list__row-action-' + action.id"
+ :current-view="currentView"
+ :render="action.renderInline"
+ :source="source"
+ class="files-list__row-action--inline" />
+
+ <!-- Menu actions -->
+ <NcActions ref="actionsMenu"
+ :boundaries-element="getBoundariesElement"
+ :container="getBoundariesElement"
+ :force-name="true"
+ type="tertiary"
+ :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
+ :inline="enabledInlineActions.length"
+ :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--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ }"
+ :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>
+
+ <!-- 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" data-cy-files-list-row-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-action-${action.id}`"
+ class="files-list__row-action--submenu"
+ close-after-click
+ :data-cy-files-list-row-action="action.id"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+ </NcActions>
+ </td>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { FileAction, Node } from '@nextcloud/files'
+
+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 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,
+ CustomElementRender,
+ NcActionButton,
+ NcActions,
+ NcActionSeparator,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ },
+
+ mixins: [actionsMixins],
+
+ props: {
+ opened: {
+ type: Boolean,
+ default: false,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ 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 {
+ activeStore,
+ currentDir,
+ currentView,
+ enabledFileActions,
+ filesListWidth,
+ t,
+ }
+ },
+
+ computed: {
+ isActive() {
+ return this.activeStore?.activeNode?.source === this.source.source
+ },
+
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ // Enabled action that are displayed inline
+ enabledInlineActions() {
+ if (this.filesListWidth < 768 || this.gridMode) {
+ return []
+ }
+ 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
+ enabledRenderActions() {
+ if (this.gridMode) {
+ return []
+ }
+ return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
+ },
+
+ // Actions shown in the menu
+ enabledMenuActions() {
+ // If we're in a submenu, only render the inline
+ // actions before the filtered submenu
+ if (this.openedSubmenu) {
+ return this.enabledInlineActions
+ }
+
+ const actions = [
+ // Showing inline first for the NcActions inline prop
+ ...this.enabledInlineActions,
+ // Then the rest
+ ...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)
+ })
+
+ // Generate list of all top-level actions ids
+ const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[]
+
+ // Filter actions that are not top-level AND have a valid parent
+ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
+ },
+
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
+ },
+
+ openedMenu: {
+ get() {
+ return this.opened
+ },
+ set(value) {
+ this.$emit('update:opened', value)
+ },
+ },
+
+ /**
+ * Making this a function in case the files-list
+ * reference changes in the future. That way we're
+ * sure there is one at the time we call it.
+ */
+ getBoundariesElement() {
+ return document.querySelector('.app-content > .files-list')
+ },
+ },
+
+ 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) {
+ 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
+ }
+ },
+
+ 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
+ }
+
+ // Make sure we set the node as active
+ this.activeStore.activeNode = this.source
+
+ // Execute the action
+ await executeAction(action)
+ },
+
+ onKeyDown(event: KeyboardEvent) {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
+ }
+
+ // ESC close the action menu if opened
+ if (event.key === 'Escape' && this.openedMenu) {
+ this.openedMenu = false
+ }
+
+ // a open the action menu
+ if (event.key === 'a' && !this.openedMenu) {
+ this.openedMenu = true
+ }
+ },
+
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
+ this.openedSubmenu = null
+ },
+
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+// Allow right click to define the position of the menu
+// only if defined
+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"] {
+ // 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 {
+ display: none;
+ }
+}
+</style>
+
+<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);
+ }
+
+ // 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
new file mode 100644
index 00000000000..5b80a971118
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -0,0 +1,173 @@
+<!--
+ - 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" :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 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 { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+
+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.ts'
+
+export default defineComponent({
+ name: 'FileEntryCheckbox',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ },
+
+ props: {
+ fileid: {
+ type: Number,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ },
+
+ 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.source.source)
+ },
+ index() {
+ return this.nodes.findIndex((node: Node) => node.source === this.source.source)
+ },
+ isFile() {
+ return this.source.type === FileType.File
+ },
+ ariaLabel() {
+ return this.isFile
+ ? 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: {
+ onSelectionChange(selected: boolean) {
+ const newSelectedIndex = this.index
+ const lastSelectedIndex = this.selectionStore.lastSelectedIndex
+
+ // Get the last selected and select all files in between
+ if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
+ 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.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(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
+ this.selectionStore.set(selection)
+ return
+ }
+
+ const selection = selected
+ ? [...this.selectedFiles, this.source.source]
+ : this.selectedFiles.filter(source => source !== this.source.source)
+
+ logger.debug('Updating selection', { selection })
+ this.selectionStore.set(selection)
+ this.selectionStore.setLastIndex(newSelectedIndex)
+ },
+
+ resetSelection() {
+ this.selectionStore.reset()
+ },
+
+ 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
new file mode 100644
index 00000000000..418f9581eb6
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -0,0 +1,288 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <!-- Rename input -->
+ <form v-if="isRenaming"
+ ref="renameForm"
+ v-on-click-outside="onRename"
+ :aria-label="t('files', 'Rename file')"
+ class="files-list__row-rename"
+ @submit.prevent.stop="onRename">
+ <NcTextField ref="renameInput"
+ :label="renameLabel"
+ :autofocus="true"
+ :minlength="1"
+ :required="true"
+ :value.sync="newName"
+ enterkeyhint="done"
+ @keyup.esc="stopRenaming" />
+ </form>
+
+ <component :is="linkTo.is"
+ v-else
+ ref="basename"
+ class="files-list__row-name-link"
+ data-cy-files-list-row-name-link
+ 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 { showError, showSuccess } from '@nextcloud/dialogs'
+import { FileType, NodeStatus } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent, inject } from 'vue'
+
+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 { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryName',
+
+ components: {
+ NcTextField,
+ },
+
+ props: {
+ /**
+ * The filename without extension
+ */
+ basename: {
+ type: String,
+ required: true,
+ },
+ /**
+ * The extension of the filename
+ */
+ extension: {
+ type: String,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ 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,
+ }
+ },
+
+ computed: {
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+ newName: {
+ get(): string {
+ return this.renamingStore.newNodeName
+ },
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
+ },
+ },
+
+ renameLabel() {
+ const matchLabel: Record<FileType, string> = {
+ [FileType.File]: t('files', 'Filename'),
+ [FileType.Folder]: t('files', 'Folder name'),
+ }
+ return matchLabel[this.source.type]
+ },
+
+ linkTo() {
+ if (this.source.status === NodeStatus.FAILED) {
+ return {
+ is: 'span',
+ params: {
+ title: t('files', 'This node is unavailable'),
+ },
+ }
+ }
+
+ if (this.defaultFileAction) {
+ const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
+ return {
+ is: 'button',
+ params: {
+ 'aria-label': displayName,
+ title: 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',
+ }
+ },
+ },
+
+ watch: {
+ /**
+ * If renaming starts, select the filename
+ * in the input, without the extension.
+ * @param renaming
+ */
+ isRenaming: {
+ immediate: true,
+ handler(renaming: boolean) {
+ if (renaming) {
+ this.startRenaming()
+ }
+ },
+ },
+
+ newName() {
+ // Check validity of the new name
+ const newName = this.newName.trim?.() || ''
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ return
+ }
+
+ 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()
+ }
+ })
+ },
+ },
+
+ 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 input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ logger.error('Could not find the rename input')
+ return
+ }
+ 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
+ }
+
+ // Reset the renaming store
+ this.renamingStore.$reset()
+ },
+
+ // Rename and move the file
+ async onRename() {
+ const newName = this.newName.trim?.() || ''
+ const form = this.$refs.renameForm as HTMLFormElement
+ if (!form.checkValidity()) {
+ showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
+ return
+ }
+
+ const oldName = this.source.basename
+ if (newName === oldName) {
+ this.stopRenaming()
+ return
+ }
+
+ try {
+ 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
+ }
+ } catch (error) {
+ logger.error(error as Error)
+ showError((error as Error).message)
+ // And ensure we reset to the renaming state
+ this.startRenaming()
+ }
+ },
+
+ t,
+ },
+})
+</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
new file mode 100644
index 00000000000..3d0fffe7584
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -0,0 +1,300 @@
+<!--
+ - 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'">
+ <FolderOpenIcon v-if="dragover" v-once />
+ <template v-else>
+ <FolderIcon v-once />
+ <OverlayIcon :is="folderOverlay"
+ v-if="folderOverlay"
+ class="files-list__row-icon-overlay" />
+ </template>
+ </template>
+
+ <!-- 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 />
+
+ <!-- Favorite icon -->
+ <span v-if="isFavorite" class="files-list__row-icon-favorite">
+ <FavoriteIcon v-once />
+ </span>
+
+ <OverlayIcon :is="fileOverlay"
+ v-if="fileOverlay"
+ class="files-list__row-icon-overlay files-list__row-icon-overlay--file" />
+ </span>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { UserConfig } from '../../types.ts'
+
+import { Node, FileType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+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'
+import FileIcon from 'vue-material-design-icons/File.vue'
+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/NetworkOutline.vue'
+import TagIcon from 'vue-material-design-icons/Tag.vue'
+import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
+
+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 defineComponent({
+ name: 'FileEntryPreview',
+
+ components: {
+ AccountGroupIcon,
+ AccountPlusIcon,
+ CollectivesIcon,
+ FavoriteIcon,
+ FileIcon,
+ FolderIcon,
+ FolderOpenIcon,
+ KeyIcon,
+ LinkIcon,
+ NetworkIcon,
+ TagIcon,
+ },
+
+ props: {
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ dragover: {
+ type: Boolean,
+ default: false,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const userConfigStore = useUserConfigStore()
+ const isPublic = isPublicShare()
+ const publicSharingToken = getSharingToken()
+
+ return {
+ userConfigStore,
+
+ isPublic,
+ publicSharingToken,
+ }
+ },
+
+ data() {
+ return {
+ backgroundFailed: undefined as boolean | undefined,
+ backgroundLoaded: false,
+ }
+ },
+
+ computed: {
+ isFavorite(): boolean {
+ return this.source.attributes.favorite === 1
+ },
+
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+ cropPreviews(): boolean {
+ return this.userConfig.crop_image_previews === true
+ },
+
+ previewUrl() {
+ if (this.source.type === FileType.Folder) {
+ return null
+ }
+
+ if (this.backgroundFailed === true) {
+ return null
+ }
+
+ if (this.source.attributes['has-preview'] !== true
+ && this.source.mime !== undefined
+ && this.source.mime !== 'application/octet-stream'
+ ) {
+ const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
+ mime: this.source.mime,
+ })
+ const url = new URL(window.location.origin + previewUrl)
+ return url.href
+ }
+
+ try {
+ const previewUrl = this.source.attributes.previewUrl
+ || (this.isPublic
+ ? 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
+ url.searchParams.set('x', this.gridMode ? '128' : '32')
+ 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
+ } catch (e) {
+ return null
+ }
+ },
+
+ fileOverlay() {
+ if (isLivePhoto(this.source)) {
+ return PlayCircleIcon
+ }
+
+ return null
+ },
+
+ folderOverlay() {
+ if (this.source.type !== FileType.Folder) {
+ return null
+ }
+
+ // Encrypted folders
+ if (this.source?.attributes?.['is-encrypted'] === 1) {
+ return KeyIcon
+ }
+
+ // System tags
+ if (this.source?.attributes?.['is-tag']) {
+ return TagIcon
+ }
+
+ // Link and mail shared folders
+ const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
+ if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
+ return LinkIcon
+ }
+
+ // Shared folders
+ if (shareTypes.length > 0) {
+ return AccountPlusIcon
+ }
+
+ switch (this.source?.attributes?.['mount-type']) {
+ case 'external':
+ case 'external-session':
+ return NetworkIcon
+ case 'group':
+ 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() {
+ // 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,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
new file mode 100644
index 00000000000..1bd0572f53b
--- /dev/null
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -0,0 +1,135 @@
+<!--
+ - 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}"
+ data-cy-files-list-row
+ :data-cy-files-list-row-fileid="fileid"
+ :data-cy-files-list-row-name="source.basename"
+ :draggable="canDrag"
+ class="files-list__row"
+ @contextmenu="onRightClick"
+ @dragover="onDragOver"
+ @dragleave="onDragLeave"
+ @dragstart="onDragStart"
+ @dragend="onDragEnd"
+ @drop="onDrop">
+ <!-- Failed indicator -->
+ <span v-if="isFailedSource" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes"
+ :source="source" />
+
+ <!-- Link to file -->
+ <td class="files-list__row-name" data-cy-files-list-row-name>
+ <!-- Icon or preview -->
+ <FileEntryPreview ref="preview"
+ :dragover="dragover"
+ :grid-mode="true"
+ :source="source"
+ @auxclick.native="execDefaultAction"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :basename="basename"
+ :extension="extension"
+ :grid-mode="true"
+ :nodes="nodes"
+ :source="source"
+ @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}`"
+ :grid-mode="true"
+ :opened.sync="openedMenu"
+ :source="source" />
+ </tr>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+
+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'
+
+export default defineComponent({
+ name: 'FileEntryGrid',
+
+ components: {
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ NcDateTime,
+ },
+
+ mixins: [
+ FileEntryMixin,
+ ],
+
+ inheritAttrs: false,
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ 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 {
+ gridMode: true,
+ }
+ },
+})
+</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
new file mode 100644
index 00000000000..31458398028
--- /dev/null
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -0,0 +1,100 @@
+<!--
+ - 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" />
+ </div>
+</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
+ * to directly render an HTMLElement, so we can do
+ * this magic here.
+ */
+export default {
+ name: 'FilesListHeader',
+ props: {
+ header: {
+ type: Object as PropType<Header>,
+ required: true,
+ },
+ currentFolder: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ currentView: {
+ 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) ?? true
+ },
+ },
+ watch: {
+ enabled(enabled) {
+ if (!enabled) {
+ return
+ }
+ // 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)
+ },
+ currentView(view: View) {
+ this.queueUpdate(this.currentFolder, view)
+ },
+ },
+
+ mounted() {
+ 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
new file mode 100644
index 00000000000..9e8cdc159ee
--- /dev/null
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -0,0 +1,164 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <tr>
+ <th class="files-list__row-checkbox">
+ <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table -->
+ <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
+ </th>
+
+ <!-- Link to file -->
+ <td class="files-list__row-name">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Summary -->
+ <span>{{ summary }}</span>
+ </td>
+
+ <!-- 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">
+ <span>{{ totalSize }}</span>
+ </td>
+
+ <!-- Mtime -->
+ <td v-if="isMtimeAvailable"
+ class="files-list__column files-list__row-mtime" />
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)">
+ <span>{{ column.summary?.(nodes, currentView) }}</span>
+ </th>
+ </tr>
+</template>
+
+<script lang="ts">
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { View, formatFileSize } from '@nextcloud/files'
+import { translate } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { useRouteParameters } from '../composables/useRouteParameters.ts'
+
+export default defineComponent({
+ name: 'FilesListTableFooter',
+
+ props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ summary: {
+ type: String,
+ default: '',
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const pathsStore = usePathsStore()
+ const filesStore = useFilesStore()
+ const { directory } = useRouteParameters()
+ return {
+ filesStore,
+ pathsStore,
+ directory,
+ }
+ },
+
+ computed: {
+ currentFolder() {
+ if (!this.currentView?.id) {
+ return
+ }
+
+ if (this.directory === '/') {
+ return this.filesStore.getRoot(this.currentView.id)
+ }
+ const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)!
+ return this.filesStore.getNode(fileId)
+ },
+
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ totalSize() {
+ // If we have the size already, let's use it
+ if (this.currentFolder?.size) {
+ return formatFileSize(this.currentFolder.size, true)
+ }
+
+ // Otherwise let's compute it
+ return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true)
+ },
+ },
+
+ methods: {
+ classForColumn(column) {
+ return {
+ 'files-list__row-column-custom': true,
+ [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+// Scoped row
+tr {
+ margin-bottom: var(--body-container-margin);
+ border-top: 1px solid var(--color-border);
+ // Prevent hover effect on the whole row
+ background-color: transparent !important;
+ border-bottom: none !important;
+
+ td {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
new file mode 100644
index 00000000000..23e631199eb
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -0,0 +1,237 @@
+<!--
+ - 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" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" />
+ </th>
+
+ <!-- Columns display -->
+
+ <!-- Link to file -->
+ <th class="files-list__column files-list__row-name files-list__column--sortable"
+ :aria-sort="ariaSortForMode('basename')">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Name -->
+ <FilesListTableHeaderButton :name="t('files', 'Name')" mode="basename" />
+ </th>
+
+ <!-- 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"
+ :class="{ 'files-list__column--sortable': isSizeAvailable }"
+ :aria-sort="ariaSortForMode('size')">
+ <FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" />
+ </th>
+
+ <!-- Mtime -->
+ <th v-if="isMtimeAvailable"
+ class="files-list__column files-list__row-mtime"
+ :class="{ 'files-list__column--sortable': isMtimeAvailable }"
+ :aria-sort="ariaSortForMode('mtime')">
+ <FilesListTableHeaderButton :name="t('files', 'Modified')" mode="mtime" />
+ </th>
+
+ <!-- Custom views columns -->
+ <th v-for="column in columns"
+ :key="column.id"
+ :class="classForColumn(column)"
+ :aria-sort="ariaSortForMode(column.id)">
+ <FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
+ <span v-else>
+ {{ column.title }}
+ </span>
+ </th>
+ </tr>
+</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 { 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 FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
+import filesSortingMixin from '../mixins/filesSorting.ts'
+import logger from '../logger.ts'
+
+export default defineComponent({
+ name: 'FilesListTableHeader',
+
+ components: {
+ FilesListTableHeaderButton,
+ NcCheckboxRadioSwitch,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isMtimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const filesStore = useFilesStore()
+ const selectionStore = useSelectionStore()
+ const { currentView } = useNavigation()
+
+ return {
+ filesStore,
+ selectionStore,
+
+ currentView,
+ }
+ },
+
+ computed: {
+ columns() {
+ // Hide columns if the list is too small
+ if (this.filesListWidth < 512) {
+ return []
+ }
+ return this.currentView?.columns || []
+ },
+
+ dir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
+ },
+
+ selectAllBind() {
+ const label = t('files', 'Toggle selection for all files and folders')
+ return {
+ 'aria-label': label,
+ checked: this.isAllSelected,
+ indeterminate: this.isSomeSelected,
+ title: label,
+ }
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isAllSelected() {
+ return this.selectedNodes.length === this.nodes.length
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isSomeSelected() {
+ return !this.isAllSelected && !this.isNoneSelected
+ },
+ },
+
+ 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): 'ascending'|'descending'|null {
+ if (this.sortingMode === mode) {
+ return this.isAscSorting ? 'ascending' : 'descending'
+ }
+ return null
+ },
+
+ classForColumn(column) {
+ return {
+ '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,
+ }
+ },
+
+ onToggleAll(selected = true) {
+ if (selected) {
+ 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)
+ } else {
+ logger.debug('Cleared selection')
+ this.selectionStore.reset()
+ }
+ },
+
+ resetSelection() {
+ if (this.isNoneSelected) {
+ return
+ }
+ this.selectionStore.reset()
+ },
+
+ t,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__column {
+ user-select: none;
+ // Make sure the cell colors don't apply to column headers
+ color: var(--color-text-maxcontrast) !important;
+
+ &--sortable {
+ cursor: pointer;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
new file mode 100644
index 00000000000..6a808355c58
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -0,0 +1,337 @@
+<!--
+ - 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" data-cy-files-list-selection-actions>
+ <NcActions ref="actionsMenu"
+ container="#app-content-vue"
+ :boundaries-element="boundariesElement"
+ :disabled="!!loading || areSomeNodesLoading"
+ :force-name="true"
+ :inline="enabledInlineActions.length"
+ :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"
+ :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" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </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 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 { 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 actionsMixins from '../mixins/actionsMixin.ts'
+import logger from '../logger.ts'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default defineComponent({
+ name: 'FilesListTableHeaderActions',
+
+ components: {
+ ArrowLeftIcon,
+ NcActions,
+ NcActionButton,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ },
+
+ mixins: [actionsMixins],
+
+ props: {
+ currentView: {
+ type: Object as PropType<View>,
+ required: true,
+ },
+ selectedNodes: {
+ type: Array as PropType<FileSource[]>,
+ default: () => ([]),
+ },
+ },
+
+ setup() {
+ 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,
+ }
+ },
+
+ data() {
+ return {
+ loading: null,
+ }
+ },
+
+ computed: {
+ enabledFileActions(): FileAction[] {
+ return actions
+ // 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(source => this.getNode(source))
+ .filter(Boolean) as Node[]
+ },
+
+ areSomeNodesLoading() {
+ return this.nodes.some(node => node.status === NodeStatus.LOADING)
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === 'global'
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? 'global' : null
+ },
+ },
+
+ inlineActions() {
+ if (this.fileListWidth < 512) {
+ return 0
+ }
+ if (this.fileListWidth < 768) {
+ return 1
+ }
+ if (this.fileListWidth < 1024) {
+ return 2
+ }
+ return 3
+ },
+ },
+
+ methods: {
+ /**
+ * Get a cached note from the store
+ *
+ * @param source The source of the node to get
+ */
+ getNode(source: string): Node|undefined {
+ return this.filesStore.getNode(source)
+ },
+
+ async onActionClick(action) {
+ // 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 => {
+ this.$set(node, 'status', NodeStatus.LOADING)
+ })
+
+ // Dispatch action execution
+ const results = await action.execBatch(this.nodes, this.currentView, this.directory)
+
+ // Check if all actions returned null
+ if (!results.some(result => result !== null)) {
+ // If the actions returned null, we stay silent
+ this.selectionStore.reset()
+ return
+ }
+
+ // Handle potential failures
+ if (results.some(result => result === false)) {
+ // Remove the failed ids from the selection
+ 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
+ // is handling the error messages and we stay silent
+ return
+ }
+
+ showError(this.t('files', '{displayName}: failed on some elements', { displayName }))
+ return
+ }
+
+ // Show success message and clear selection
+ 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}: failed', { displayName }))
+ } finally {
+ // Remove loading markers
+ this.loading = null
+ this.nodes.forEach(node => {
+ this.$set(node, 'status', undefined)
+ })
+ }
+ },
+
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__row-actions-batch {
+ flex: 1 1 100% !important;
+ max-width: 100%;
+}
+</style>
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
new file mode 100644
index 00000000000..d2e14a5495f
--- /dev/null
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -0,0 +1,91 @@
+<!--
+ - 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,
+ 'files-list__column-sort-button--size': sortingMode === 'size',
+ }]"
+ :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" />
+ <MenuDown v-else class="files-list__column-sort-button-icon" />
+ </template>
+ <span class="files-list__column-sort-button-text">{{ name }}</span>
+ </NcButton>
+</template>
+
+<script lang="ts">
+import { translate } from '@nextcloud/l10n'
+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/components/NcButton'
+
+import filesSortingMixin from '../mixins/filesSorting.ts'
+
+export default defineComponent({
+ name: 'FilesListTableHeaderButton',
+
+ components: {
+ MenuDown,
+ MenuUp,
+ NcButton,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ mode: {
+ type: String,
+ required: true,
+ },
+ },
+
+ methods: {
+ t: translate,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__column-sort-button {
+ // Compensate for cells margin
+ margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1);
+ min-width: calc(100% - 3 * var(--cell-margin))!important;
+
+ &-text {
+ color: var(--color-text-maxcontrast);
+ font-weight: normal;
+ }
+
+ &-icon {
+ color: var(--color-text-maxcontrast);
+ opacity: 0;
+ transition: opacity var(--animation-quick);
+ inset-inline-start: -10px;
+ }
+
+ &--size &-icon {
+ inset-inline-start: 10px;
+ }
+
+ &--active &-icon,
+ &:hover &-icon,
+ &:focus &-icon,
+ &:active &-icon {
+ opacity: 1;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
new file mode 100644
index 00000000000..47b8ef19b19
--- /dev/null
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -0,0 +1,1035 @@
+<!--
+ - 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"
+ :data-key="'source'"
+ :data-sources="nodes"
+ :grid-mode="userConfig.grid_view"
+ :extra-props="{
+ isMimeAvailable,
+ isMtimeAvailable,
+ isSizeAvailable,
+ nodes,
+ }"
+ :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 headers"
+ :key="header.id"
+ :current-folder="currentFolder"
+ :current-view="currentView"
+ :header="header" />
+ </template>
+
+ <!-- Thead-->
+ <template #header>
+ <!-- Table header and sort buttons -->
+ <FilesListTableHeader ref="thead"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes" />
+ </template>
+
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
+ <!-- Tfoot-->
+ <template #footer>
+ <FilesListTableFooter :current-view="currentView"
+ :files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes"
+ :summary="summary" />
+ </template>
+ </VirtualList>
+</template>
+
+<script lang="ts">
+import type { UserConfig } from '../types'
+import type { Node as NcNode } from '@nextcloud/files'
+import type { ComponentPublicInstance, PropType } from 'vue'
+
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
+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 { 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 FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
+import VirtualList from './VirtualList.vue'
+
+export default defineComponent({
+ name: 'FilesListVirtual',
+
+ components: {
+ FileListFilters,
+ FilesListHeader,
+ FilesListTableFooter,
+ FilesListTableHeader,
+ VirtualList,
+ FilesListTableHeaderActions,
+ },
+
+ props: {
+ currentView: {
+ type: View,
+ required: true,
+ },
+ currentFolder: {
+ type: Folder,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<NcNode[]>,
+ required: true,
+ },
+ summary: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const activeStore = useActiveStore()
+ const selectionStore = useSelectionStore()
+ const userConfigStore = useUserConfigStore()
+
+ const fileListWidth = useFileListWidth()
+ const { fileId, openDetails, openFile } = useRouteParameters()
+
+ return {
+ fileId,
+ fileListWidth,
+ headers: useFileListHeaders(),
+ openDetails,
+ openFile,
+
+ activeStore,
+ selectionStore,
+ userConfigStore,
+
+ n,
+ t,
+ }
+ },
+
+ data() {
+ return {
+ FileEntry,
+ FileEntryGrid,
+ scrollToIndex: 0,
+ }
+ },
+
+ computed: {
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
+ isMimeAvailable() {
+ if (!this.userConfig.show_mime_column) {
+ return false
+ }
+ // Hide mime column on narrow screens
+ if (this.fileListWidth < 1024) {
+ return false
+ }
+ return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream')
+ },
+ isMtimeAvailable() {
+ // Hide mtime column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.mtime !== undefined)
+ },
+ isSizeAvailable() {
+ // Hide size column on narrow screens
+ if (this.fileListWidth < 768) {
+ return false
+ }
+ return this.nodes.some(node => node.size !== undefined)
+ },
+
+ cantUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
+ },
+
+ 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,
+ cantUploadCaption,
+ quotaExceededCaption,
+ sortableCaption,
+ virtualListNote,
+ ].filter(Boolean).join('\n')
+ },
+
+ selectedNodes() {
+ return this.selectionStore.selected
+ },
+
+ isNoneSelected() {
+ return this.selectedNodes.length === 0
+ },
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
+ },
+
+ watch: {
+ // 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)
+ 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: {
+ handleOpenQueries() {
+ // If the list is empty, or we don't have a fileId,
+ // there's nothing to be done.
+ if (this.isEmpty || !this.fileId) {
+ return
+ }
+
+ logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
+ nodes: this.nodes,
+ fileId: this.fileId,
+ openFile: this.openFile,
+ openDetails: this.openDetails,
+ })
+
+ if (this.openFile) {
+ this.handleOpenFile(this.fileId)
+ }
+
+ if (this.openDetails) {
+ this.openSidebarForFile(this.fileId)
+ }
+
+ if (this.fileId) {
+ this.scrollToFile(this.fileId, false)
+ }
+ },
+
+ openSidebarForFile(fileId) {
+ // Open the sidebar for the given URL fileid
+ // iif we just loaded the app.
+ 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(t('files', 'File not found'))
+ }
+
+ this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
+ }
+ },
+
+ /**
+ * 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,
+ )
+ }
+ },
+
+ /**
+ * 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
+ }
+
+ 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) {
+ // Detect if we're only dragging existing files or not
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ // Only handle uploading of existing Nextcloud files
+ // See DragAndDropNotice for handling of foreign files
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ 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) {
+ tableElement.scrollTop = tableElement.scrollTop - 25
+ return
+ }
+
+ // If reaching bottom, scroll down
+ if (event.clientY > tableBottom - 50) {
+ tableElement.scrollTop = tableElement.scrollTop + 25
+ }
+ },
+
+ 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: 44px;
+ --cell-margin: 14px;
+
+ --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
+ --checkbox-size: 24px;
+ --clickable-area: var(--default-clickable-area);
+ --icon-preview-size: 24px;
+
+ --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 {
+ will-change: padding;
+ contain: layout paint style;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ // Necessary for virtual scrolling absolute
+ position: relative;
+
+ /* Hover effect on tbody lines only */
+ tr {
+ contain: strict;
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-dark);
+ }
+ }
+ }
+
+ // Before table and thead
+ .files-list__before {
+ display: flex;
+ 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__filters {
+ // Pinned on top when scrolling above table header
+ position: sticky;
+ top: 0;
+ // 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-block-end: 1px solid var(--color-border);
+ height: var(--row-height);
+ flex: 0 0 var(--row-height);
+ }
+
+ .files-list__thead,
+ .files-list__tfoot {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ background-color: var(--color-main-background);
+ }
+
+ // Table header
+ .files-list__thead {
+ // Pinned on top when scrolling
+ position: sticky;
+ z-index: 10;
+ top: var(--fixed-block-start-position);
+ }
+
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ tr {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ border-block-end: 1px solid var(--color-border);
+ box-sizing: border-box;
+ user-select: none;
+ height: var(--row-height);
+ }
+
+ td, th {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ justify-content: start;
+ width: var(--row-height);
+ height: var(--row-height);
+ margin: 0;
+ padding: 0;
+ color: var(--color-text-maxcontrast);
+ border: none;
+
+ // Columns should try to add any text
+ // node wrapped in a span. That should help
+ // with the ellipsis on overflow.
+ span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .files-list__row--failed {
+ position: absolute;
+ display: block;
+ top: 0;
+ inset-inline: 0;
+ bottom: 0;
+ opacity: .1;
+ z-index: -1;
+ background: var(--color-error);
+ }
+
+ .files-list__row-checkbox {
+ justify-content: center;
+
+ .checkbox-radio-switch {
+ display: flex;
+ justify-content: center;
+
+ --icon-size: var(--checkbox-size);
+
+ label.checkbox-radio-switch__label {
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ margin: 0;
+ padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
+ }
+
+ .checkbox-radio-switch__icon {
+ margin: 0 !important;
+ }
+ }
+ }
+
+ .files-list__row {
+ &:hover, &:focus, &:active, &--active, &--dragover {
+ // WCAG AA compliant
+ background-color: var(--color-background-hover);
+ // text-maxcontrast have been designed to pass WCAG AA over
+ // a white background, we need to adjust then.
+ --color-text-maxcontrast: var(--color-main-text);
+ > * {
+ --color-border: var(--color-border-dark);
+ }
+
+ // Hover state of the row should also change the favorite markers background
+ .favorite-marker-icon svg path {
+ stroke: var(--color-background-hover);
+ }
+ }
+
+ &--dragover * {
+ // Prevent dropping on row children
+ pointer-events: none;
+ }
+ }
+
+ // Entry preview or mime icon
+ .files-list__row-icon {
+ position: relative;
+ display: flex;
+ overflow: visible;
+ align-items: center;
+ // No shrinking or growing allowed
+ flex: 0 0 var(--icon-preview-size);
+ justify-content: center;
+ width: var(--icon-preview-size);
+ height: 100%;
+ // Show same padding as the checkbox right padding for visual balance
+ margin-inline-end: var(--checkbox-padding);
+ color: var(--color-primary-element);
+
+ // Icon is also clickable
+ * {
+ cursor: pointer;
+ }
+
+ & > span {
+ justify-content: flex-start;
+
+ &:not(.files-list__row-icon-favorite) svg {
+ width: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ // Slightly increase the size of the folder icon
+ &.folder-icon,
+ &.folder-open-icon {
+ margin: -3px;
+ svg {
+ width: calc(var(--icon-preview-size) + 6px);
+ height: calc(var(--icon-preview-size) + 6px);
+ }
+ }
+ }
+
+ &-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);
+ // animation: preview-gradient-fade 1.2s ease-in-out infinite;
+ }
+ }
+
+ &-favorite {
+ position: absolute;
+ top: 0px;
+ inset-inline-end: -10px;
+ }
+
+ // File and folder overlay
+ &-overlay {
+ position: absolute;
+ max-height: calc(var(--icon-preview-size) * 0.6);
+ max-width: calc(var(--icon-preview-size) * 0.6);
+ color: var(--color-primary-element-text);
+ // better alignment with the folder icon
+ margin-block-start: 2px;
+
+ // Improve icon contrast with a background for files
+ &--file {
+ color: var(--color-main-text);
+ background: var(--color-main-background);
+ border-radius: 100%;
+ }
+ }
+ }
+
+ // Entry link
+ .files-list__row-name {
+ // Prevent link from overflowing
+ overflow: hidden;
+ // Take as much space as possible
+ flex: 1 1 auto;
+
+ 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 !important;
+ }
+
+ // Keyboard indicator a11y
+ &:focus .files-list__row-name-text {
+ 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;
+ }
+ }
+
+ .files-list__row-name-text {
+ color: var(--color-main-text);
+ // Make some space for the outline
+ padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
+ padding-inline-start: -10px;
+ // Align two name and ext
+ display: inline-flex;
+ }
+
+ .files-list__row-name-ext {
+ color: var(--color-text-maxcontrast);
+ // always show the extension
+ overflow: visible;
+ }
+ }
+
+ // Rename form
+ .files-list__row-rename {
+ width: 100%;
+ max-width: 600px;
+ input {
+ width: 100%;
+ // Align with text, 0 - padding - border
+ margin-inline-start: -8px;
+ padding: 2px 6px;
+ border-width: 2px;
+
+ &:invalid {
+ // Show red border on invalid input
+ border-color: var(--color-error);
+ color: red;
+ }
+ }
+ }
+
+ .files-list__row-actions {
+ // take as much space as necessary
+ width: auto;
+
+ // Add margin to all cells after the actions
+ & ~ td,
+ & ~ th {
+ margin: 0 var(--cell-margin);
+ }
+
+ button {
+ .button-vue__text {
+ // Remove bold from default button styling
+ font-weight: normal;
+ }
+ }
+ }
+
+ .files-list__row-action--inline {
+ 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) * 2);
+ // Right align content/text
+ justify-content: flex-end;
+ }
+
+ .files-list__row-mtime {
+ 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.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
+.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));
+
+ align-content: center;
+ align-items: center;
+ justify-content: space-around;
+ justify-items: center;
+
+ tr {
+ display: flex;
+ flex-direction: column;
+ width: var(--row-width);
+ height: var(--row-height);
+ border: none;
+ 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: calc(var(--item-padding) / 2);
+ inset-inline-start: calc(var(--item-padding) / 2);
+ overflow: hidden;
+ --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;
+ inset-inline-end: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name {
+ 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: var(--icon-preview-size);
+ height: var(--icon-preview-size);
+ }
+
+ .files-list__row-name-text {
+ margin: 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;
+ 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 bfcbaea3776..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,25 +17,27 @@
<!-- 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>
</template>
<script>
+import { debounce, throttle } from 'throttle-debounce'
import { formatFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
-import { debounce, throttle } from 'throttle-debounce'
+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 logger from '../logger.js'
-import { subscribe } from '@nextcloud/event-bus'
+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.ts'
export default {
name: 'NavigationQuota',
@@ -51,8 +57,8 @@ export default {
computed: {
storageStatsTitle() {
- const usedQuotaByte = formatFileSize(this.storageStats?.used)
- const quotaByte = formatFileSize(this.storageStats?.quota)
+ const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false)
+ const quotaByte = formatFileSize(this.storageStats?.total, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
@@ -80,15 +86,26 @@ export default {
*/
setInterval(this.throttleUpdateStorageStats, 60 * 1000)
- subscribe('files:file:created', this.throttleUpdateStorageStats)
- subscribe('files:file:deleted', this.throttleUpdateStorageStats)
- subscribe('files:file:moved', this.throttleUpdateStorageStats)
- subscribe('files:file:updated', this.throttleUpdateStorageStats)
+ subscribe('files:node:created', this.throttleUpdateStorageStats)
+ subscribe('files:node:deleted', this.throttleUpdateStorageStats)
+ subscribe('files:node:moved', this.throttleUpdateStorageStats)
+ subscribe('files:node:updated', this.throttleUpdateStorageStats)
+ },
- subscribe('files:folder:created', this.throttleUpdateStorageStats)
- subscribe('files:folder:deleted', this.throttleUpdateStorageStats)
- subscribe('files:folder:moved', this.throttleUpdateStorageStats)
- subscribe('files:folder:updated', this.throttleUpdateStorageStats)
+ mounted() {
+ // 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()
+ }
},
methods: {
@@ -105,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) {
@@ -118,6 +135,13 @@ export default {
if (!response?.data?.data) {
throw new Error('Invalid storage stats')
}
+
+ // 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()
+ }
+
this.storageStats = response.data.data
} catch (error) {
logger.error('Could not refresh storage stats', { error })
@@ -130,6 +154,10 @@ export default {
}
},
+ showStorageFullWarning() {
+ showError(this.t('files', 'Your storage is full, files can not be updated or synced anymore!'))
+ },
+
t: translate,
},
}
@@ -139,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__title {
- margin-top: -4px;
+ --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: 10px;
- 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 1431ae4053a..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">
@@ -27,7 +11,7 @@
</template>
<script>
-import TransferOwnershipDialogue from './TransferOwnershipDialogue'
+import TransferOwnershipDialogue from './TransferOwnershipDialogue.vue'
export default {
name: 'PersonalSettings',
diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue
index c55a2841517..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 Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 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 ac3cfba7d02..d86e5da9d20 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -1,25 +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"
@@ -39,8 +21,8 @@
</template>
<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
@@ -66,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 ad152af9ea3..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'
-import { getToken, isPublic } from '../utils/davUtils'
+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>
@@ -182,7 +174,7 @@ export default {
border-radius: var(--border-radius-large);
input:checked + label > & {
- border-color: var(--color-primary);
+ border-color: var(--color-primary-element);
}
&--failed {
@@ -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 67840b18829..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,41 +9,33 @@
<form @submit.prevent="submit">
<p class="transfer-select-row">
<span>{{ readableDirectory }}</span>
- <NcButton v-if="directory === undefined" @click.prevent="start">
+ <NcButton v-if="directory === undefined"
+ class="transfer-select-row__choose_button"
+ @click.prevent="start">
{{ t('files', 'Choose file or folder to transfer') }}
</NcButton>
<NcButton v-else @click.prevent="start">
{{ t('files', 'Change') }}
</NcButton>
- <span class="error">{{ directoryPickerError }}</span>
</p>
- <p class="new-owner-row">
+ <p class="new-owner">
<label for="targetUser">
<span>{{ t('files', 'New owner') }}</span>
</label>
- <NcMultiselect id="targetUser"
- v-model="selectedUser"
+ <NcSelect v-model="selectedUser"
+ input-id="targetUser"
:options="formatedUserSuggestions"
:multiple="false"
- :searchable="true"
- :placeholder="t('files', 'Search users')"
- :preselect-first="true"
- :preserve-search="true"
:loading="loadingUsers"
- track-by="user"
- label="displayName"
- :internal-search="false"
- :clear-on-select="false"
:user-select="true"
- class="middle-align"
- @search-change="findUserDebounced" />
+ @search="findUserDebounced" />
</p>
<p>
- <input type="submit"
- class="primary"
- :value="submitButtonText"
+ <NcButton native-type="submit"
+ type="primary"
:disabled="!canSubmit">
- <span class="error">{{ submitError }}</span>
+ {{ submitButtonText }}
+ </NcButton>
</p>
</form>
</div>
@@ -69,16 +45,15 @@
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import { generateOcsUrl } from '@nextcloud/router'
-import { getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs'
-import NcMultiselect from '@nextcloud/vue/dist/Components/NcMultiselect'
+import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import Vue from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton'
+import NcButton from '@nextcloud/vue/components/NcButton'
-import logger from '../logger'
+import logger from '../logger.ts'
const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer'))
.setMultiSelect(false)
- .setModal(true)
.setType(1)
.allowDirectories()
.build()
@@ -86,7 +61,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans
export default {
name: 'TransferOwnershipDialogue',
components: {
- NcMultiselect,
+ NcSelect,
NcButton,
},
data() {
@@ -113,6 +88,7 @@ export default {
user: user.uid,
displayName: user.displayName,
icon: 'icon-user',
+ subname: user.shareWithDisplayNameUnique,
}
})
},
@@ -152,6 +128,7 @@ export default {
logger.error(`Selecting object for transfer aborted: ${error.message || 'Unknown error'}`, { error })
this.directoryPickerError = error.message || t('files', 'Unknown error')
+ showError(this.directoryPickerError)
})
},
async findUser(query) {
@@ -178,6 +155,7 @@ export default {
Vue.set(this.userSuggestions, user.value.shareWith, {
uid: user.value.shareWith,
displayName: user.label,
+ shareWithDisplayNameUnique: user.shareWithDisplayNameUnique,
})
})
} catch (error) {
@@ -217,6 +195,7 @@ export default {
} else {
this.submitError = error.message || t('files', 'Unknown error')
}
+ showError(this.submitError)
})
},
},
@@ -224,33 +203,34 @@ 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-direction: column;
+ max-width: 400px;
label {
display: flex;
align-items: center;
+ 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 {
+ width: min(100%, 400px) !important;
}
}
</style>
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
new file mode 100644
index 00000000000..4746fedf863
--- /dev/null
+++ b/apps/files/src/components/VirtualList.vue
@@ -0,0 +1,424 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <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>
+
+ <div v-if="dataSources.length === 0"
+ class="files-list__empty">
+ <slot name="empty" />
+ </div>
+
+ <table :aria-hidden="dataSources.length === 0"
+ :inert="dataSources.length === 0"
+ class="files-list__table"
+ :class="{
+ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'],
+ 'files-list__table--hidden': dataSources.length === 0,
+ }">
+ <!-- Accessibility table caption for screen readers -->
+ <caption v-if="caption" class="hidden-visually">
+ {{ caption }}
+ </caption>
+
+ <!-- Header -->
+ <thead ref="thead" class="files-list__thead" data-cy-files-list-thead>
+ <slot name="header" />
+ </thead>
+
+ <!-- Body -->
+ <tbody :style="tbodyStyle"
+ class="files-list__tbody"
+ data-cy-files-list-tbody>
+ <component :is="dataComponent"
+ v-for="({key, item}, i) in renderedItems"
+ :key="key"
+ :source="item"
+ :index="i"
+ v-bind="extraProps" />
+ </tbody>
+
+ <!-- Footer -->
+ <tfoot ref="footer"
+ class="files-list__tfoot"
+ data-cy-files-list-tfoot>
+ <slot name="footer" />
+ </tfoot>
+ </table>
+ </div>
+</template>
+
+<script lang="ts">
+import type { File, Folder, Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import debounce from 'debounce'
+
+import { useFileListWidth } from '../composables/useFileListWidth.ts'
+import logger from '../logger.ts'
+
+interface RecycledPoolItem {
+ key: string,
+ item: Node,
+}
+
+type DataSource = File | Folder
+type DataSourceKey = keyof DataSource
+
+export default defineComponent({
+ name: 'VirtualList',
+
+ props: {
+ dataComponent: {
+ type: [Object, Function],
+ required: true,
+ },
+ dataKey: {
+ type: String as PropType<DataSourceKey>,
+ required: true,
+ },
+ dataSources: {
+ type: Array as PropType<DataSource[]>,
+ required: true,
+ },
+ extraProps: {
+ type: Object as PropType<Record<string, unknown>>,
+ default: () => ({}),
+ },
+ scrollToIndex: {
+ type: Number,
+ default: 0,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ /**
+ * Visually hidden caption for the table accessibility
+ */
+ caption: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ const fileListWidth = useFileListWidth()
+
+ return {
+ fileListWidth,
+ }
+ },
+
+ data() {
+ return {
+ index: this.scrollToIndex,
+ beforeHeight: 0,
+ footerHeight: 0,
+ headerHeight: 0,
+ tableHeight: 0,
+ resizeObserver: null as ResizeObserver | null,
+ }
+ },
+
+ computed: {
+ // Wait for measurements to be done before rendering
+ isReady() {
+ return this.tableHeight > 0
+ },
+
+ // 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
+ // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
+ },
+
+ // Grid mode only
+ itemWidth() {
+ // 166px + 16px x 2 (padding left and right)
+ return 166 + 16 + 16
+ },
+
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
+ },
+
+ /**
+ * 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.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() {
+ 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) {
+ return this.rowCount * this.columnCount
+ }
+
+ return this.rowCount
+ },
+
+ renderedItems(): RecycledPoolItem[] {
+ if (!this.isReady) {
+ return []
+ }
+
+ const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[]
+
+ const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey]))
+ const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string)
+ const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key]))
+
+ return items.map(item => {
+ const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey])
+ // If defined, let's keep the key
+ if (index !== -1) {
+ return {
+ key: Object.keys(this.$_recycledPool)[index],
+ item,
+ }
+ }
+
+ // Get and consume reusable key or generate a new one
+ const key = unusedKeys.pop() || Math.random().toString(36).substr(2)
+ this.$_recycledPool[key] = item[this.dataKey]
+ return { key, item }
+ })
+ },
+
+ /**
+ * The total number of rows that are available
+ */
+ totalRowCount() {
+ return Math.ceil(this.dataSources.length / this.columnCount)
+ },
+
+ tbodyStyle() {
+ // 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 {
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
+ minHeight: `${this.totalRowCount * this.itemHeight}px`,
+ }
+ },
+ },
+ watch: {
+ 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
+ logger.debug('VirtualList: columnCount is 0, skipping scroll')
+ return
+ }
+ // If the column count changes in grid view,
+ // update the scroll position again
+ this.scrollTo(this.index)
+ },
+ },
+
+ mounted() {
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
+
+ this.resizeObserver = new ResizeObserver(debounce(() => {
+ this.updateHeightVariables()
+ logger.debug('VirtualList: resizeObserver updated')
+ this.onScroll()
+ }, 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() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect()
+ }
+ },
+
+ 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
+
+ // 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 index = this.scrollPosToIndex(this.$el.scrollTop)
+ if (index === this.index) {
+ return
+ }
+
+ // Max 0 to prevent negative 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>