<script lang='ts'>
import { debounce } from 'debounce'
import { emit } from '@nextcloud/event-bus'
+import { extname } from 'path'
import { formatFileSize, Permission } from '@nextcloud/files'
import { Fragment } from 'vue-frag'
-import { extname } from 'path'
+import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
import { vOnClickOutside } from '@vueuse/components'
import axios from '@nextcloud/axios'
import CancelablePromise from 'cancelable-promise'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import moment from '@nextcloud/moment'
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 Vue from 'vue'
-import type moment from 'moment'
import { ACTION_DETAILS } from '../actions/sidebarAction.ts'
import { getFileActions, DefaultType } from '../services/FileAction.ts'
import { useActionsMenuStore } from '../store/actionsmenu.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 { useRenamingStore } from '../store/renaming.ts'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import FavoriteIcon from './FavoriteIcon.vue'
},
watch: {
- active(active, before) {
- if (active === false && before === true) {
- this.resetState()
-
- // When the row is not active anymore
- // remove the display from the row to prevent
- // keyboard interaction with it.
- this.$el.parentNode.style.display = 'none'
- return
- }
-
- // Restore default tabindex
- this.$el.parentNode.style.display = ''
- },
-
/**
* When the source changes, reset the preview
* and fetch the new one.
--- /dev/null
+<!--
+ - @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 { formatFileSize } from '@nextcloud/files'
+import { translate } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+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>
--- /dev/null
+<!--
+ - @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 } from '../services/FileAction.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
+import logger from '../logger.js'
+
+// 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._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, '_loading', true)
+ })
+
+ // 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, '_loading', false)
+ })
+ }
+ },
+
+ 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>
--- /dev/null
+<!--
+ - @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>
-
-->
<template>
- <tr class="files-list__row-footer">
+ <tr>
<th class="files-list__row-checkbox">
<span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
</th>
width: 100%;
user-select: none;
border-bottom: 1px solid var(--color-border);
+ user-select: none;
}
td, th {
--- /dev/null
+<template>
+ <table class="files-list">
+ <!-- Header -->
+ <div ref="before" class="files-list__before">
+ <slot name="before" />
+ </div>
+
+ <!-- Header -->
+ <thead ref="thead" class="files-list__thead">
+ <slot name="header" />
+ </thead>
+
+ <!-- Body -->
+ <tbody :style="tbodyStyle" class="files-list__tbody">
+ <tr v-for="(item, i) in renderedItems"
+ :key="i"
+ :class="{'list__row--active': (i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)}"
+ class="list__row">
+ <component :is="dataComponent"
+ :active="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
+ :source="item"
+ :index="i"
+ :item-height="itemHeight"
+ v-bind="extraProps" />
+ </tr>
+ </tbody>
+
+ <!-- Footer -->
+ <tfoot ref="tfoot" class="files-list__tfoot">
+ <slot name="footer" />
+ </tfoot>
+ </table>
+</template>
+
+<script lang="ts">
+import { File, Folder } from '@nextcloud/files'
+import { debounce } from 'debounce'
+import Vue from 'vue'
+import logger from '../logger.js'
+
+// Items to render before and after the visible area
+const bufferItems = 3
+
+export default Vue.extend({
+ name: 'VirtualList',
+
+ props: {
+ dataComponent: {
+ type: [Object, Function],
+ required: true,
+ },
+ dataKey: {
+ type: String,
+ required: true,
+ },
+ dataSources: {
+ type: Array as () => (File | Folder)[],
+ required: true,
+ },
+ itemHeight: {
+ type: Number,
+ required: true,
+ },
+ extraProps: {
+ type: Object,
+ default: () => ({}),
+ },
+ scrollToIndex: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ data() {
+ return {
+ bufferItems,
+ index: this.scrollToIndex,
+ beforeHeight: 0,
+ footerHeight: 0,
+ headerHeight: 0,
+ tableHeight: 0,
+ resizeObserver: null as ResizeObserver | null,
+ }
+ },
+
+ computed: {
+ startIndex() {
+ return Math.max(0, this.index - bufferItems)
+ },
+ shownItems() {
+ return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
+ },
+ renderedItems(): (File | Folder)[] {
+ return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
+ },
+
+ tbodyStyle() {
+ const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
+ const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
+ const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
+ return {
+ paddingTop: `${this.startIndex * 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
+ },
+ index() {
+ logger.debug('VirtualList index updated to ' + 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.footerHeight = tfoot?.clientHeight ?? 0
+ this.headerHeight = thead?.clientHeight ?? 0
+ this.tableHeight = root?.clientHeight ?? 0
+ 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
+ }
+ },
+
+ beforeDestroy() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect()
+ }
+ },
+
+ methods: {
+ onScroll() {
+ // Max 0 to prevent negative index
+ this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
+ },
+ },
+})
+</script>
+
+<style scoped>
+
+</style>
"vue-multiselect": "^2.1.6",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
+ "vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#master",
"vue-virtual-scroller": "^1.1.2",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
+ "node_modules/vue-virtual-scroll-list": {
+ "version": "2.3.5",
+ "resolved": "git+ssh://git@github.com/skjnldsv/vue-virtual-scroll-list.git#0f81a0090c3d5f934a7e44c1a90ab8bf36757ea1",
+ "license": "MIT"
+ },
"node_modules/vue-virtual-scroller": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.1.2.tgz",
"vue-multiselect": "^2.1.6",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
+ "vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#master",
"vue-virtual-scroller": "^1.1.2",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",