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/CustomSvgIconRender.vue68
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue155
-rw-r--r--apps/files/src/components/FileEntry.vue779
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue (renamed from apps/files/src/components/FavoriteIcon.vue)24
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue243
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue131
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue330
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue215
-rw-r--r--apps/files/src/components/FileEntryGrid.vue408
-rw-r--r--apps/files/src/components/FilesListFooter.vue175
-rw-r--r--apps/files/src/components/FilesListHeaderActions.vue226
-rw-r--r--apps/files/src/components/FilesListHeaderButton.vue122
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue13
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue17
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue6
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue17
-rw-r--r--apps/files/src/components/FilesListVirtual.vue399
-rw-r--r--apps/files/src/components/NavigationQuota.vue4
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue10
-rw-r--r--apps/files/src/components/VirtualList.vue159
20 files changed, 2079 insertions, 1422 deletions
diff --git a/apps/files/src/components/CustomSvgIconRender.vue b/apps/files/src/components/CustomSvgIconRender.vue
deleted file mode 100644
index 4edb51806d1..00000000000
--- a/apps/files/src/components/CustomSvgIconRender.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <span class="custom-svg-icon" />
-</template>
-
-<script>
-// eslint-disable-next-line import/named
-import { sanitize } from 'dompurify'
-
-export default {
- name: 'CustomSvgIconRender',
- props: {
- svg: {
- type: String,
- required: true,
- },
- },
- watch: {
- svg() {
- this.$el.innerHTML = sanitize(this.svg)
- },
- },
- mounted() {
- this.$el.innerHTML = sanitize(this.svg)
- },
-}
-</script>
-<style lang="scss" scoped>
-.custom-svg-icon {
- display: flex;
- align-items: center;
- align-self: center;
- justify-content: center;
- justify-self: center;
- width: 44px;
- height: 44px;
- opacity: 1;
-
- ::v-deep svg {
- // mdi icons have a size of 24px
- // 22px results in roughly 16px inner size
- height: 22px;
- width: 22px;
- fill: currentColor;
- }
-}
-
-</style>
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
new file mode 100644
index 00000000000..d5f93dac256
--- /dev/null
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -0,0 +1,155 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div class="files-list__drag-drop-notice"
+ :class="{ 'files-list__drag-drop-notice--dragover': dragover }"
+ @drop="onDrop">
+ <div class="files-list__drag-drop-notice-wrapper">
+ <TrayArrowDownIcon :size="48" />
+ <h3 class="files-list-drag-drop-notice__title">
+ {{ t('files', 'Drag and drop files here to upload') }}
+ </h3>
+ </div>
+ </div>
+</template>
+
+<script lang="ts">
+import type { Upload } from '@nextcloud/upload'
+import { join } from 'path'
+import { showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { getUploader } from '@nextcloud/upload'
+import Vue from 'vue'
+
+import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
+
+import logger from '../logger.js'
+
+export default Vue.extend({
+ name: 'DragAndDropNotice',
+
+ components: {
+ TrayArrowDownIcon,
+ },
+
+ props: {
+ currentFolder: {
+ type: Object,
+ required: true,
+ },
+ dragover: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ methods: {
+ onDrop(event: DragEvent) {
+ this.$emit('update:dragover', false)
+
+ if (this.$el.querySelector('tbody')?.contains(event.target as Node)) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (event.dataTransfer && event.dataTransfer.files?.length > 0) {
+ const uploader = getUploader()
+ uploader.destination = this.currentFolder
+
+ // Start upload
+ logger.debug(`Uploading files to ${this.currentFolder.path}`)
+ const promises = [...event.dataTransfer.files].map((file: File) => {
+ return uploader.upload(file.name, file) as Promise<Upload>
+ })
+
+ // Process finished uploads
+ Promise.all(promises).then((uploads) => {
+ logger.debug('Upload terminated', { uploads })
+ showSuccess(t('files', 'Upload successful'))
+
+ // Scroll to last upload if terminated
+ const lastUpload = uploads[uploads.length - 1]
+ if (lastUpload?.response?.headers?.['oc-fileid']) {
+ this.$router.push(Object.assign({}, this.$route, {
+ params: {
+ // Remove instanceid from header response
+ fileid: parseInt(lastUpload.response?.headers?.['oc-fileid']),
+ },
+ }))
+ }
+ })
+ }
+ },
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.files-list__drag-drop-notice {
+ position: absolute;
+ z-index: 9999;
+ top: 0;
+ right: 0;
+ left: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ // Breadcrumbs height + row thead height
+ min-height: calc(58px + 55px);
+ margin: 0;
+ user-select: none;
+ color: var(--color-text-maxcontrast);
+ background-color: var(--color-main-background);
+
+ &--dragover {
+ display: flex;
+ border-color: black;
+ }
+
+ h3 {
+ margin-left: 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);
+ }
+
+ &__close {
+ position: absolute !important;
+ top: 10px;
+ right: 10px;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 2ca02edc5b8..df18dcae016 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -21,7 +21,7 @@
-->
<template>
- <tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+ <tr :class="{'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"
@@ -37,125 +37,40 @@
<span v-if="source.attributes.failed" class="files-list__row--failed" />
<!-- Checkbox -->
- <td class="files-list__row-checkbox">
- <NcLoadingIcon v-if="isLoading" />
- <NcCheckboxRadioSwitch v-else-if="visible"
- :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
- :checked="isSelected"
- @update:checked="onSelectionChange" />
- </td>
+ <FileEntryCheckbox :display-name="displayName"
+ :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes" />
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
- <span class="files-list__row-icon" @click="execDefaultAction">
- <template v-if="source.type === 'folder'">
- <FolderOpenIcon v-if="dragover" />
- <template v-else>
- <FolderIcon />
- <OverlayIcon :is="folderOverlay"
- v-if="folderOverlay"
- class="files-list__row-icon-overlay" />
- </template>
- </template>
-
- <!-- Decorative image, should not be aria documented -->
- <img v-else-if="previewUrl && backgroundFailed !== true"
- ref="previewImg"
- alt=""
- class="files-list__row-icon-preview"
- :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
- :src="previewUrl"
- @error="backgroundFailed = true"
- @load="backgroundFailed = false">
-
- <FileIcon v-else />
-
- <!-- Favorite icon -->
- <span v-if="isFavorite"
- class="files-list__row-icon-favorite"
- :aria-label="t('files', 'Favorite')">
- <FavoriteIcon :aria-hidden="true" />
- </span>
- </span>
-
- <!-- Rename input -->
- <form v-if="isRenaming"
- v-on-click-outside="stopRenaming"
- :aria-hidden="!isRenaming"
- :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="checkInputValidity"
- @keyup.esc="stopRenaming" />
- </form>
-
- <a v-else
- ref="basename"
- :aria-hidden="isRenaming"
- class="files-list__row-name-link"
- data-cy-files-list-row-name-link
- v-bind="linkTo"
- @click="execDefaultAction">
- <!-- File name -->
- <span class="files-list__row-name-text">
- <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
- <span class="files-list__row-name-" v-text="displayName" />
- <span class="files-list__row-name-ext" v-text="extension" />
- </span>
- </a>
+ <FileEntryPreview ref="preview"
+ :source="source"
+ :dragover="dragover"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :display-name="displayName"
+ :extension="extension"
+ :files-list-width="filesListWidth"
+ :nodes="nodes"
+ :source="source"
+ @click="execDefaultAction" />
</td>
<!-- Actions -->
- <td v-show="!isRenamingSmallScreen"
+ <FileEntryActions v-show="!isRenamingSmallScreen"
+ ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
- 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 v-if="visible"
- ref="actionsMenu"
- :boundaries-element="getBoundariesElement()"
- :container="getBoundariesElement()"
- :disabled="isLoading"
- :force-name="true"
- :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
- :inline="enabledInlineActions.length"
- :open.sync="openedMenu">
- <NcActionButton v-for="action in enabledMenuActions"
- :key="action.id"
- :class="'files-list__row-action-' + action.id"
- :close-after-click="true"
- :data-cy-files-list-row-action="action.id"
- :title="action.title?.([source], currentView)"
- @click="onActionClick(action)">
- <template #icon>
- <NcLoadingIcon v-if="loading === action.id" :size="18" />
- <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
- </template>
- {{ actionDisplayName(action) }}
- </NcActionButton>
- </NcActions>
- </td>
+ :files-list-width="filesListWidth"
+ :loading.sync="loading"
+ :opened.sync="openedMenu"
+ :source="source" />
<!-- Size -->
- <td v-if="isSizeAvailable"
- :style="{ opacity: sizeOpacity }"
+ <td v-if="!compact && isSizeAvailable"
+ :style="sizeOpacity"
class="files-list__row-size"
data-cy-files-list-row-size
@click="openDetailsIfAvailable">
@@ -163,7 +78,8 @@
</td>
<!-- Mtime -->
- <td v-if="isMtimeAvailable"
+ <td v-if="!compact && isMtimeAvailable"
+ :style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
@@ -177,94 +93,56 @@
class="files-list__row-column-custom"
:data-cy-files-list-row-column-custom="column.id"
@click="openDetailsIfAvailable">
- <CustomElementRender v-if="visible"
- :current-view="currentView"
+ <CustomElementRender :current-view="currentView"
:render="column.render"
:source="source" />
</td>
</tr>
</template>
-<script lang='ts'>
+<script lang="ts">
import type { PropType } from 'vue'
-import { emit } from '@nextcloud/event-bus'
-import { extname } from 'path'
-import { generateUrl } from '@nextcloud/router'
-import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File, FileAction, NodeStatus, Node } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
+import { extname, join } from 'path'
+import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
+import { getUploader } from '@nextcloud/upload'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
import { vOnClickOutside } from '@vueuse/components'
-import axios from '@nextcloud/axios'
import moment from '@nextcloud/moment'
import Vue from 'vue'
-import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.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 TagIcon from 'vue-material-design-icons/Tag.vue'
-import LinkIcon from 'vue-material-design-icons/Link.vue'
-import NetworkIcon from 'vue-material-design-icons/Network.vue'
-import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { hashCode } from '../utils/hashUtils.ts'
+import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
-import { useKeyboardStore } from '../store/keyboard.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
-import { useUserConfigStore } from '../store/userconfig.ts'
import CustomElementRender from './CustomElementRender.vue'
-import CustomSvgIconRender from './CustomSvgIconRender.vue'
-import FavoriteIcon from './FavoriteIcon.vue'
+import FileEntryActions from './FileEntry/FileEntryActions.vue'
+import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryName from './FileEntry/FileEntryName.vue'
+import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import logger from '../logger.js'
-// The registered actions list
-const actions = getFileActions()
-
Vue.directive('onClickOutside', vOnClickOutside)
export default Vue.extend({
name: 'FileEntry',
components: {
- AccountGroupIcon,
- AccountPlusIcon,
CustomElementRender,
- CustomSvgIconRender,
- FavoriteIcon,
- FileIcon,
- FolderIcon,
- FolderOpenIcon,
- KeyIcon,
- LinkIcon,
- NcActionButton,
- NcActions,
- NcCheckboxRadioSwitch,
- NcLoadingIcon,
- NcTextField,
- NetworkIcon,
- TagIcon,
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
},
props: {
- visible: {
- type: Boolean,
- default: false,
- },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -274,11 +152,7 @@ export default Vue.extend({
default: false,
},
source: {
- type: [Folder, File, Node] as PropType<Node>,
- required: true,
- },
- index: {
- type: Number,
+ type: [Folder, NcFile, Node] as PropType<Node>,
required: true,
},
nodes: {
@@ -289,48 +163,41 @@ export default Vue.extend({
type: Number,
default: 0,
},
+ compact: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
- const keyboardStore = useKeyboardStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
- const userConfigStore = useUserConfigStore()
return {
actionsMenuStore,
draggingStore,
filesStore,
- keyboardStore,
renamingStore,
selectionStore,
- userConfigStore,
}
},
data() {
return {
- backgroundFailed: undefined,
loading: '',
dragover: false,
-
- NodeStatus,
}
},
computed: {
- userConfig() {
- return this.userConfigStore.userConfig
- },
-
- currentView() {
- return this.$navigation.active
+ currentView(): View {
+ return this.$navigation.active as View
},
columns() {
// Hide columns if the list is too small
- if (this.filesListWidth < 512) {
+ if (this.filesListWidth < 512 || this.compact) {
return []
}
return this.currentView?.columns || []
@@ -346,6 +213,12 @@ export default Vue.extend({
fileid() {
return this.source?.fileid?.toString?.()
},
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
extension() {
if (this.source.attributes?.displayName) {
@@ -365,102 +238,52 @@ export default Vue.extend({
size() {
const size = parseInt(this.source.size, 10) || 0
if (typeof size !== 'number' || size < 0) {
- return this.t('files', 'Pending')
+ return t('files', 'Pending')
}
return formatFileSize(size, true)
},
sizeOpacity() {
- // Whatever theme is active, the contrast will pass WCAG AA
- // with color main text over main background and an opacity of 0.7
- const minOpacity = 0.7
const maxOpacitySize = 10 * 1024 * 1024
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
- return minOpacity
+ return {}
}
- return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
+ const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2)))
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
+ }
},
mtime() {
if (this.source.mtime) {
return moment(this.source.mtime).fromNow()
}
- return this.t('files_trashbin', 'A long time ago')
- },
- mtimeTitle() {
- if (this.source.mtime) {
- return moment(this.source.mtime).format('LLL')
- }
- return ''
+ return t('files_trashbin', 'A long time ago')
},
+ mtimeOpacity() {
+ const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
- 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
+ const mtime = this.source.mtime?.getTime?.()
+ if (!mtime) {
+ return {}
}
- // Link and mail shared folders
- const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
- if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
- return LinkIcon
+ // 1 = today, 0 = 31 days ago
+ const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime))
+ if (ratio < 0) {
+ return {}
}
-
- // 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
+ return {
+ color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
}
-
- return null
},
-
- linkTo() {
- if (this.source.attributes.failed) {
- return {
- title: this.t('files', 'This node is unavailable'),
- is: 'span',
- }
- }
-
- if (this.enabledDefaultActions.length > 0) {
- const action = this.enabledDefaultActions[0]
- const displayName = action.displayName([this.source], this.currentView)
- return {
- title: displayName,
- role: 'button',
- }
- }
-
- if (this.source?.permissions & Permission.READ) {
- return {
- download: this.source.basename,
- href: this.source.source,
- title: this.t('files', 'Download file {name}', { name: this.displayName }),
- }
- }
-
- return {
- is: 'span',
+ mtimeTitle() {
+ if (this.source.mtime) {
+ return moment(this.source.mtime).format('LLL')
}
+ return ''
},
draggingFiles() {
@@ -473,124 +296,12 @@ export default Vue.extend({
return this.selectedFiles.includes(this.fileid)
},
- cropPreviews() {
- return this.userConfig.crop_image_previews
- },
- previewUrl() {
- if (this.source.type === FileType.Folder) {
- return null
- }
-
- if (this.backgroundFailed === true) {
- return null
- }
-
- try {
- const previewUrl = this.source.attributes.previewUrl
- || generateUrl('/core/preview?fileid={fileid}', {
- fileid: this.fileid,
- })
- const url = new URL(window.location.origin + previewUrl)
-
- // Request tiny previews
- url.searchParams.set('x', '32')
- url.searchParams.set('y', '32')
- url.searchParams.set('mimeFallback', 'true')
-
- // Handle cropping
- url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
- return url.href
- } catch (e) {
- return null
- }
- },
-
- // Sorted actions that are enabled for this node
- enabledActions() {
- if (this.source.attributes.failed) {
- return []
- }
-
- return actions
- .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- },
-
- // Enabled action that are displayed inline
- enabledInlineActions() {
- if (this.filesListWidth < 768) {
- return []
- }
- return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
- },
-
- // Enabled action that are displayed inline with a custom render function
- enabledRenderActions() {
- if (!this.visible) {
- return []
- }
- return this.enabledActions.filter(action => typeof action.renderInline === 'function')
- },
-
- // Default actions
- enabledDefaultActions() {
- return this.enabledActions.filter(action => !!action?.default)
- },
-
- // Actions shown in the menu
- enabledMenuActions() {
- return [
- // Showing inline first for the NcActions inline prop
- ...this.enabledInlineActions,
- // Then the rest
- ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
- ].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)
- })
- },
- openedMenu: {
- get() {
- return this.actionsMenuStore.opened === this.uniqueId
- },
- set(opened) {
- this.actionsMenuStore.opened = opened ? this.uniqueId : null
- },
- },
-
- uniqueId() {
- return hashCode(this.source.source)
- },
-
- isFavorite() {
- return this.source.attributes.favorite === 1
- },
- isLoading() {
- return this.source.status === NodeStatus.LOADING
- },
-
- renameLabel() {
- const matchLabel: Record<FileType, string> = {
- [FileType.File]: t('files', 'File name'),
- [FileType.Folder]: t('files', 'Folder name'),
- }
- return matchLabel[this.source.type]
- },
-
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
},
- newName: {
- get() {
- return this.renamingStore.newName
- },
- set(newName) {
- this.renamingStore.newName = newName
- },
- },
isActive() {
return this.fileid === this.currentFileId?.toString?.()
@@ -598,7 +309,7 @@ export default Vue.extend({
canDrag() {
const canDrag = (node: Node): boolean => {
- return (node.permissions & Permission.UPDATE) !== 0
+ return (node?.permissions & Permission.UPDATE) !== 0
}
// If we're dragging a selection, we need to check all files
@@ -621,6 +332,15 @@ export default Vue.extend({
return (this.source.permissions & Permission.CREATE) !== 0
},
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? this.uniqueId : null
+ },
+ },
},
watch: {
@@ -631,16 +351,6 @@ export default Vue.extend({
source() {
this.resetState()
},
-
- /**
- * If renaming starts, select the file name
- * in the input, without the extension.
- */
- isRenaming(renaming) {
- if (renaming) {
- this.startRenaming()
- }
- },
},
beforeDestroy() {
@@ -652,96 +362,12 @@ export default Vue.extend({
// Reset loading state
this.loading = ''
- // Reset background state
- this.backgroundFailed = undefined
- if (this.$refs.previewImg) {
- this.$refs.previewImg.src = ''
- }
+ this.$refs.preview.reset()
// Close menu
this.openedMenu = false
},
- async onActionClick(action) {
- const displayName = action.displayName([this.source], this.currentView)
- try {
- // Set the loading marker
- this.loading = action.id
- Vue.set(this.source, 'status', NodeStatus.LOADING)
-
- const success = await action.exec(this.source, this.currentView, this.currentDir)
-
- // If the action returns null, we stay silent
- if (success === null) {
- return
- }
-
- if (success) {
- showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
- return
- }
- showError(this.t('files', '"{displayName}" action failed', { displayName }))
- } catch (e) {
- logger.error('Error while executing action', { action, e })
- showError(this.t('files', '"{displayName}" action failed', { displayName }))
- } finally {
- // Reset the loading marker
- this.loading = ''
- Vue.set(this.source, 'status', undefined)
- }
- },
- execDefaultAction(event) {
- if (this.enabledDefaultActions.length > 0) {
- event.preventDefault()
- event.stopPropagation()
- // Execute the first default action if any
- this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- openDetailsIfAvailable(event) {
- event.preventDefault()
- event.stopPropagation()
- if (sidebarAction?.enabled?.([this.source], this.currentView)) {
- sidebarAction.exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- 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.fileid)
-
- const start = Math.min(newSelectedIndex, lastSelectedIndex)
- const end = Math.max(lastSelectedIndex, newSelectedIndex)
-
- const lastSelection = this.selectionStore.lastSelection
- const filesToSelect = this.nodes
- .map(file => file.fileid?.toString?.())
- .slice(start, end + 1)
-
- // If already selected, update the new selection _without_ the current file
- const selection = [...lastSelection, ...filesToSelect]
- .filter(fileid => !isAlreadySelected || fileid !== this.fileid)
-
- 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.fileid]
- : this.selectedFiles.filter(fileid => fileid !== this.fileid)
-
- logger.debug('Updating selection', { selection })
- this.selectionStore.set(selection)
- this.selectionStore.setLastIndex(newSelectedIndex)
- },
-
// Open the actions menu on right click
onRightClick(event) {
// If already opened, fallback to default browser
@@ -758,165 +384,21 @@ export default Vue.extend({
event.stopPropagation()
},
- /**
- * Check if the file name is valid and update the
- * input validity using browser's native validation.
- * @param event the keyup event
- */
- checkInputValidity(event?: KeyboardEvent) {
- const input = event.target as HTMLInputElement
- const newName = this.newName.trim?.() || ''
- logger.debug('Checking input validity', { newName })
- try {
- this.isFileNameValid(newName)
- input.setCustomValidity('')
- input.title = ''
- } catch (e) {
- input.setCustomValidity(e.message)
- input.title = e.message
- } finally {
- input.reportValidity()
- }
- },
- isFileNameValid(name) {
- const trimmedName = name.trim()
- if (trimmedName === '.' || trimmedName === '..') {
- throw new Error(this.t('files', '"{name}" is an invalid file name.', { name }))
- } else if (trimmedName.length === 0) {
- throw new Error(this.t('files', 'File name cannot be empty.'))
- } else if (trimmedName.indexOf('/') !== -1) {
- throw new Error(this.t('files', '"/" is not allowed inside a file name.'))
- } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
- throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name }))
- } else if (this.checkIfNodeExists(name)) {
- throw new Error(this.t('files', '{newName} already exists.', { newName: name }))
- }
-
- return true
- },
- checkIfNodeExists(name) {
- return this.nodes.find(node => node.basename === name && node !== this.source)
- },
-
- startRenaming() {
- this.$nextTick(() => {
- // Using split to get the true string length
- const extLength = (this.source.extension || '').split('').length
- const length = this.source.basename.split('').length - extLength
- const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
- if (!input) {
- logger.error('Could not find the rename input')
- return
- }
- input.setSelectionRange(0, length)
- input.focus()
-
- // 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 oldName = this.source.basename
- const oldSource = this.source.source
- const newName = this.newName.trim?.() || ''
- if (newName === '') {
- showError(this.t('files', 'Name cannot be empty'))
- return
- }
-
- if (oldName === newName) {
- this.stopRenaming()
- return
- }
-
- // Checking if already exists
- if (this.checkIfNodeExists(newName)) {
- showError(this.t('files', 'Another entry with the same name already exists'))
- return
- }
-
- // Set loading state
- this.loading = 'renaming'
- Vue.set(this.source, 'status', NodeStatus.LOADING)
-
- // Update node
- this.source.rename(newName)
-
- try {
- await axios({
- method: 'MOVE',
- url: oldSource,
- headers: {
- Destination: encodeURI(this.source.source),
- },
- })
-
- // Success 🎉
- emit('files:node:updated', this.source)
- emit('files:node:renamed', this.source)
- showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
-
- // Reset the renaming store
- this.stopRenaming()
- this.$nextTick(() => {
- this.$refs.basename.focus()
- })
- } catch (error) {
- logger.error('Error while renaming file', { error })
- this.source.rename(oldName)
- this.$refs.renameInput.focus()
-
- // TODO: 409 means current folder does not exist, redirect ?
- if (error?.response?.status === 404) {
- showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
- return
- } else if (error?.response?.status === 412) {
- showError(this.t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
- return
- }
-
- // Unknown error
- showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
- } finally {
- this.loading = false
- Vue.set(this.source, 'status', undefined)
- }
- },
-
- /**
- * 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')
+ execDefaultAction(...args) {
+ this.$refs.actions.execDefaultAction(...args)
},
- actionDisplayName(action: FileAction) {
- if (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
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
}
- return action.displayName([this.source], this.currentView)
},
onDragOver(event: DragEvent) {
this.dragover = this.canDrop
if (!this.canDrop) {
- event.preventDefault()
- event.stopPropagation()
event.dataTransfer.dropEffect = 'none'
return
}
@@ -929,9 +411,13 @@ export default Vue.extend({
}
},
onDragLeave(event: DragEvent) {
- if (this.$el.contains(event.target) && event.target !== this.$el) {
+ // 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
},
@@ -960,7 +446,7 @@ export default Vue.extend({
.map(fileid => this.filesStore.getNode(fileid)) as Node[]
const image = await getDragAndDropPreview(nodes)
- event.dataTransfer.setDragImage(image, -10, -10)
+ event.dataTransfer?.setDragImage(image, -10, -10)
},
onDragEnd() {
this.draggingStore.reset()
@@ -969,6 +455,9 @@ export default Vue.extend({
},
async onDrop(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
// If another button is pressed, cancel it
// This allows cancelling the drag with the right click
if (!this.canDrop || event.button !== 0) {
@@ -980,6 +469,16 @@ export default Vue.extend({
logger.debug('Dropped', { event, selection: this.draggingFiles })
+ // Check whether we're uploading files
+ if (event.dataTransfer?.files?.length > 0) {
+ const uploader = getUploader()
+ event.dataTransfer.files.forEach((file: File) => {
+ uploader.upload(join(this.source.path, file.name), file)
+ })
+ logger.debug(`Uploading files to ${this.source.path}`)
+ return
+ }
+
const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
nodes.forEach(async (node: Node) => {
Vue.set(node, 'status', NodeStatus.LOADING)
@@ -989,9 +488,9 @@ export default Vue.extend({
} catch (error) {
logger.error('Error while moving file', { error })
if (isCopy) {
- showError(this.t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
+ showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
} else {
- showError(this.t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
+ showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
}
} finally {
Vue.set(node, 'status', undefined)
@@ -1006,48 +505,8 @@ export default Vue.extend({
}
},
- t: translate,
+ t,
formatFileSize,
},
})
</script>
-
-<style scoped lang='scss'>
-/* Hover effect on tbody lines only */
-tr {
- &:hover,
- &:focus {
- background-color: var(--color-background-dark);
- }
-}
-
-// Folder overlay
-.files-list__row-icon-overlay {
- position: absolute;
- max-height: 18px;
- max-width: 18px;
- color: var(--color-main-background);
- // better alignment with the folder icon
- margin-top: 2px;
-}
-
-/* Preview not loaded animation effect */
-.files-list__row-icon-preview:not(.files-list__row-icon-preview--loaded) {
- background: var(--color-loading-dark);
- // animation: preview-gradient-fade 1.2s ease-in-out infinite;
-}
-</style>
-
-<style>
-/* @keyframes preview-gradient-fade {
- 0% {
- opacity: 1;
- }
- 50% {
- opacity: 0.5;
- }
- 100% {
- opacity: 1;
- }
-} */
-</style>
diff --git a/apps/files/src/components/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index 4d48b11e579..6eb1fbd8edd 100644
--- a/apps/files/src/components/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -20,12 +20,12 @@
-
-->
<template>
- <CustomSvgIconRender class="favorite-marker-icon" :svg="StarSvg" />
+ <NcIconSvgWrapper class="favorite-marker-icon" :svg="StarSvg" />
</template>
<script>
import StarSvg from '@mdi/svg/svg/star.svg?raw'
-import CustomSvgIconRender from './CustomSvgIconRender.vue'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
/**
* A favorite icon to be used for overlaying favorite entries like the file preview / icon
@@ -41,33 +41,37 @@ import CustomSvgIconRender from './CustomSvgIconRender.vue'
export default {
name: 'FavoriteIcon',
components: {
- CustomSvgIconRender,
+ NcIconSvgWrapper,
},
data() {
return {
StarSvg,
}
},
- mounted() {
+ 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')
- el.setAttribute('width', '25')
- el.setAttribute('height', '25')
},
}
</script>
<style lang="scss" scoped>
.favorite-marker-icon {
color: #a08b00;
- width: fit-content;
- height: fit-content;
+ // 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: 26px;
- height: 26px;
+ width: 26px !important;
+ height: 26px !important;
+
+ // Override NcIconSvgWrapper defaults of 20px
+ max-width: unset !important;
+ max-height: unset !important;
// Sow a border around the icon for better contrast
path {
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
new file mode 100644
index 00000000000..bd4649cdee5
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -0,0 +1,243 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<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"
+ :disabled="isLoading || loading !== ''"
+ :force-name="true"
+ :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
+ :inline="enabledInlineActions.length"
+ :open.sync="openedMenu">
+ <NcActionButton v-for="action in enabledMenuActions"
+ :key="action.id"
+ :class="'files-list__row-action-' + action.id"
+ :close-after-click="true"
+ :data-cy-files-list-row-action="action.id"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </NcActions>
+ </td>
+</template>
+
+<script lang="ts">
+import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n';
+import Vue, { PropType } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+
+import CustomElementRender from '../CustomElementRender.vue'
+import logger from '../../logger.js'
+
+// The registered actions list
+const actions = getFileActions()
+
+export default Vue.extend({
+ name: 'FileEntryActions',
+
+ components: {
+ NcActionButton,
+ NcActions,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ CustomElementRender,
+ },
+
+ props: {
+ filesListWidth: {
+ type: Number,
+ required: true,
+ },
+ loading: {
+ type: String,
+ required: true,
+ },
+ opened: {
+ type: Boolean,
+ default: false,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ return {
+ }
+ },
+
+ computed: {
+ currentDir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
+ },
+ currentView(): View {
+ return this.$navigation.active as View
+ },
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ // Sorted actions that are enabled for this node
+ enabledActions() {
+ if (this.source.attributes.failed) {
+ return []
+ }
+
+ return actions
+ .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ // Enabled action that are displayed inline
+ enabledInlineActions() {
+ if (this.filesListWidth < 768 || this.gridMode) {
+ return []
+ }
+ return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ },
+
+ // Enabled action that are displayed inline with a custom render function
+ enabledRenderActions() {
+ if (this.gridMode) {
+ return []
+ }
+ return this.enabledActions.filter(action => typeof action.renderInline === 'function')
+ },
+
+ // Default actions
+ enabledDefaultActions() {
+ return this.enabledActions.filter(action => !!action?.default)
+ },
+
+ // Actions shown in the menu
+ enabledMenuActions() {
+ return [
+ // Showing inline first for the NcActions inline prop
+ ...this.enabledInlineActions,
+ // Then the rest
+ ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
+ ].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)
+ })
+ },
+
+ 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 > table.files-list')
+ },
+ },
+
+ methods: {
+ actionDisplayName(action: FileAction) {
+ if (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)
+ },
+
+ async onActionClick(action) {
+ const displayName = action.displayName([this.source], this.currentView)
+ try {
+ // Set the loading marker
+ this.$emit('update:loading', action.id)
+ Vue.set(this.source, 'status', NodeStatus.LOADING)
+
+ const success = await action.exec(this.source, this.currentView, this.currentDir)
+
+ // If the action returns null, we stay silent
+ if (success === null) {
+ return
+ }
+
+ if (success) {
+ showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
+ return
+ }
+ showError(t('files', '"{displayName}" action failed', { displayName }))
+ } catch (e) {
+ logger.error('Error while executing action', { action, e })
+ showError(t('files', '"{displayName}" action failed', { displayName }))
+ } finally {
+ // Reset the loading marker
+ this.$emit('update:loading', '')
+ Vue.set(this.source, 'status', undefined)
+ }
+ },
+ execDefaultAction(event) {
+ if (this.enabledDefaultActions.length > 0) {
+ event.preventDefault()
+ event.stopPropagation()
+ // Execute the first default action if any
+ this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
new file mode 100644
index 00000000000..961e4bf2266
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -0,0 +1,131 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <td class="files-list__row-checkbox">
+ <NcLoadingIcon v-if="isLoading" />
+ <NcCheckboxRadioSwitch v-else
+ :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
+ :checked="isSelected"
+ @update:checked="onSelectionChange" />
+ </td>
+</template>
+
+<script lang="ts">
+import { Node } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import Vue, { PropType } from 'vue'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+
+import { useKeyboardStore } from '../../store/keyboard.ts'
+import { useSelectionStore } from '../../store/selection.ts'
+import logger from '../../logger.js'
+
+export default Vue.extend({
+ name: 'FileEntryCheckbox',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ },
+
+ props: {
+ displayName: {
+ type: String,
+ required: true,
+ },
+ fileid: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ const selectionStore = useSelectionStore()
+ const keyboardStore = useKeyboardStore()
+ return {
+ keyboardStore,
+ selectionStore,
+ }
+ },
+
+ computed: {
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.fileid)
+ },
+ index() {
+ return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid))
+ },
+ },
+
+ 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.fileid)
+
+ const start = Math.min(newSelectedIndex, lastSelectedIndex)
+ const end = Math.max(lastSelectedIndex, newSelectedIndex)
+
+ const lastSelection = this.selectionStore.lastSelection
+ const filesToSelect = this.nodes
+ .map(file => file.fileid?.toString?.())
+ .slice(start, end + 1)
+
+ // If already selected, update the new selection _without_ the current file
+ const selection = [...lastSelection, ...filesToSelect]
+ .filter(fileid => !isAlreadySelected || fileid !== this.fileid)
+
+ 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.fileid]
+ : this.selectedFiles.filter(fileid => fileid !== this.fileid)
+
+ logger.debug('Updating selection', { selection })
+ this.selectionStore.set(selection)
+ this.selectionStore.setLastIndex(newSelectedIndex)
+ },
+
+ t,
+ },
+})
+</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..e54eacdbe9e
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -0,0 +1,330 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <!-- Rename input -->
+ <form v-if="isRenaming"
+ v-on-click-outside="stopRenaming"
+ :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="checkInputValidity"
+ @keyup.esc="stopRenaming" />
+ </form>
+
+ <a v-else
+ ref="basename"
+ :aria-hidden="isRenaming"
+ class="files-list__row-name-link"
+ data-cy-files-list-row-name-link
+ v-bind="linkTo"
+ @click="$emit('click', $event)">
+ <!-- File name -->
+ <span class="files-list__row-name-text">
+ <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
+ <span class="files-list__row-name-" v-text="displayName" />
+ <span class="files-list__row-name-ext" v-text="extension" />
+ </span>
+ </a>
+</template>
+
+<script lang="ts">
+import { emit } from '@nextcloud/event-bus'
+import { FileType, NodeStatus, Permission } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import Vue, { PropType } from 'vue'
+
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+import { useRenamingStore } from '../../store/renaming.ts'
+import logger from '../../logger.js'
+
+const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
+
+export default Vue.extend({
+ name: 'FileEntryName',
+
+ components: {
+ NcTextField,
+ },
+
+ props: {
+ displayName: {
+ type: String,
+ required: true,
+ },
+ extension: {
+ type: String,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const renamingStore = useRenamingStore()
+ return {
+ renamingStore,
+ }
+ },
+
+ computed: {
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+ newName: {
+ get() {
+ return this.renamingStore.newName
+ },
+ set(newName) {
+ this.renamingStore.newName = newName
+ },
+ },
+
+ renameLabel() {
+ const matchLabel: Record<FileType, string> = {
+ [FileType.File]: t('files', 'File name'),
+ [FileType.Folder]: t('files', 'Folder name'),
+ }
+ return matchLabel[this.source.type]
+ },
+
+ linkTo() {
+ if (this.source.attributes.failed) {
+ return {
+ title: t('files', 'This node is unavailable'),
+ is: 'span',
+ }
+ }
+
+ const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
+ if (enabledDefaultActions?.length > 0) {
+ const action = enabledDefaultActions[0]
+ const displayName = action.displayName([this.source], this.currentView)
+ return {
+ title: displayName,
+ role: 'button',
+ }
+ }
+
+ if (this.source?.permissions & Permission.READ) {
+ return {
+ download: this.source.basename,
+ href: this.source.source,
+ title: t('files', 'Download file {name}', { name: this.displayName }),
+ }
+ }
+
+ return {
+ is: 'span',
+ }
+ },
+ },
+
+ watch: {
+ /**
+ * If renaming starts, select the file name
+ * in the input, without the extension.
+ * @param renaming
+ */
+ isRenaming(renaming: boolean) {
+ if (renaming) {
+ this.startRenaming()
+ }
+ },
+ },
+
+ methods: {
+ /**
+ * Check if the file name is valid and update the
+ * input validity using browser's native validation.
+ * @param event the keyup event
+ */
+ checkInputValidity(event?: KeyboardEvent) {
+ const input = event.target as HTMLInputElement
+ const newName = this.newName.trim?.() || ''
+ logger.debug('Checking input validity', { newName })
+ try {
+ this.isFileNameValid(newName)
+ input.setCustomValidity('')
+ input.title = ''
+ } catch (e) {
+ input.setCustomValidity(e.message)
+ input.title = e.message
+ } finally {
+ input.reportValidity()
+ }
+ },
+ isFileNameValid(name) {
+ const trimmedName = name.trim()
+ if (trimmedName === '.' || trimmedName === '..') {
+ throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
+ } else if (trimmedName.length === 0) {
+ throw new Error(t('files', 'File name cannot be empty.'))
+ } else if (trimmedName.indexOf('/') !== -1) {
+ throw new Error(t('files', '"/" is not allowed inside a file name.'))
+ } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
+ throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
+ } else if (this.checkIfNodeExists(name)) {
+ throw new Error(t('files', '{newName} already exists.', { newName: name }))
+ }
+
+ const toCheck = trimmedName.split('')
+ toCheck.forEach(char => {
+ if (forbiddenCharacters.indexOf(char) !== -1) {
+ throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char }))
+ }
+ })
+
+ return true
+ },
+ checkIfNodeExists(name) {
+ return this.nodes.find(node => node.basename === name && node !== this.source)
+ },
+
+ startRenaming() {
+ this.$nextTick(() => {
+ // Using split to get the true string length
+ const extLength = (this.source.extension || '').split('').length
+ const length = this.source.basename.split('').length - extLength
+ const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+ if (!input) {
+ logger.error('Could not find the rename input')
+ return
+ }
+ input.setSelectionRange(0, length)
+ input.focus()
+
+ // 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 oldName = this.source.basename
+ const oldEncodedSource = this.source.encodedSource
+ const newName = this.newName.trim?.() || ''
+ if (newName === '') {
+ showError(t('files', 'Name cannot be empty'))
+ return
+ }
+
+ if (oldName === newName) {
+ this.stopRenaming()
+ return
+ }
+
+ // Checking if already exists
+ if (this.checkIfNodeExists(newName)) {
+ showError(t('files', 'Another entry with the same name already exists'))
+ return
+ }
+
+ // Set loading state
+ this.loading = 'renaming'
+ Vue.set(this.source, 'status', NodeStatus.LOADING)
+
+ // Update node
+ this.source.rename(newName)
+
+ logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
+ try {
+ await axios({
+ method: 'MOVE',
+ url: oldEncodedSource,
+ headers: {
+ Destination: this.source.encodedSource,
+ },
+ })
+
+ // Success 🎉
+ emit('files:node:updated', this.source)
+ emit('files:node:renamed', this.source)
+ showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
+
+ // Reset the renaming store
+ this.stopRenaming()
+ this.$nextTick(() => {
+ this.$refs.basename.focus()
+ })
+ } catch (error) {
+ logger.error('Error while renaming file', { error })
+ this.source.rename(oldName)
+ this.$refs.renameInput.focus()
+
+ // TODO: 409 means current folder does not exist, redirect ?
+ if (error?.response?.status === 404) {
+ showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
+ return
+ } else if (error?.response?.status === 412) {
+ showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
+ return
+ }
+
+ // Unknown error
+ showError(t('files', 'Could not rename "{oldName}"', { oldName }))
+ } finally {
+ this.loading = false
+ Vue.set(this.source, 'status', undefined)
+ }
+ },
+
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
new file mode 100644
index 00000000000..8a7af255ec2
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -0,0 +1,215 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <span class="files-list__row-icon">
+ <template v-if="source.type === 'folder'">
+ <FolderOpenIcon v-once v-if="dragover" />
+ <template v-else>
+ <FolderIcon v-once />
+ <OverlayIcon :is="folderOverlay"
+ v-if="folderOverlay"
+ class="files-list__row-icon-overlay" />
+ </template>
+ </template>
+
+ <!-- Decorative image, should not be aria documented -->
+ <img v-else-if="previewUrl && backgroundFailed !== true"
+ ref="previewImg"
+ alt=""
+ class="files-list__row-icon-preview"
+ :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
+ :src="previewUrl"
+ @error="backgroundFailed = true"
+ @load="backgroundFailed = false">
+
+ <FileIcon v-once v-else />
+
+ <!-- Favorite icon -->
+ <span v-if="isFavorite"
+ class="files-list__row-icon-favorite"
+ :aria-label="t('files', 'Favorite')">
+ <FavoriteIcon v-once />
+ </span>
+ </span>
+</template>
+
+<script lang="ts">
+import type { UserConfig } from '../../types.ts'
+
+import { File, Folder, Node, FileType } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { translate as t } from '@nextcloud/l10n'
+import { Type as ShareType } from '@nextcloud/sharing'
+import Vue, { PropType } 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/Network.vue'
+import TagIcon from 'vue-material-design-icons/Tag.vue'
+
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import FavoriteIcon from './FavoriteIcon.vue'
+
+export default Vue.extend({
+ name: 'FileEntryPreview',
+
+ components: {
+ AccountGroupIcon,
+ AccountPlusIcon,
+ 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()
+ return {
+ userConfigStore,
+ }
+ },
+
+ data() {
+ return {
+ backgroundFailed: undefined as boolean | undefined,
+ }
+ },
+
+ computed: {
+ fileid() {
+ return this.source?.fileid?.toString?.()
+ },
+ 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
+ }
+
+ try {
+ const previewUrl = this.source.attributes.previewUrl
+ || generateUrl('/core/preview?fileId={fileid}', {
+ fileid: this.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')
+
+ // Handle cropping
+ url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
+ return url.href
+ } catch (e) {
+ 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.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_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
+ }
+
+ return null
+ },
+ },
+
+ methods: {
+ reset() {
+ // Reset background state
+ this.backgroundFailed = undefined
+ if (this.$refs.previewImg) {
+ this.$refs.previewImg.src = ''
+ }
+ },
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
new file mode 100644
index 00000000000..def818eea2f
--- /dev/null
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -0,0 +1,408 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<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="source.attributes.failed" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox :display-name="displayName"
+ :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes" />
+
+ <!-- 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"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :display-name="displayName"
+ :extension="extension"
+ :files-list-width="filesListWidth"
+ :grid-mode="true"
+ :nodes="nodes"
+ :source="source"
+ @click="execDefaultAction" />
+ </td>
+
+ <!-- Actions -->
+ <FileEntryActions ref="actions"
+ :class="`files-list__row-actions-${uniqueId}`"
+ :files-list-width="filesListWidth"
+ :grid-mode="true"
+ :loading.sync="loading"
+ :opened.sync="openedMenu"
+ :source="source" />
+ </tr>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { extname, join } from 'path'
+import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
+import { getUploader } from '@nextcloud/upload'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { vOnClickOutside } from '@vueuse/components'
+import Vue from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { getDragAndDropPreview } from '../utils/dragUtils.ts'
+import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
+import { hashCode } from '../utils/hashUtils.ts'
+import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+import { 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 FileEntryActions from './FileEntry/FileEntryActions.vue'
+import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryName from './FileEntry/FileEntryName.vue'
+import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
+import logger from '../logger.js'
+
+Vue.directive('onClickOutside', vOnClickOutside)
+
+export default Vue.extend({
+ name: 'FileEntryGrid',
+
+ components: {
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ },
+
+ inheritAttrs: false,
+ props: {
+ source: {
+ type: [Folder, NcFile, Node] as PropType<Node>,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ const filesStore = useFilesStore()
+ const renamingStore = useRenamingStore()
+ const selectionStore = useSelectionStore()
+ return {
+ actionsMenuStore,
+ draggingStore,
+ filesStore,
+ renamingStore,
+ selectionStore,
+ }
+ },
+
+ data() {
+ return {
+ loading: '',
+ dragover: false,
+ }
+ },
+
+ computed: {
+ currentView(): View {
+ return this.$navigation.active as View
+ },
+
+ currentDir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
+ },
+ currentFileId() {
+ return this.$route.params?.fileid || this.$route.query?.fileid || null
+ },
+ fileid() {
+ return this.source?.fileid?.toString?.()
+ },
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ extension() {
+ if (this.source.attributes?.displayName) {
+ return extname(this.source.attributes.displayName)
+ }
+ return this.source.extension || ''
+ },
+ displayName() {
+ const ext = this.extension
+ const name = (this.source.attributes.displayName
+ || this.source.basename)
+
+ // Strip extension from name if defined
+ return !ext ? name : name.slice(0, 0 - ext.length)
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging
+ },
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.fileid)
+ },
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+
+ isActive() {
+ return this.fileid === this.currentFileId?.toString?.()
+ },
+
+ canDrag() {
+ const canDrag = (node: Node): boolean => {
+ return (node?.permissions & Permission.UPDATE) !== 0
+ }
+
+ // If we're dragging a selection, we need to check all files
+ if (this.selectedFiles.length > 0) {
+ const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ return nodes.every(canDrag)
+ }
+ return canDrag(this.source)
+ },
+
+ canDrop() {
+ if (this.source.type !== FileType.Folder) {
+ return false
+ }
+
+ // If the current folder is also being dragged, we can't drop it on itself
+ if (this.draggingFiles.includes(this.fileid)) {
+ return false
+ }
+
+ return (this.source.permissions & Permission.CREATE) !== 0
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? this.uniqueId : null
+ },
+ },
+ },
+
+ watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ */
+ source() {
+ this.resetState()
+ },
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ resetState() {
+ // Reset loading state
+ this.loading = ''
+
+ this.$refs.preview.reset()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ execDefaultAction(...args) {
+ this.$refs.actions.execDefaultAction(...args)
+ },
+
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ onDragOver(event: DragEvent) {
+ this.dragover = this.canDrop
+ if (!this.canDrop) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ return
+ }
+
+ this.dragover = false
+ },
+
+ async onDragStart(event: DragEvent) {
+ event.stopPropagation()
+ if (!this.canDrag) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
+ logger.debug('Drag started')
+
+ // Reset any renaming
+ this.renamingStore.$reset()
+
+ // Dragging set of files, if we're dragging a file
+ // that is already selected, we use the entire selection
+ if (this.selectedFiles.includes(this.fileid)) {
+ this.draggingStore.set(this.selectedFiles)
+ } else {
+ this.draggingStore.set([this.fileid])
+ }
+
+ const nodes = this.draggingStore.dragging
+ .map(fileid => this.filesStore.getNode(fileid)) as Node[]
+
+ const image = await getDragAndDropPreview(nodes)
+ event.dataTransfer?.setDragImage(image, -10, -10)
+ },
+ onDragEnd() {
+ this.draggingStore.reset()
+ this.dragover = false
+ logger.debug('Drag ended')
+ },
+
+ async onDrop(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ // If another button is pressed, cancel it
+ // This allows cancelling the drag with the right click
+ if (!this.canDrop || event.button !== 0) {
+ return
+ }
+
+ const isCopy = event.ctrlKey
+ this.dragover = false
+
+ logger.debug('Dropped', { event, selection: this.draggingFiles })
+
+ // Check whether we're uploading files
+ if (event.dataTransfer?.files?.length > 0) {
+ const uploader = getUploader()
+ event.dataTransfer.files.forEach((file: File) => {
+ uploader.upload(join(this.source.path, file.name), file)
+ })
+ logger.debug(`Uploading files to ${this.source.path}`)
+ return
+ }
+
+ const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ nodes.forEach(async (node: Node) => {
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ try {
+ // TODO: resolve potential conflicts prior and force overwrite
+ await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
+ } catch (error) {
+ logger.error('Error while moving file', { error })
+ if (isCopy) {
+ showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
+ } else {
+ showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
+ }
+ } finally {
+ Vue.set(node, 'status', undefined)
+ }
+ })
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue
deleted file mode 100644
index 3a89970a26d..00000000000
--- a/apps/files/src/components/FilesListFooter.vue
+++ /dev/null
@@ -1,175 +0,0 @@
-<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <tr>
- <th class="files-list__row-checkbox">
- <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" />
-
- <!-- 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 Vue from 'vue'
-import { formatFileSize } from '@nextcloud/files'
-import { translate } from '@nextcloud/l10n'
-
-import { useFilesStore } from '../store/files.ts'
-import { usePathsStore } from '../store/paths.ts'
-
-export default Vue.extend({
- name: 'FilesListFooter',
-
- components: {
- },
-
- props: {
- isMtimeAvailable: {
- type: Boolean,
- default: false,
- },
- isSizeAvailable: {
- type: Boolean,
- default: false,
- },
- nodes: {
- type: Array,
- required: true,
- },
- summary: {
- type: String,
- default: '',
- },
- filesListWidth: {
- type: Number,
- default: 0,
- },
- },
-
- setup() {
- const pathsStore = usePathsStore()
- const filesStore = useFilesStore()
- return {
- filesStore,
- pathsStore,
- }
- },
-
- computed: {
- currentView() {
- return this.$navigation.active
- },
-
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
-
- currentFolder() {
- if (!this.currentView?.id) {
- return
- }
-
- if (this.dir === '/') {
- return this.filesStore.getRoot(this.currentView.id)
- }
- const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
- 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 {
- padding-bottom: 300px;
- 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/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue
deleted file mode 100644
index dfe892af772..00000000000
--- a/apps/files/src/components/FilesListHeaderActions.vue
+++ /dev/null
@@ -1,226 +0,0 @@
-<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <th class="files-list__column files-list__row-actions-batch" colspan="2">
- <NcActions ref="actionsMenu"
- :disabled="!!loading || areSomeNodesLoading"
- :force-name="true"
- :inline="inlineActions"
- :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
- :open.sync="openedMenu">
- <NcActionButton v-for="action in enabledActions"
- :key="action.id"
- :class="'files-list__row-actions-batch-' + action.id"
- @click="onActionClick(action)">
- <template #icon>
- <NcLoadingIcon v-if="loading === action.id" :size="18" />
- <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
- </template>
- {{ action.displayName(nodes, currentView) }}
- </NcActionButton>
- </NcActions>
- </th>
-</template>
-
-<script lang="ts">
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import Vue from 'vue'
-
-import { getFileActions, useActionsMenuStore } from '../store/actionsmenu.ts'
-import { useFilesStore } from '../store/files.ts'
-import { useSelectionStore } from '../store/selection.ts'
-import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import CustomSvgIconRender from './CustomSvgIconRender.vue'
-import logger from '../logger.js'
-import { NodeStatus } from '@nextcloud/files'
-
-// The registered actions list
-const actions = getFileActions()
-
-export default Vue.extend({
- name: 'FilesListHeaderActions',
-
- components: {
- CustomSvgIconRender,
- NcActions,
- NcActionButton,
- NcLoadingIcon,
- },
-
- mixins: [
- filesListWidthMixin,
- ],
-
- props: {
- currentView: {
- type: Object,
- required: true,
- },
- selectedNodes: {
- type: Array,
- default: () => ([]),
- },
- },
-
- setup() {
- const actionsMenuStore = useActionsMenuStore()
- const filesStore = useFilesStore()
- const selectionStore = useSelectionStore()
- return {
- actionsMenuStore,
- filesStore,
- selectionStore,
- }
- },
-
- data() {
- return {
- loading: null,
- }
- },
-
- computed: {
- dir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
- },
- enabledActions() {
- return actions
- .filter(action => action.execBatch)
- .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- },
-
- nodes() {
- return this.selectedNodes
- .map(fileid => this.getNode(fileid))
- .filter(node => 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.filesListWidth < 512) {
- return 0
- }
- if (this.filesListWidth < 768) {
- return 1
- }
- if (this.filesListWidth < 1024) {
- return 2
- }
- return 3
- },
- },
-
- methods: {
- /**
- * Get a cached note from the store
- *
- * @param {number} fileId the file id to get
- * @return {Folder|File}
- */
- getNode(fileId) {
- return this.filesStore.getNode(fileId)
- },
-
- async onActionClick(action) {
- const displayName = action.displayName(this.nodes, this.currentView)
- const selectionIds = this.selectedNodes
- try {
- // Set loading markers
- this.loading = action.id
- this.nodes.forEach(node => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- })
-
- // Dispatch action execution
- const results = await action.execBatch(this.nodes, this.currentView, this.dir)
-
- // 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 failedIds = selectionIds
- .filter((fileid, index) => results[index] === false)
- this.selectionStore.set(failedIds)
-
- showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
- return
- }
-
- // Show success message and clear selection
- showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
- this.selectionStore.reset()
- } catch (e) {
- logger.error('Error while executing action', { action, e })
- showError(this.t('files', '"{displayName}" action failed', { displayName }))
- } finally {
- // Remove loading markers
- this.loading = null
- this.nodes.forEach(node => {
- Vue.set(node, 'status', undefined)
- })
- }
- },
-
- t: translate,
- },
-})
-</script>
-
-<style scoped lang="scss">
-.files-list__row-actions-batch {
- flex: 1 1 100% !important;
-
- // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
- ::v-deep .button-vue__wrapper {
- width: 100%;
- span.button-vue__text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-}
-</style>
diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue
deleted file mode 100644
index 9aac83a185d..00000000000
--- a/apps/files/src/components/FilesListHeaderButton.vue
+++ /dev/null
@@ -1,122 +0,0 @@
-<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <NcButton :aria-label="sortAriaLabel(name)"
- :class="{'files-list__column-sort-button--active': sortingMode === mode}"
- class="files-list__column-sort-button"
- type="tertiary"
- @click.stop.prevent="toggleSortBy(mode)">
- <!-- Sort icon before text as size is align right -->
- <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" />
- <MenuDown v-else slot="icon" />
- {{ name }}
- </NcButton>
-</template>
-
-<script lang="ts">
-import { translate } from '@nextcloud/l10n'
-import MenuDown from 'vue-material-design-icons/MenuDown.vue'
-import MenuUp from 'vue-material-design-icons/MenuUp.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import Vue from 'vue'
-
-import filesSortingMixin from '../mixins/filesSorting.ts'
-
-export default Vue.extend({
- name: 'FilesListHeaderButton',
-
- components: {
- MenuDown,
- MenuUp,
- NcButton,
- },
-
- mixins: [
- filesSortingMixin,
- ],
-
- props: {
- name: {
- type: String,
- required: true,
- },
- mode: {
- type: String,
- required: true,
- },
- },
-
- methods: {
- sortAriaLabel(column) {
- const direction = this.isAscSorting
- ? this.t('files', 'ascending')
- : this.t('files', 'descending')
- return this.t('files', 'Sort list by {column} ({direction})', {
- column,
- direction,
- })
- },
-
- t: translate,
- },
-})
-</script>
-
-<style lang="scss">
-.files-list__column-sort-button {
- // Compensate for cells margin
- margin: 0 calc(var(--cell-margin) * -1);
- // Reverse padding
- padding: 0 4px 0 16px !important;
-
- // Icon after text
- .button-vue__wrapper {
- flex-direction: row-reverse;
- // Take max inner width for text overflow ellipsis
- // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
- width: 100%;
- }
-
- .button-vue__icon {
- transition-timing-function: linear;
- transition-duration: .1s;
- transition-property: opacity;
- opacity: 0;
- }
-
- // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
- .button-vue__text {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- &--active,
- &:hover,
- &:focus,
- &:active {
- .button-vue__icon {
- opacity: 1 !important;
- }
- }
-}
-</style>
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
index 3e8f49deace..bca4604d57d 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -159,17 +159,16 @@ export default Vue.extend({
<style scoped lang="scss">
// Scoped row
tr {
- padding-bottom: 300px;
+ margin-bottom: 300px;
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;
+ 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
index 52060d2589e..e619acf0623 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -34,6 +34,7 @@
<template v-else>
<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
+ :aria-sort="ariaSortForMode('basename')"
@click.stop.prevent="toggleSortBy('basename')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />
@@ -48,21 +49,24 @@
<!-- Size -->
<th v-if="isSizeAvailable"
:class="{'files-list__column--sortable': isSizeAvailable}"
- class="files-list__column files-list__row-size">
+ class="files-list__column files-list__row-size"
+ :aria-sort="ariaSortForMode('size')">
<FilesListTableHeaderButton :name="t('files', 'Size')" mode="size" />
</th>
<!-- Mtime -->
<th v-if="isMtimeAvailable"
:class="{'files-list__column--sortable': isMtimeAvailable}"
- class="files-list__column files-list__row-mtime">
+ class="files-list__column files-list__row-mtime"
+ :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)">
+ :class="classForColumn(column)"
+ :aria-sort="ariaSortForMode(column.id)">
<FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
@@ -173,6 +177,13 @@ export default Vue.extend({
},
methods: {
+ ariaSortForMode(mode: string): ARIAMixin['ariaSort'] {
+ if (this.sortingMode === mode) {
+ return this.isAscSorting ? 'ascending' : 'descending'
+ }
+ return null
+ },
+
classForColumn(column) {
return {
'files-list__column': true,
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index e5247fb4b94..3b364a0b83d 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -33,7 +33,7 @@
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
- <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
@@ -47,6 +47,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue from 'vue'
@@ -54,7 +55,6 @@ import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
-import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
// The registered actions list
@@ -64,9 +64,9 @@ export default Vue.extend({
name: 'FilesListTableHeaderActions',
components: {
- CustomSvgIconRender,
NcActions,
NcActionButton,
+ NcIconSvgWrapper,
NcLoadingIcon,
},
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
index ebd1abb4314..659aee8e456 100644
--- a/apps/files/src/components/FilesListTableHeaderButton.vue
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -22,6 +22,7 @@
<template>
<NcButton :aria-label="sortAriaLabel(name)"
:class="{'files-list__column-sort-button--active': sortingMode === mode}"
+ :alignment="mode !== 'size' ? 'start-reverse' : undefined"
class="files-list__column-sort-button"
type="tertiary"
@click.stop.prevent="toggleSortBy(mode)">
@@ -67,12 +68,8 @@ export default Vue.extend({
methods: {
sortAriaLabel(column) {
- const direction = this.isAscSorting
- ? this.t('files', 'ascending')
- : this.t('files', 'descending')
- return this.t('files', 'Sort list by {column} ({direction})', {
+ return this.t('files', 'Sort list by {column}', {
column,
- direction,
})
},
@@ -85,16 +82,6 @@ export default Vue.extend({
.files-list__column-sort-button {
// Compensate for cells margin
margin: 0 calc(var(--cell-margin) * -1);
- // Reverse padding
- padding: 0 4px 0 16px !important;
-
- // Icon after text
- .button-vue__wrapper {
- flex-direction: row-reverse;
- // Take max inner width for text overflow ellipsis
- // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
- width: 100%;
- }
.button-vue__icon {
transition-timing-function: linear;
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 9a55b9cdde4..7ada3e705ee 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -20,63 +20,80 @@
-
-->
<template>
- <VirtualList :data-component="FileEntry"
- :data-key="'source'"
- :data-sources="nodes"
- :item-height="56"
- :extra-props="{
- isMtimeAvailable,
- isSizeAvailable,
- nodes,
- filesListWidth,
- }"
- :scroll-to-index="scrollToIndex">
- <!-- Accessibility description and headers -->
- <template #before>
- <!-- Accessibility description -->
- <caption class="hidden-visually">
- {{ currentView.caption || t('files', 'List of files and folders.') }}
- {{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
- </caption>
-
- <!-- Headers -->
- <FilesListHeader v-for="header in sortedHeaders"
- :key="header.id"
- :current-folder="currentFolder"
- :current-view="currentView"
- :header="header" />
- </template>
-
- <!-- Thead-->
- <template #header>
- <FilesListTableHeader :files-list-width="filesListWidth"
- :is-mtime-available="isMtimeAvailable"
- :is-size-available="isSizeAvailable"
- :nodes="nodes" />
- </template>
-
- <!-- Tfoot-->
- <template #footer>
- <FilesListTableFooter :files-list-width="filesListWidth"
- :is-mtime-available="isMtimeAvailable"
- :is-size-available="isSizeAvailable"
- :nodes="nodes"
- :summary="summary" />
- </template>
- </VirtualList>
+ <Fragment>
+ <!-- Drag and drop notice -->
+ <DragAndDropNotice v-if="canUpload && filesListWidth >= 512"
+ :current-folder="currentFolder"
+ :dragover.sync="dragover"
+ :style="{ height: dndNoticeHeight }" />
+
+ <VirtualList ref="table"
+ :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
+ :data-key="'source'"
+ :data-sources="nodes"
+ :grid-mode="userConfig.grid_view"
+ :extra-props="{
+ isMtimeAvailable,
+ isSizeAvailable,
+ nodes,
+ filesListWidth,
+ }"
+ :scroll-to-index="scrollToIndex"
+ @scroll="onScroll">
+ <!-- Accessibility description and headers -->
+ <template #before>
+ <!-- Accessibility description -->
+ <caption class="hidden-visually">
+ {{ currentView.caption || t('files', 'List of files and folders.') }}
+ {{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
+ </caption>
+
+ <!-- Headers -->
+ <FilesListHeader v-for="header in sortedHeaders"
+ :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="filesListWidth"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes" />
+ </template>
+
+ <!-- Tfoot-->
+ <template #footer>
+ <FilesListTableFooter :files-list-width="filesListWidth"
+ :is-mtime-available="isMtimeAvailable"
+ :is-size-available="isSizeAvailable"
+ :nodes="nodes"
+ :summary="summary" />
+ </template>
+ </VirtualList>
+ </Fragment>
</template>
<script lang="ts">
+import type { Node as NcNode } from '@nextcloud/files'
import type { PropType } from 'vue'
-import type { Node } from '@nextcloud/files'
+import type { UserConfig } from '../types.ts'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import { getFileListHeaders, Folder, View } from '@nextcloud/files'
+import { Fragment } from 'vue-frag'
+import { getFileListHeaders, Folder, View, Permission } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import DragAndDropNotice from './DragAndDropNotice.vue'
import FileEntry from './FileEntry.vue'
+import FileEntryGrid from './FileEntryGrid.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
@@ -88,9 +105,11 @@ export default Vue.extend({
name: 'FilesListVirtual',
components: {
+ DragAndDropNotice,
FilesListHeader,
- FilesListTableHeader,
FilesListTableFooter,
+ FilesListTableHeader,
+ Fragment,
VirtualList,
},
@@ -108,26 +127,40 @@ export default Vue.extend({
required: true,
},
nodes: {
- type: Array as PropType<Node[]>,
+ type: Array as PropType<NcNode[]>,
required: true,
},
},
+ setup() {
+ const userConfigStore = useUserConfigStore()
+ return {
+ userConfigStore,
+ }
+ },
+
data() {
return {
FileEntry,
+ FileEntryGrid,
headers: getFileListHeaders(),
scrollToIndex: 0,
+ dragover: false,
+ dndNoticeHeight: 0,
}
},
computed: {
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
files() {
return this.nodes.filter(node => node.type === 'file')
},
fileId() {
- return parseInt(this.$route.params.fileid || this.$route.query.fileid) || null
+ return parseInt(this.$route.params.fileid) || null
},
summaryFile() {
@@ -163,40 +196,99 @@ export default Vue.extend({
return [...this.headers].sort((a, b) => a.order - b.order)
},
+
+ canUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+ },
+ },
+
+ watch: {
+ fileId(fileId) {
+ this.scrollToFile(fileId, false)
+ },
},
mounted() {
- // Scroll to the file if it's in the url
- if (this.fileId) {
- const index = this.nodes.findIndex(node => node.fileid === this.fileId)
- if (index === -1 && this.fileId !== this.currentFolder.fileid) {
- showError(this.t('files', 'File not found'))
- }
- this.scrollToIndex = Math.max(0, index)
- }
+ // 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)
+ mainContent.addEventListener('dragleave', this.onDragLeave)
+
+ this.scrollToFile(this.fileId)
+ this.openSidebarForFile(this.fileId)
+ },
+ methods: {
// Open the file sidebar if we have the room for it
- if (document.documentElement.clientWidth > 1024) {
- // Don't open the sidebar for the current folder
- if (this.currentFolder.fileid === this.fileId) {
- return
+ // but don't open the sidebar for the current folder
+ openSidebarForFile(fileId) {
+ if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== fileId) {
+ // Open the sidebar for the given URL fileid
+ // iif we just loaded the app.
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node && sidebarAction?.enabled?.([node], this.currentView)) {
+ logger.debug('Opening sidebar on file ' + node.path, { node })
+ sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ }
}
+ },
- // Open the sidebar for the given URL fileid
- // iif we just loaded the app.
- const node = this.nodes.find(n => n.fileid === this.fileId) as Node
- if (node && sidebarAction?.enabled?.([node], this.currentView)) {
- logger.debug('Opening sidebar on file ' + node.path, { node })
- sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ scrollToFile(fileId: number, warn = true) {
+ if (fileId) {
+ const index = this.nodes.findIndex(node => node.fileid === fileId)
+ if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
+ showError(this.t('files', 'File not found'))
+ }
+ this.scrollToIndex = Math.max(0, index)
}
- }
- },
+ },
- methods: {
getFileId(node) {
return node.fileid
},
+ onDragOver(event: DragEvent) {
+ // Detect if we're only dragging existing files or not
+ const isForeignFile = event.dataTransfer?.types.includes('Files')
+ if (isForeignFile) {
+ this.dragover = true
+ } else {
+ this.dragover = false
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const tableTop = this.$refs.table.$el.getBoundingClientRect().top
+ const tableBottom = tableTop + this.$refs.table.$el.getBoundingClientRect().height
+
+ // If reaching top, scroll up. Using 100 because of the floating header
+ if (event.clientY < tableTop + 100) {
+ this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop - 25
+ return
+ }
+
+ // If reaching bottom, scroll down
+ if (event.clientY > tableBottom - 50) {
+ this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop + 25
+ }
+ },
+ 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
+ },
+
+ onScroll() {
+ // Update the sticky position of the thead to adapt to the scroll
+ this.dndNoticeHeight = (this.$refs.thead.$el?.getBoundingClientRect?.()?.top ?? 0) + 'px'
+ },
+
t,
},
})
@@ -215,15 +307,27 @@ export default Vue.extend({
display: block;
overflow: auto;
height: 100%;
+ will-change: scroll-position;
&::v-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
@@ -232,6 +336,15 @@ export default Vue.extend({
flex-direction: column;
}
+ .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
@@ -240,12 +353,9 @@ export default Vue.extend({
top: 0;
}
- .files-list__thead,
+ // Table footer
.files-list__tfoot {
- display: flex;
- width: 100%;
- background-color: var(--color-main-background);
-
+ min-height: 300px;
}
tr {
@@ -255,7 +365,9 @@ export default Vue.extend({
width: 100%;
user-select: none;
border-bottom: 1px solid var(--color-border);
+ box-sizing: border-box;
user-select: none;
+ height: var(--row-height);
}
td, th {
@@ -316,7 +428,11 @@ export default Vue.extend({
.files-list__row {
&:hover, &:focus, &:active, &--active, &--dragover {
- background-color: var(--color-background-dark);
+ // 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);
}
@@ -377,10 +493,15 @@ export default Vue.extend({
width: var(--icon-preview-size);
height: var(--icon-preview-size);
border-radius: var(--border-radius);
- background-repeat: no-repeat;
// Center and contain the preview
- background-position: center;
- background-size: contain;
+ object-fit: contain;
+ object-position: center;
+
+ /* 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 {
@@ -388,6 +509,16 @@ export default Vue.extend({
top: 0px;
right: -10px;
}
+
+ // Folder overlay
+ &-overlay {
+ position: absolute;
+ max-height: calc(var(--icon-preview-size) * 0.5);
+ max-width: calc(var(--icon-preview-size) * 0.5);
+ color: var(--color-main-background);
+ // better alignment with the folder icon
+ margin-top: 2px;
+ }
}
// Entry link
@@ -430,6 +561,8 @@ export default Vue.extend({
.files-list__row-name-ext {
color: var(--color-text-maxcontrast);
+ // always show the extension
+ overflow: visible;
}
}
@@ -453,6 +586,7 @@ export default Vue.extend({
}
.files-list__row-actions {
+ // take as much space as necessary
width: auto;
// Add margin to all cells after the actions
@@ -475,19 +609,12 @@ export default Vue.extend({
.files-list__row-mtime,
.files-list__row-size {
- // Right align text
- justify-content: flex-end;
+ color: var(--color-text-maxcontrast);
+ }
+ .files-list__row-size {
width: calc(var(--row-height) * 1.5);
- // opacity varies with the size
- color: var(--color-main-text);
-
- // Icon is before text since size is right aligned
- .files-list__column-sort-button {
- padding: 0 16px 0 4px !important;
- .button-vue__wrapper {
- flex-direction: row;
- }
- }
+ // Right align content/text
+ justify-content: flex-end;
}
.files-list__row-mtime {
@@ -500,3 +627,91 @@ export default Vue.extend({
}
}
</style>
+
+<style lang="scss">
+// Grid mode
+tbody.files-list__tbody.files-list__tbody--grid {
+ --half-clickable-area: calc(var(--clickable-area) / 2);
+ --row-width: 160px;
+ // We use half of the clickable area as visual balance margin
+ --row-height: calc(var(--row-width) - var(--half-clickable-area));
+ --icon-preview-size: calc(var(--row-width) - var(--clickable-area));
+ --checkbox-padding: 0px;
+
+ display: grid;
+ grid-template-columns: repeat(auto-fill, var(--row-width));
+ grid-gap: 15px;
+ row-gap: 15px;
+
+ align-content: center;
+ align-items: center;
+ justify-content: space-around;
+ justify-items: center;
+
+ tr {
+ width: var(--row-width);
+ height: calc(var(--row-height) + var(--clickable-area));
+ border: none;
+ border-radius: var(--border-radius);
+ }
+
+ // Checkbox in the top left
+ .files-list__row-checkbox {
+ position: absolute;
+ z-index: 9;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ border-radius: var(--half-clickable-area);
+ }
+
+ // Star icon in the top right
+ .files-list__row-icon-favorite {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name {
+ display: grid;
+ justify-content: stretch;
+ width: 100%;
+ height: 100%;
+ grid-auto-rows: var(--row-height) var(--clickable-area);
+
+ span.files-list__row-icon {
+ width: 100%;
+ height: 100%;
+ // Visual balance, we use half of the clickable area
+ // as a margin around the preview
+ padding-top: var(--half-clickable-area);
+ }
+
+ a.files-list__row-name-link {
+ // Minus action menu
+ width: calc(100% - var(--clickable-area));
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name-text {
+ margin: 0;
+ padding-right: 0;
+ }
+ }
+
+ .files-list__row-actions {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+}
+</style>
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index 4a877049fa8..25bdcde1b45 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -51,8 +51,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?.quota, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue
index 037c4fd4f68..66043220223 100644
--- a/apps/files/src/components/TransferOwnershipDialogue.vue
+++ b/apps/files/src/components/TransferOwnershipDialogue.vue
@@ -25,7 +25,9 @@
<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">
@@ -225,10 +227,12 @@ p {
}
.new-owner-row {
display: flex;
+ flex-wrap: wrap;
label {
display: flex;
align-items: center;
+ margin-bottom: calc(var(--default-grid-baseline) * 2);
span {
margin-right: 8px;
@@ -244,5 +248,9 @@ p {
span {
margin-right: 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
index 511053b2fa1..a579cfcc8f3 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -11,11 +11,13 @@
</thead>
<!-- Body -->
- <tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody>
+ <tbody :style="tbodyStyle"
+ class="files-list__tbody"
+ :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
+ data-cy-files-list-tbody>
<component :is="dataComponent"
- v-for="(item, i) in renderedItems"
- :key="i"
- :visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
+ v-for="({key, item}, i) in renderedItems"
+ :key="key"
:source="item"
:index="i"
v-bind="extraProps" />
@@ -23,7 +25,6 @@
<!-- Footer -->
<tfoot v-show="isReady"
- ref="tfoot"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@@ -32,16 +33,23 @@
</template>
<script lang="ts">
-import { File, Folder, debounce } from 'debounce'
-import Vue from 'vue'
+import type { File, Folder, Node } from '@nextcloud/files'
+import { debounce } from 'debounce'
+import Vue, { PropType } from 'vue'
+
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger.js'
-// Items to render before and after the visible area
-const bufferItems = 3
+interface RecycledPoolItem {
+ key: string,
+ item: Node,
+}
export default Vue.extend({
name: 'VirtualList',
+ mixins: [filesListWidthMixin],
+
props: {
dataComponent: {
type: [Object, Function],
@@ -52,26 +60,25 @@ export default Vue.extend({
required: true,
},
dataSources: {
- type: Array as () => (File | Folder)[],
- required: true,
- },
- itemHeight: {
- type: Number,
+ type: Array as PropType<(File | Folder)[]>,
required: true,
},
extraProps: {
- type: Object,
+ type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
scrollToIndex: {
type: Number,
default: 0,
},
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
- bufferItems,
index: this.scrollToIndex,
beforeHeight: 0,
headerHeight: 0,
@@ -86,60 +93,126 @@ export default Vue.extend({
return this.tableHeight > 0
},
+ // Items to render before and after the visible area
+ bufferItems() {
+ if (this.gridMode) {
+ return this.columnCount
+ }
+ return 3
+ },
+
+ itemHeight() {
+ // Align with css in FilesListVirtual
+ // 138px + 44px (name) + 15px (grid gap)
+ return this.gridMode ? (138 + 44 + 15) : 55
+ },
+ // Grid mode only
+ itemWidth() {
+ // 160px + 15px grid gap
+ return 160 + 15
+ },
+
+ rowCount() {
+ return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
+ },
+ columnCount() {
+ if (!this.gridMode) {
+ return 1
+ }
+ return Math.floor(this.filesListWidth / this.itemWidth)
+ },
+
startIndex() {
- return Math.max(0, this.index - bufferItems)
+ return Math.max(0, this.index - this.bufferItems)
},
shownItems() {
- return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
+ // 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(): (File | Folder)[] {
+ renderedItems(): RecycledPoolItem[] {
if (!this.isReady) {
return []
}
- return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
+
+ 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 }
+ })
},
tbodyStyle() {
- const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
+ const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
- const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
+ const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
return {
- paddingTop: `${this.startIndex * this.itemHeight}px`,
+ paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
}
},
},
watch: {
- scrollToIndex() {
- this.index = this.scrollToIndex
- this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
+ scrollToIndex(index) {
+ this.scrollTo(index)
+ },
+ columnCount(columnCount, oldColumnCount) {
+ if (oldColumnCount === 0) {
+ // We're initializing, the scroll position
+ // is handled on mounted
+ console.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() {
const before = this.$refs?.before as HTMLElement
const root = this.$el as HTMLElement
- const tfoot = this.$refs?.tfoot as HTMLElement
const thead = this.$refs?.thead as HTMLElement
this.resizeObserver = new ResizeObserver(debounce(() => {
this.beforeHeight = before?.clientHeight ?? 0
this.headerHeight = thead?.clientHeight ?? 0
this.tableHeight = root?.clientHeight ?? 0
- logger.debug('VirtualList resizeObserver updated')
+ logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
}, 100, false))
this.resizeObserver.observe(before)
this.resizeObserver.observe(root)
- this.resizeObserver.observe(tfoot)
this.resizeObserver.observe(thead)
- this.$el.addEventListener('scroll', this.onScroll)
-
if (this.scrollToIndex) {
- this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
+ this.scrollTo(this.scrollToIndex)
}
+
+ // Adding scroll listener AFTER the initial scroll to index
+ this.$el.addEventListener('scroll', this.onScroll, { passive: true })
+
+ this.$_recycledPool = {} as Record<string, any>
},
beforeDestroy() {
@@ -149,14 +222,24 @@ export default Vue.extend({
},
methods: {
+ scrollTo(index: number) {
+ this.index = index
+ // Scroll to one row and a half before the index
+ const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
+ logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount })
+ this.$el.scrollTop = scrollTop
+ },
+
onScroll() {
- // Max 0 to prevent negative index
- this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
+ this._onScrollHandle ??= requestAnimationFrame(() => {
+ this._onScrollHandle = null;
+ const topScroll = this.$el.scrollTop - this.beforeHeight
+ const index = Math.floor(topScroll / this.itemHeight) * this.columnCount
+ // Max 0 to prevent negative index
+ this.index = Math.max(0, index)
+ this.$emit('scroll')
+ });
},
},
})
</script>
-
-<style scoped>
-
-</style>