aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components')
-rw-r--r--apps/files/src/components/BreadCrumbs.vue6
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue4
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue22
-rw-r--r--apps/files/src/components/FileEntry.vue75
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue6
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue168
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue6
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue26
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue14
-rw-r--r--apps/files/src/components/FileEntryGrid.vue6
-rw-r--r--apps/files/src/components/FileEntryMixin.ts103
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilter.vue6
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterModified.vue10
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterToSearch.vue47
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterType.vue10
-rw-r--r--apps/files/src/components/FileListFilters.vue4
-rw-r--r--apps/files/src/components/FilesListHeader.vue63
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue10
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue20
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue149
-rw-r--r--apps/files/src/components/FilesListTableHeaderButton.vue2
-rw-r--r--apps/files/src/components/FilesListVirtual.vue255
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue12
-rw-r--r--apps/files/src/components/FilesNavigationSearch.vue86
-rw-r--r--apps/files/src/components/LegacyView.vue2
-rw-r--r--apps/files/src/components/NavigationQuota.vue8
-rw-r--r--apps/files/src/components/NewNodeDialog.vue19
-rw-r--r--apps/files/src/components/SidebarTab.vue4
-rw-r--r--apps/files/src/components/TemplateFiller.vue22
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue4
-rw-r--r--apps/files/src/components/TemplateFiller/TemplateRichTextField.vue4
-rw-r--r--apps/files/src/components/TransferOwnershipDialogue.vue24
-rw-r--r--apps/files/src/components/VirtualList.vue182
33 files changed, 996 insertions, 383 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index a14c6545118..8458fd65f3d 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -42,9 +42,9 @@ import { defineComponent } from 'vue'
import { Permission } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
-import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
-import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb'
+import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { useNavigation } from '../composables/useNavigation.ts'
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 23ebf7cd296..c7684d5c205 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -85,7 +85,7 @@ export default defineComponent({
if (this.isQuotaExceeded) {
return this.t('files', 'Your have used your space quota and cannot upload files anymore')
} else if (!this.canUpload) {
- return this.t('files', 'You don’t have permission to upload or create files here')
+ return this.t('files', 'You do not have permission to upload or create files here.')
}
return null
},
@@ -235,7 +235,7 @@ export default defineComponent({
justify-content: center;
width: 100%;
// Breadcrumbs height + row thead height
- min-height: calc(58px + 55px);
+ min-height: calc(58px + 44px);
margin: 0;
user-select: none;
color: var(--color-text-maxcontrast);
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
index 7c9c6f4f1a7..72fd98d43fb 100644
--- a/apps/files/src/components/DragAndDropPreview.vue
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -92,7 +92,7 @@ export default Vue.extend({
</script>
<style lang="scss">
-$size: 32px;
+$size: 28px;
$stack-shift: 6px;
.files-list-drag-image {
@@ -102,24 +102,24 @@ $stack-shift: 6px;
display: flex;
overflow: hidden;
align-items: center;
- height: 44px;
- padding: 6px 12px;
+ height: $size + $stack-shift;
+ padding: $stack-shift $stack-shift * 2;
background: var(--color-main-background);
&__icon,
- .files-list__row-icon {
+ .files-list__row-icon-preview-container {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
- width: 32px;
- height: 32px;
+ width: $size - $stack-shift;
+ height: $size - $stack-shift;;
border-radius: var(--border-radius);
}
&__icon {
overflow: visible;
- margin-inline-end: 12px;
+ margin-inline-end: $stack-shift * 2;
img {
max-width: 100%;
@@ -138,13 +138,15 @@ $stack-shift: 6px;
display: flex;
// Stack effect if more than one element
- .files-list__row-icon + .files-list__row-icon {
+ // Max 3 elements
+ > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container {
margin-top: $stack-shift;
- margin-inline-start: $stack-shift - $size;
- & + .files-list__row-icon {
+ margin-inline-start: $stack-shift * 2 - $size;
+ & + .files-list__row-icon-preview-container {
margin-top: $stack-shift * 2;
}
}
+
// If we have manually clone the preview,
// let's hide any fallback icons
&:not(:empty) + * {
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 7541c0f0631..d66c3fa0ed7 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -49,6 +49,15 @@
:opened.sync="openedMenu"
:source="source" />
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ :title="mime"
+ class="files-list__row-mime"
+ data-cy-files-list-row-mime
+ @click="openDetailsIfAvailable">
+ <span>{{ mime }}</span>
+ </td>
+
<!-- Size -->
<td v-if="!compact && isSizeAvailable"
:style="sizeOpacity"
@@ -64,7 +73,9 @@
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
- <NcDateTime v-if="mtime" :timestamp="mtime" :ignore-seconds="true" />
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
<span v-else>{{ t('files', 'Unknown date') }}</span>
</td>
@@ -83,11 +94,11 @@
</template>
<script lang="ts">
+import { FileType, formatFileSize } from '@nextcloud/files'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
-import { formatFileSize } from '@nextcloud/files'
-import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
-import moment from '@nextcloud/moment'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
+import { t } from '@nextcloud/l10n'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { useNavigation } from '../composables/useNavigation.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
@@ -122,6 +133,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
@@ -185,6 +200,36 @@ export default defineComponent({
return this.currentView.columns || []
},
+ mime() {
+ if (this.source.type === FileType.Folder) {
+ return this.t('files', 'Folder')
+ }
+
+ if (!this.source.mime || this.source.mime === 'application/octet-stream') {
+ return t('files', 'Unknown file type')
+ }
+
+ if (window.OC?.MimeTypeList?.names?.[this.source.mime]) {
+ return window.OC.MimeTypeList.names[this.source.mime]
+ }
+
+ const baseType = this.source.mime.split('/')[0]
+ const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || ''
+ if (baseType === 'image') {
+ return t('files', '{ext} image', { ext })
+ }
+ if (baseType === 'video') {
+ return t('files', '{ext} video', { ext })
+ }
+ if (baseType === 'audio') {
+ return t('files', '{ext} audio', { ext })
+ }
+ if (baseType === 'text') {
+ return t('files', '{ext} text', { ext })
+ }
+
+ return this.source.mime
+ },
size() {
const size = this.source.size
if (size === undefined || isNaN(size) || size < 0) {
@@ -206,26 +251,6 @@ export default defineComponent({
color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
}
},
-
- mtime() {
- // If the mtime is not a valid date, return it as is
- if (this.source.mtime && !isNaN(this.source.mtime.getDate())) {
- return this.source.mtime
- }
-
- if (this.source.crtime && !isNaN(this.source.crtime.getDate())) {
- return this.source.crtime
- }
-
- return null
- },
-
- mtimeTitle() {
- if (this.source.mtime) {
- return moment(this.source.mtime).format('LLL')
- }
- return ''
- },
},
created() {
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index 84f9fd828fc..c66cb8fbd7f 100644
--- a/apps/files/src/components/FileEntry/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -11,7 +11,7 @@ import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
/**
* A favorite icon to be used for overlaying favorite entries like the file preview / icon
@@ -56,8 +56,8 @@ export default defineComponent({
:deep() {
svg {
// We added a stroke for a11y so we must increase the size to include the stroke
- width: 26px !important;
- height: 26px !important;
+ width: 20px !important;
+ height: 20px !important;
// Override NcIconSvgWrapper defaults of 20px
max-width: unset !important;
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index 4e7bec88452..5c537d878fe 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -22,33 +22,68 @@
type="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
- :open.sync="openedMenu"
- @close="openedSubmenu = null">
- <!-- Default actions list-->
- <NcActionButton v-for="action in enabledMenuActions"
+ :open="openedMenu"
+ @close="onMenuClose"
+ @closed="onMenuClosed">
+ <!-- Non-destructive actions list -->
+ <!-- Please keep this block in sync with the destructive actions block below -->
+ <NcActionButton v-for="action, index in renderedNonDestructiveActions"
:key="action.id"
:ref="`action-${action.id}`"
+ class="files-list__row-action"
:class="{
[`files-list__row-action-${action.id}`]: true,
- [`files-list__row-action--menu`]: isMenu(action.id)
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
}"
- :close-after-click="!isMenu(action.id)"
+ :close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
- :is-menu="isMenu(action.id)"
+ :is-menu="isValidMenu(action)"
:aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
- <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
- <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
</template>
- {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
+ {{ actionDisplayName(action) }}
</NcActionButton>
+ <!-- Destructive actions list -->
+ <template v-if="renderedDestructiveActions.length > 0">
+ <NcActionSeparator />
+ <NcActionButton v-for="action, index in renderedDestructiveActions"
+ :key="action.id"
+ :ref="`action-${action.id}`"
+ class="files-list__row-action"
+ :class="{
+ [`files-list__row-action-${action.id}`]: true,
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ 'files-list__row-action--destructive': true,
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
- <NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
+ <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
@@ -63,10 +98,11 @@
class="files-list__row-action--submenu"
close-after-click
:data-cy-files-list-row-action="action.id"
+ :aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
- <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@@ -82,22 +118,23 @@ import type { FileAction, Node } from '@nextcloud/files'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { defineComponent, inject } from 'vue'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
-import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { executeAction } from '../../utils/actionUtils.ts'
import { useActiveStore } from '../../store/active.ts'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import actionsMixins from '../../mixins/actionsMixin.ts'
import logger from '../../logger.ts'
export default defineComponent({
@@ -113,6 +150,8 @@ export default defineComponent({
NcLoadingIcon,
},
+ mixins: [actionsMixins],
+
props: {
opened: {
type: Boolean,
@@ -146,12 +185,6 @@ export default defineComponent({
}
},
- data() {
- return {
- openedSubmenu: null as FileAction | null,
- }
- },
-
computed: {
isActive() {
return this.activeStore?.activeNode?.source === this.source.source
@@ -209,16 +242,12 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
- enabledSubmenuActions() {
- return this.enabledFileActions
- .filter(action => action.parent)
- .reduce((arr, action) => {
- if (!arr[action.parent!]) {
- arr[action.parent!] = []
- }
- arr[action.parent!].push(action)
- return arr
- }, {} as Record<string, FileAction[]>)
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
},
openedMenu: {
@@ -238,14 +267,10 @@ export default defineComponent({
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
-
- mountType() {
- return this.source.attributes['mount-type']
- },
},
watch: {
- // Close any submenu when the menu is closed
+ // Close any submenu when the menu state changes
openedMenu() {
this.openedSubmenu = null
},
@@ -287,7 +312,7 @@ export default defineComponent({
return this.activeStore?.activeAction?.id === action.id
},
- async onActionClick(action, isSubmenu = false) {
+ async onActionClick(action) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
@@ -295,34 +320,10 @@ export default defineComponent({
}
// Make sure we set the node as active
- this.activeStore.setActiveNode(this.source)
+ this.activeStore.activeNode = this.source
// Execute the action
await executeAction(action)
-
- // If that was a submenu, we just go back after the action
- if (isSubmenu) {
- this.openedSubmenu = null
- }
- },
-
- isMenu(id: string) {
- return this.enabledSubmenuActions[id]?.length > 0
- },
-
- async onBackToMenuClick(action: FileAction) {
- this.openedSubmenu = null
- // Wait for first render
- await this.$nextTick()
-
- // Focus the previous menu action button
- this.$nextTick(() => {
- // Focus the action button
- const menuAction = this.$refs[`action-${action.id}`]?.[0]
- if (menuAction) {
- menuAction.$el.querySelector('button')?.focus()
- }
- })
},
onKeyDown(event: KeyboardEvent) {
@@ -341,6 +342,16 @@ export default defineComponent({
this.openedMenu = true
}
},
+
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
+ this.openedSubmenu = null
+ },
+
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
},
})
</script>
@@ -363,13 +374,26 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper {
}
</style>
-<style lang="scss" scoped>
-:deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) {
- .button-vue__text {
- color: var(--color-primary-element);
+<style scoped lang="scss">
+.files-list__row-action {
+ --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
+
+ // inline icons can have clickable area size so they still fit into the row
+ &.files-list__row-action--inline {
+ --max-icon-size: var(--default-clickable-area);
}
- .button-vue__icon {
- color: var(--color-primary-element);
+
+ // Some icons exceed the default size so we need to enforce a max width and height
+ .files-list__row-action-icon :deep(svg) {
+ max-height: var(--max-icon-size) !important;
+ max-width: var(--max-icon-size) !important;
+ }
+
+ &.files-list__row-action--destructive {
+ ::deep(button) {
+ color: var(--color-error) !important;
+ }
}
}
+
</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
index 1b6112373f4..5b80a971118 100644
--- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -21,11 +21,11 @@ import type { FileSource } from '../../types.ts'
import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
-import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { useActiveStore } from '../../store/active.ts'
import { useKeyboardStore } from '../../store/keyboard.ts'
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue
index 2a727b55c29..418f9581eb6 100644
--- a/apps/files/src/components/FileEntry/FileEntryName.vue
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -23,7 +23,6 @@
<component :is="linkTo.is"
v-else
ref="basename"
- :aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo.params">
@@ -31,7 +30,7 @@
<span class="files-list__row-name-text" dir="auto">
<!-- Keep the filename stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="basename" />
- <span class="files-list__row-name-ext" v-text="extension" />
+ <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" />
</span>
</component>
</template>
@@ -45,13 +44,14 @@ import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent, inject } from 'vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-import { useNavigation } from '../../composables/useNavigation'
+import { getFilenameValidity } from '../../utils/filenameValidity.ts'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
-import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useNavigation } from '../../composables/useNavigation.ts'
import { useRenamingStore } from '../../store/renaming.ts'
-import { getFilenameValidity } from '../../utils/filenameValidity.ts'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
import logger from '../../logger.ts'
export default defineComponent({
@@ -96,6 +96,7 @@ export default defineComponent({
const { directory } = useRouteParameters()
const filesListWidth = useFileListWidth()
const renamingStore = useRenamingStore()
+ const userConfigStore = useUserConfigStore()
const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
@@ -106,6 +107,7 @@ export default defineComponent({
filesListWidth,
renamingStore,
+ userConfigStore,
}
},
@@ -117,11 +119,11 @@ export default defineComponent({
return this.isRenaming && this.filesListWidth < 512
},
newName: {
- get() {
- return this.renamingStore.newName
+ get(): string {
+ return this.renamingStore.newNodeName
},
- set(newName) {
- this.renamingStore.newName = newName
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
},
},
@@ -249,7 +251,9 @@ export default defineComponent({
try {
const status = await this.renamingStore.rename()
if (status) {
- showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
+ showSuccess(
+ t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
+ )
this.$nextTick(() => {
const nameContainer = this.$refs.basename as HTMLElement | undefined
nameContainer?.focus()
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index 2d5844f851f..3d0fffe7584 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -21,6 +21,7 @@
class="files-list__row-icon-blurhash"
aria-hidden="true" />
<img v-if="backgroundFailed !== true"
+ :key="source.fileid"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
@@ -63,7 +64,7 @@ import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
-import NetworkIcon from 'vue-material-design-icons/Network.vue'
+import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
@@ -147,6 +148,17 @@ export default defineComponent({
return null
}
+ if (this.source.attributes['has-preview'] !== true
+ && this.source.mime !== undefined
+ && this.source.mime !== 'application/octet-stream'
+ ) {
+ const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
+ mime: this.source.mime,
+ })
+ const url = new URL(window.location.origin + previewUrl)
+ return url.href
+ }
+
try {
const previewUrl = this.source.attributes.previewUrl
|| (this.isPublic
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
index bf007e2be6c..1bd0572f53b 100644
--- a/apps/files/src/components/FileEntryGrid.vue
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -51,7 +51,9 @@
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@click="openDetailsIfAvailable">
- <NcDateTime v-if="source.mtime" :timestamp="source.mtime" :ignore-seconds="true" />
+ <NcDateTime v-if="mtime"
+ ignore-seconds
+ :timestamp="mtime" />
</td>
<!-- Actions -->
@@ -66,7 +68,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { useNavigation } from '../composables/useNavigation.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index d949a907d43..735490c45b3 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -133,11 +133,16 @@ export default defineComponent({
return this.source.status === NodeStatus.FAILED
},
- canDrag() {
+ canDrag(): boolean {
if (this.isRenaming) {
return false
}
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
const canDrag = (node: Node): boolean => {
return (node?.permissions & Permission.UPDATE) !== 0
}
@@ -150,11 +155,16 @@ export default defineComponent({
return canDrag(this.source)
},
- canDrop() {
+ canDrop(): boolean {
if (this.source.type !== FileType.Folder) {
return false
}
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return false
+ }
+
// If the current folder is also being dragged, we can't drop it on itself
if (this.draggingFiles.includes(this.source.source)) {
return false
@@ -168,25 +178,52 @@ export default defineComponent({
return this.actionsMenuStore.opened === this.uniqueId.toString()
},
set(opened) {
- this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null
+ // If the menu is opened on another file entry, we ignore closed events
+ if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) {
+ return
+ }
+
+ // If opened, we specify the current file id
+ // else we set it to null to close the menu
+ this.actionsMenuStore.opened = opened
+ ? this.uniqueId.toString()
+ : null
},
},
- mtimeOpacity() {
- const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
+ mtime() {
+ // If the mtime is not a valid date, return it as is
+ if (this.source.mtime && !isNaN(this.source.mtime.getDate())) {
+ return this.source.mtime
+ }
+
+ if (this.source.crtime && !isNaN(this.source.crtime.getDate())) {
+ return this.source.crtime
+ }
+
+ return null
+ },
- const mtime = this.source.mtime?.getTime?.()
- if (!mtime) {
+ mtimeOpacity() {
+ if (!this.mtime) {
return {}
}
- // 1 = today, 0 = 31 days ago
- const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime))
- if (ratio < 0) {
+ // The time when we start reducing the opacity
+ const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
+ // everything older than the maxOpacityTime will have the same value
+ const timeDiff = Date.now() - this.mtime.getTime()
+ if (timeDiff < 0) {
+ // this means we have an invalid mtime which is in the future!
return {}
}
+
+ // inversed time difference from 0 to maxOpacityTime (which would mean today)
+ const opacityTime = Math.max(0, maxOpacityTime - timeDiff)
+ // 100 = today, 0 = 31 days ago or older
+ const percentage = Math.round(opacityTime * 100 / maxOpacityTime)
return {
- color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`,
+ color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`,
}
},
@@ -235,21 +272,16 @@ export default defineComponent({
},
openedMenu() {
- if (this.openedMenu === false) {
- // TODO: This timeout can be removed once `close` event only triggers after the transition
- // ref: https://github.com/nextcloud-libraries/nextcloud-vue/pull/6065
- window.setTimeout(() => {
- if (this.openedMenu) {
- // was reopened while the animation run
- return
- }
- // Reset any right menu position potentially set
- const root = document.getElementById('app-content-vue')
- if (root !== null) {
- root.style.removeProperty('--mouse-pos-x')
- root.style.removeProperty('--mouse-pos-y')
- }
- }, 300)
+ // Checking if the menu is really closed and not
+ // just a change in the open state to another file entry.
+ if (this.actionsMenuStore.opened === null) {
+ // Reset any right menu position potentially set
+ logger.debug('All actions menu closed, resetting right menu position...')
+ const root = this.$el?.closest('main.app-content') as HTMLElement
+ if (root !== null) {
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
}
},
},
@@ -274,6 +306,11 @@ export default defineComponent({
return
}
+ // Ignore right click if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
// The grid mode is compact enough to not care about
// the actions menu mouse position
if (!this.gridMode) {
@@ -282,6 +319,7 @@ export default defineComponent({
const contentRect = root.getBoundingClientRect()
// Using Math.min/max to prevent the menu from going out of the AppContent
// 200 = max width of the menu
+ logger.debug('Setting actions menu position...')
root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px')
root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px')
} else {
@@ -311,9 +349,14 @@ export default defineComponent({
return
}
+ // Ignore if the node is not available
+ if (this.isFailedSource) {
+ return
+ }
+
// if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab
// also if there is no default action use this as a fallback
- const metaKeyPressed = event.ctrlKey || event.metaKey || Boolean(event.button & 4)
+ const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1
if (metaKeyPressed || !this.defaultFileAction) {
// If no download permission, then we can not allow to download (direct link) the files
if (isPublicShare() && !isDownloadable(this.source)) {
@@ -325,7 +368,9 @@ export default defineComponent({
: generateUrl('/f/{fileId}', { fileId: this.fileid })
event.preventDefault()
event.stopPropagation()
- window.open(url, metaKeyPressed ? '_self' : undefined)
+
+ // Open the file in a new tab if the meta key or the middle mouse button is clicked
+ window.open(url, metaKeyPressed ? '_blank' : '_self')
return
}
@@ -442,7 +487,7 @@ export default defineComponent({
logger.debug('Dropped', { event, folder, selection, fileTree })
// Check whether we're uploading files
- if (fileTree.contents.length > 0) {
+ if (selection.length === 0 && fileTree.contents.length > 0) {
await onDropExternalFiles(fileTree, folder, contents.contents)
return
}
diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue
index 447ae7abdaa..bd3ac867ed5 100644
--- a/apps/files/src/components/FileListFilter/FileListFilter.vue
+++ b/apps/files/src/components/FileListFilter/FileListFilter.vue
@@ -24,9 +24,9 @@
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
defineProps<{
isActive: boolean
diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
index a69e1782c2d..3a843b2bc3e 100644
--- a/apps/files/src/components/FileListFilter/FileListFilterModified.vue
+++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
@@ -7,7 +7,7 @@
:filter-name="t('files', 'Modified')"
@reset-filter="resetFilter">
<template #icon>
- <NcIconSvgWrapper :path="mdiCalendarRange" />
+ <NcIconSvgWrapper :path="mdiCalendarRangeOutline" />
</template>
<NcActionButton v-for="preset of timePresets"
:key="preset.id"
@@ -25,12 +25,12 @@
import type { PropType } from 'vue'
import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
-import { mdiCalendarRange } from '@mdi/js'
+import { mdiCalendarRangeOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FileListFilter from './FileListFilter.vue'
export default defineComponent({
@@ -50,7 +50,7 @@ export default defineComponent({
setup() {
return {
// icons used in template
- mdiCalendarRange,
+ mdiCalendarRangeOutline,
}
},
diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
new file mode 100644
index 00000000000..938be171f6d
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
@@ -0,0 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcButton v-show="isVisible" @click="onClick">
+ {{ t('files', 'Search everywhere') }}
+ </NcButton>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import { getPinia } from '../../store/index.ts'
+import { useSearchStore } from '../../store/search.ts'
+
+const isVisible = ref(false)
+
+defineExpose({
+ hideButton,
+ showButton,
+})
+
+/**
+ * Hide the button - called by the filter class
+ */
+function hideButton() {
+ isVisible.value = false
+}
+
+/**
+ * Show the button - called by the filter class
+ */
+function showButton() {
+ isVisible.value = true
+}
+
+/**
+ * Button click handler to make the filtering a global search.
+ */
+function onClick() {
+ const searchStore = useSearchStore(getPinia())
+ searchStore.scope = 'globally'
+}
+</script>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue
index c71ec94f662..d3ad791513f 100644
--- a/apps/files/src/components/FileListFilter/FileListFilterType.vue
+++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue
@@ -8,7 +8,7 @@
:filter-name="t('files', 'Type')"
@reset-filter="resetFilter">
<template #icon>
- <NcIconSvgWrapper :path="mdiFile" />
+ <NcIconSvgWrapper :path="mdiFileOutline" />
</template>
<NcActionButton v-for="fileType of typePresets"
:key="fileType.id"
@@ -27,12 +27,12 @@
import type { PropType } from 'vue'
import type { ITypePreset } from '../../filters/TypeFilter.ts'
-import { mdiFile } from '@mdi/js'
+import { mdiFileOutline } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import FileListFilter from './FileListFilter.vue'
export default defineComponent({
@@ -57,7 +57,7 @@ export default defineComponent({
setup() {
return {
- mdiFile,
+ mdiFileOutline,
t,
}
},
diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue
index 1bd1dd627af..7f0d71fd85a 100644
--- a/apps/files/src/components/FileListFilters.vue
+++ b/apps/files/src/components/FileListFilters.vue
@@ -32,8 +32,8 @@ import { t } from '@nextcloud/l10n'
import { computed, ref, watchEffect } from 'vue'
import { useFiltersStore } from '../store/filters.ts'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcChip from '@nextcloud/vue/components/NcChip'
const filterStore = useFiltersStore()
const visualFilters = computed(() => filterStore.filtersWithUI)
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
index 96d465a23d2..31458398028 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -9,6 +9,13 @@
</template>
<script lang="ts">
+import type { Folder, Header, View } from '@nextcloud/files'
+import type { PropType } from 'vue'
+
+import PQueue from 'p-queue'
+
+import logger from '../logger.ts'
+
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
@@ -19,21 +26,29 @@ export default {
name: 'FilesListHeader',
props: {
header: {
- type: Object,
+ type: Object as PropType<Header>,
required: true,
},
currentFolder: {
- type: Object,
+ type: Object as PropType<Folder>,
required: true,
},
currentView: {
- type: Object,
+ type: Object as PropType<View>,
required: true,
},
},
+ setup() {
+ // Create a queue to ensure that the header is only rendered once at a time
+ const queue = new PQueue({ concurrency: 1 })
+
+ return {
+ queue,
+ }
+ },
computed: {
enabled() {
- return this.header.enabled(this.currentFolder, this.currentView)
+ return this.header.enabled?.(this.currentFolder, this.currentView) ?? true
},
},
watch: {
@@ -41,15 +56,45 @@ export default {
if (!enabled) {
return
}
- this.header.updated(this.currentFolder, this.currentView)
+ // If the header is enabled, we need to render it
+ logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header })
+ this.queueUpdate(this.currentFolder, this.currentView)
+ },
+ currentFolder(folder: Folder) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queueUpdate(folder, this.currentView)
},
- currentFolder() {
- this.header.updated(this.currentFolder, this.currentView)
+ currentView(view: View) {
+ this.queueUpdate(this.currentFolder, view)
},
},
+
mounted() {
- console.debug('Mounted', this.header.id)
- this.header.render(this.$refs.mount, this.currentFolder, this.currentView)
+ logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header })
+ const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView)
+ this.queue.add(initialRender).then(() => {
+ logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header })
+ }).catch((error) => {
+ logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
+ destroyed() {
+ logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header })
+ },
+
+ methods: {
+ queueUpdate(currentFolder: Folder, currentView: View) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queue.add(() => this.header.updated(currentFolder, currentView))
+ .then(() => {
+ logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header })
+ })
+ .catch((error) => {
+ logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
},
}
</script>
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
index bf545aacf4f..9e8cdc159ee 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -21,6 +21,10 @@
<!-- Actions -->
<td class="files-list__row-actions" />
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime" />
+
<!-- Size -->
<td v-if="isSizeAvailable"
class="files-list__column files-list__row-size">
@@ -60,6 +64,10 @@ export default defineComponent({
type: View,
required: true,
},
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -141,7 +149,7 @@ export default defineComponent({
<style scoped lang="scss">
// Scoped row
tr {
- margin-bottom: max(25vh, var(--body-container-margin));
+ margin-bottom: var(--body-container-margin);
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
index b7818a9a40f..23e631199eb 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -24,6 +24,14 @@
<!-- Actions -->
<th class="files-list__row-actions" />
+ <!-- Mime -->
+ <th v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime"
+ :class="{ 'files-list__column--sortable': isMimeAvailable }"
+ :aria-sort="ariaSortForMode('mime')">
+ <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" />
+ </th>
+
<!-- Size -->
<th v-if="isSizeAvailable"
class="files-list__column files-list__row-size"
@@ -58,10 +66,10 @@ import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
-import { defineComponent } from 'vue'
import { translate as t } from '@nextcloud/l10n'
-import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { useFilesStore } from '../store/files.ts'
import { useNavigation } from '../composables/useNavigation'
@@ -83,6 +91,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
@@ -171,7 +183,7 @@ export default defineComponent({
},
methods: {
- ariaSortForMode(mode: string): ARIAMixin['ariaSort'] {
+ ariaSortForMode(mode: string): 'ascending'|'descending'|null {
if (this.sortingMode === mode) {
return this.isAscSorting ? 'ascending' : 'descending'
}
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index 16d99f974dd..53b7e7ef21b 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -6,16 +6,26 @@
<div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions>
<NcActions ref="actionsMenu"
container="#app-content-vue"
+ :boundaries-element="boundariesElement"
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
- :inline="inlineActions"
- :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
- :open.sync="openedMenu">
- <NcActionButton v-for="action in enabledActions"
+ :inline="enabledInlineActions.length"
+ :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
+ :open.sync="openedMenu"
+ @close="openedSubmenu = null">
+ <!-- Default actions list-->
+ <NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
- :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
- :class="'files-list__row-actions-batch-' + action.id"
+ :ref="`action-batch-${action.id}`"
+ :class="{
+ [`files-list__row-actions-batch-${action.id}`]: true,
+ [`files-list__row-actions-batch--menu`]: isValidMenu(action)
+ }"
+ :close-after-click="!isValidMenu(action)"
:data-cy-files-list-selection-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
@@ -23,30 +33,61 @@
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
+
+ <!-- Submenu actions list-->
+ <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
+ <!-- Back to top-level button -->
+ <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
+ <template #icon>
+ <ArrowLeftIcon />
+ </template>
+ {{ t('files', 'Back') }}
+ </NcActionButton>
+ <NcActionSeparator />
+
+ <!-- Submenu actions -->
+ <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
+ :key="action.id"
+ :class="`files-list__row-actions-batch-${action.id}`"
+ class="files-list__row-actions-batch--submenu"
+ close-after-click
+ :data-cy-files-list-selection-action="action.id"
+ :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
+ :title="action.title?.(nodes, currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
+ </template>
+ {{ action.displayName(nodes, currentView) }}
+ </NcActionButton>
+ </template>
</NcActions>
</div>
</template>
<script lang="ts">
-import type { Node, View } from '@nextcloud/files'
+import type { FileAction, Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types'
-import { NodeStatus, getFileActions } from '@nextcloud/files'
+import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import { defineComponent } 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 ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
+import actionsMixins from '../mixins/actionsMixin.ts'
import logger from '../logger.ts'
// The registered actions list
@@ -56,12 +97,15 @@ export default defineComponent({
name: 'FilesListTableHeaderActions',
components: {
+ ArrowLeftIcon,
NcActions,
NcActionButton,
NcIconSvgWrapper,
NcLoadingIcon,
},
+ mixins: [actionsMixins],
+
props: {
currentView: {
type: Object as PropType<View>,
@@ -80,6 +124,8 @@ export default defineComponent({
const fileListWidth = useFileListWidth()
const { directory } = useRouteParameters()
+ const boundariesElement = document.getElementById('app-content-vue')
+
return {
directory,
fileListWidth,
@@ -87,6 +133,8 @@ export default defineComponent({
actionsMenuStore,
filesStore,
selectionStore,
+
+ boundariesElement,
}
},
@@ -97,13 +145,78 @@ export default defineComponent({
},
computed: {
- enabledActions() {
+ enabledFileActions(): FileAction[] {
return actions
- .filter(action => action.execBatch)
+ // We don't handle renderInline actions in this component
+ .filter(action => !action.renderInline)
+ // We don't handle actions that are not visible
+ .filter(action => action.default !== DefaultType.HIDDEN)
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
+ /**
+ * Return the list of enabled actions that are
+ * allowed to be rendered inlined.
+ * This means that they are not within a menu, nor
+ * being the parent of submenu actions.
+ */
+ enabledInlineActions(): FileAction[] {
+ return this.enabledFileActions
+ // Remove all actions that are not top-level actions
+ .filter(action => action.parent === undefined)
+ // Remove all actions that are not batch actions
+ .filter(action => action.execBatch !== undefined)
+ // Remove all top-menu entries
+ .filter(action => !this.isValidMenu(action))
+ // Return a maximum actions to fit the screen
+ .slice(0, this.inlineActions)
+ },
+
+ /**
+ * Return the rest of enabled actions that are not
+ * rendered inlined.
+ */
+ enabledMenuActions(): FileAction[] {
+ // If we're in a submenu, only render the inline
+ // actions before the filtered submenu
+ if (this.openedSubmenu) {
+ return this.enabledInlineActions
+ }
+
+ // We filter duplicates to prevent inline actions to be shown twice
+ const actions = this.enabledFileActions.filter((value, index, self) => {
+ return index === self.findIndex(action => action.id === value.id)
+ })
+
+ // Generate list of all top-level actions ids
+ const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[]
+
+ const menuActions = actions
+ .filter(action => {
+ // If the action is not a batch action, we need
+ // to make sure it's a top-level parent entry
+ // and that we have some children actions bound to it
+ if (!action.execBatch) {
+ return childrenActionsIds.includes(action.id)
+ }
+
+ // Rendering second-level actions is done in the template
+ // when openedSubmenu is set.
+ if (action.parent) {
+ return false
+ }
+
+ return true
+ })
+ .filter(action => !this.enabledInlineActions.includes(action))
+
+ // Make sure we render the inline actions first
+ // and then the rest of the actions.
+ // We do NOT want nested actions to be rendered inlined
+ return [...this.enabledInlineActions, ...menuActions]
+ },
+
nodes() {
return this.selectedNodes
.map(source => this.getNode(source))
@@ -148,6 +261,12 @@ export default defineComponent({
},
async onActionClick(action) {
+ // If the action is a submenu, we open it
+ if (this.enabledSubmenuActions[action.id]) {
+ this.openedSubmenu = action
+ return
+ }
+
let displayName = action.id
try {
displayName = action.displayName(this.nodes, this.currentView)
@@ -186,7 +305,7 @@ export default defineComponent({
return
}
- showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
+ showError(this.t('files', '"{displayName}" failed on some elements', { displayName }))
return
}
diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue
index b2fa411e318..d2e14a5495f 100644
--- a/apps/files/src/components/FilesListTableHeaderButton.vue
+++ b/apps/files/src/components/FilesListTableHeaderButton.vue
@@ -25,7 +25,7 @@ import { defineComponent } from 'vue'
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
import MenuUp from 'vue-material-design-icons/MenuUp.vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import filesSortingMixin from '../mixins/filesSorting.ts'
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 2442dd98190..47b8ef19b19 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -9,6 +9,7 @@
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
+ isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
@@ -20,14 +21,16 @@
</template>
<template v-if="!isNoneSelected" #header-overlay>
- <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span>
+ <span class="files-list__selected">
+ {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
+ </span>
<FilesListTableHeaderActions :current-view="currentView"
:selected-nodes="selectedNodes" />
</template>
<template #before>
<!-- Headers -->
- <FilesListHeader v-for="header in sortedHeaders"
+ <FilesListHeader v-for="header in headers"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
@@ -39,15 +42,22 @@
<!-- Table header and sort buttons -->
<FilesListTableHeader ref="thead"
:files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter :current-view="currentView"
:files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
@@ -57,24 +67,25 @@
</template>
<script lang="ts">
-import type { ComponentPublicInstance, PropType } from 'vue'
-import type { Node as NcNode } from '@nextcloud/files'
import type { UserConfig } from '../types'
+import type { Node as NcNode } from '@nextcloud/files'
+import type { ComponentPublicInstance, PropType } from 'vue'
-import { defineComponent } from 'vue'
-import { getFileListHeaders, Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
+import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { translate as t } from '@nextcloud/l10n'
-import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'
+import { n, t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getSummaryFor } from '../utils/fileUtils'
import { useActiveStore } from '../store/active.ts'
+import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useSelectionStore } from '../store/selection.js'
import { useUserConfigStore } from '../store/userconfig.ts'
+import logger from '../logger.ts'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
@@ -83,7 +94,6 @@ import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
-import logger from '../logger.ts'
import VirtualList from './VirtualList.vue'
export default defineComponent({
@@ -111,6 +121,10 @@ export default defineComponent({
type: Array as PropType<NcNode[]>,
required: true,
},
+ summary: {
+ type: String,
+ required: true,
+ },
},
setup() {
@@ -124,6 +138,7 @@ export default defineComponent({
return {
fileId,
fileListWidth,
+ headers: useFileListHeaders(),
openDetails,
openFile,
@@ -131,6 +146,7 @@ export default defineComponent({
selectionStore,
userConfigStore,
+ n,
t,
}
},
@@ -139,9 +155,7 @@ export default defineComponent({
return {
FileEntry,
FileEntryGrid,
- headers: getFileListHeaders(),
scrollToIndex: 0,
- openFileId: null as number|null,
}
},
@@ -150,10 +164,16 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
- summary() {
- return getSummaryFor(this.nodes)
+ isMimeAvailable() {
+ if (!this.userConfig.show_mime_column) {
+ return false
+ }
+ // Hide mime column on narrow screens
+ if (this.fileListWidth < 1024) {
+ return false
+ }
+ return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream')
},
-
isMtimeAvailable() {
// Hide mtime column on narrow screens
if (this.fileListWidth < 768) {
@@ -169,14 +189,6 @@ export default defineComponent({
return this.nodes.some(node => node.size !== undefined)
},
- sortedHeaders() {
- if (!this.currentFolder || !this.currentView) {
- return []
- }
-
- return [...this.headers].sort((a, b) => a.order - b.order)
- },
-
cantUpload() {
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
},
@@ -188,7 +200,7 @@ export default defineComponent({
caption() {
const defaultCaption = t('files', 'List of files and folders.')
const viewCaption = this.currentView.caption || defaultCaption
- const cantUploadCaption = this.cantUpload ? t('files', 'You don’t have permission to upload or create files here.') : null
+ const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null
const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null
const sortableCaption = t('files', 'Column headers with buttons are sortable.')
const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.')
@@ -208,38 +220,26 @@ export default defineComponent({
isNoneSelected() {
return this.selectedNodes.length === 0
},
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
},
watch: {
- fileId: {
- handler(fileId) {
- this.scrollToFile(fileId, false)
- },
- immediate: true,
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
},
-
- openFile: {
- handler() {
- // wait for scrolling and updating the actions to settle
- this.$nextTick(() => {
- if (this.fileId && this.openFile) {
- this.handleOpenFile(this.fileId)
- }
- })
- },
- immediate: true,
+ fileId() {
+ this.handleOpenQueries()
},
-
- openDetails: {
- handler() {
- // wait for scrolling and updating the actions to settle
- this.$nextTick(() => {
- if (this.fileId && this.openDetails) {
- this.openSidebarForFile(this.fileId)
- }
- })
- },
- immediate: true,
+ openFile() {
+ this.handleOpenQueries()
+ },
+ openDetails() {
+ this.handleOpenQueries()
},
},
@@ -269,6 +269,33 @@ export default defineComponent({
},
methods: {
+ handleOpenQueries() {
+ // If the list is empty, or we don't have a fileId,
+ // there's nothing to be done.
+ if (this.isEmpty || !this.fileId) {
+ return
+ }
+
+ logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
+ nodes: this.nodes,
+ fileId: this.fileId,
+ openFile: this.openFile,
+ openDetails: this.openDetails,
+ })
+
+ if (this.openFile) {
+ this.handleOpenFile(this.fileId)
+ }
+
+ if (this.openDetails) {
+ this.openSidebarForFile(this.fileId)
+ }
+
+ if (this.fileId) {
+ this.scrollToFile(this.fileId, false)
+ }
+ },
+
openSidebarForFile(fileId) {
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
@@ -276,7 +303,9 @@ export default defineComponent({
if (node && sidebarAction?.enabled?.([node], this.currentView)) {
logger.debug('Opening sidebar on file ' + node.path, { node })
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
+ return
}
+ logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
},
scrollToFile(fileId: number|null, warn = true) {
@@ -292,6 +321,7 @@ export default defineComponent({
}
this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
@@ -303,7 +333,7 @@ export default defineComponent({
delete query.openfile
delete query.opendetails
- this.activeStore.clearActiveNode()
+ this.activeStore.activeNode = undefined
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
@@ -329,30 +359,40 @@ export default defineComponent({
* Handle opening a file (e.g. by ?openfile=true)
* @param fileId File to open
*/
- handleOpenFile(fileId: number|null) {
- if (fileId === null) {
- return
- }
-
+ async handleOpenFile(fileId: number) {
const node = this.nodes.find(n => n.fileid === fileId) as NcNode
- if (node === undefined || node.type === FileType.Folder) {
+ if (node === undefined) {
return
}
- logger.debug('Opening file ' + node.path, { node })
- this.openFileId = fileId
- const defaultAction = getFileActions()
- // Get only default actions (visible and hidden)
- .filter(action => !!action?.default)
- // Find actions that are either always enabled or enabled for the current node
- .filter((action) => !action.enabled || action.enabled([node], this.currentView))
- // Sort enabled default actions by order
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- // Get the first one
- .at(0)
- // Some file types do not have a default action (e.g. they can only be downloaded)
- // So if there is an enabled default action, so execute it
- defaultAction?.exec(node, this.currentView, this.currentFolder.path)
+ if (node.type === FileType.File) {
+ const defaultAction = getFileActions()
+ // Get only default actions (visible and hidden)
+ .filter((action) => !!action?.default)
+ // Find actions that are either always enabled or enabled for the current node
+ .filter((action) => !action.enabled || action.enabled([node], this.currentView))
+ .filter((action) => action.id !== 'download')
+ // Sort enabled default actions by order
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ // Get the first one
+ .at(0)
+
+ // Some file types do not have a default action (e.g. they can only be downloaded)
+ // So if there is an enabled default action, so execute it
+ if (defaultAction) {
+ logger.debug('Opening file ' + node.path, { node })
+ return await defaultAction.exec(node, this.currentView, this.currentFolder.path)
+ }
+ }
+ // The file is either a folder or has no default action other than downloading
+ // in this case we need to open the details instead and remove the route from the history
+ logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ { ...this.$route.query, openfile: undefined, opendetails: '' },
+ true, // silent update of the URL
+ )
},
onDragOver(event: DragEvent) {
@@ -425,7 +465,7 @@ export default defineComponent({
delete query.openfile
delete query.opendetails
- this.activeStore.setActiveNode(node)
+ this.activeStore.activeNode = node
// Silent update of the URL
window.OCP.Files.Router.goToRoute(
@@ -441,15 +481,17 @@ export default defineComponent({
<style scoped lang="scss">
.files-list {
- --row-height: 55px;
+ --row-height: 44px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: var(--default-clickable-area);
- --icon-preview-size: 32px;
+ --icon-preview-size: 24px;
--fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
@@ -497,6 +539,13 @@ export default defineComponent({
// Hide the table header below the overlay
margin-block-start: calc(-1 * var(--row-height));
}
+
+ // Visually hide the table when there are no files
+ &--hidden {
+ visibility: hidden;
+ z-index: -1;
+ opacity: 0;
+ }
}
.files-list__filters {
@@ -528,6 +577,7 @@ export default defineComponent({
background-color: var(--color-main-background);
border-block-end: 1px solid var(--color-border);
height: var(--row-height);
+ flex: 0 0 var(--row-height);
}
.files-list__thead,
@@ -536,7 +586,6 @@ export default defineComponent({
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);
-
}
// Table header
@@ -547,6 +596,16 @@ export default defineComponent({
top: var(--fixed-block-start-position);
}
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
tr {
position: relative;
display: flex;
@@ -716,8 +775,8 @@ export default defineComponent({
// File and folder overlay
&-overlay {
position: absolute;
- max-height: calc(var(--icon-preview-size) * 0.5);
- max-width: calc(var(--icon-preview-size) * 0.5);
+ max-height: calc(var(--icon-preview-size) * 0.6);
+ max-width: calc(var(--icon-preview-size) * 0.6);
color: var(--color-primary-element-text);
// better alignment with the folder icon
margin-block-start: 2px;
@@ -822,22 +881,28 @@ export default defineComponent({
margin-inline-end: 7px;
}
+ .files-list__row-mime,
.files-list__row-mtime,
.files-list__row-size {
color: var(--color-text-maxcontrast);
}
+
.files-list__row-size {
- width: calc(var(--row-height) * 1.5);
+ width: calc(var(--row-height) * 2);
// Right align content/text
justify-content: flex-end;
}
.files-list__row-mtime {
- width: calc(var(--row-height) * 2);
+ width: calc(var(--row-height) * 2.5);
+ }
+
+ .files-list__row-mime {
+ width: calc(var(--row-height) * 3.5);
}
.files-list__row-column-custom {
- width: calc(var(--row-height) * 2);
+ width: calc(var(--row-height) * 2.5);
}
}
}
@@ -853,12 +918,11 @@ export default defineComponent({
<style lang="scss">
// Grid mode
-tbody.files-list__tbody.files-list__tbody--grid {
- --half-clickable-area: calc(var(--clickable-area) / 2);
+.files-list--grid tbody.files-list__tbody {
--item-padding: 16px;
--icon-preview-size: 166px;
- --name-height: 32px;
- --mtime-height: 16px;
+ --name-height: var(--default-clickable-area);
+ --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline));
--row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2);
--row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2);
--checkbox-padding: 0px;
@@ -940,15 +1004,32 @@ tbody.files-list__tbody.files-list__tbody--grid {
.files-list__row-mtime {
width: var(--icon-preview-size);
height: var(--mtime-height);
- font-size: calc(var(--default-font-size) - 4px);
+ font-size: var(--font-size-small);
}
.files-list__row-actions {
position: absolute;
- inset-inline-end: calc(var(--half-clickable-area) / 2);
+ inset-inline-end: calc(var(--clickable-area) / 4);
inset-block-end: calc(var(--mtime-height) / 2);
width: var(--clickable-area);
height: var(--clickable-area);
}
}
+
+@media screen and (max-width: 768px) {
+ // there is no mtime
+ .files-list--grid tbody.files-list__tbody {
+ --mtime-height: 0px;
+
+ // so we move the action to the name
+ .files-list__row-actions {
+ inset-block-end: var(--item-padding);
+ }
+
+ // and we need to keep space on the name for the actions
+ .files-list__row-name-text {
+ padding-inline-end: var(--clickable-area) !important;
+ }
+ }
+}
</style>
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
index f05ad389f50..c29bc00c67f 100644
--- a/apps/files/src/components/FilesNavigationItem.vue
+++ b/apps/files/src/components/FilesNavigationItem.vue
@@ -42,8 +42,8 @@ import type { View } from '@nextcloud/files'
import { defineComponent } from 'vue'
import { Fragment } from 'vue-frag'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { useNavigation } from '../composables/useNavigation.js'
import { useViewConfigStore } from '../store/viewConfig.js'
@@ -89,7 +89,7 @@ export default defineComponent({
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
.filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
}
- return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
+ return this.filterVisible(this.views[this.parent.id] ?? [])
},
style() {
@@ -103,11 +103,15 @@ export default defineComponent({
},
methods: {
+ filterVisible(views: View[]) {
+ return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true)
+ },
+
hasChildViews(view: View): boolean {
if (this.level >= maxLevel) {
return false
}
- return this.views[view.id]?.length > 0
+ return this.filterVisible(this.views[view.id] ?? []).length > 0
},
/**
diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue
new file mode 100644
index 00000000000..e34d4bf0971
--- /dev/null
+++ b/apps/files/src/components/FilesNavigationSearch.vue
@@ -0,0 +1,86 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import { mdiMagnify, mdiSearchWeb } from '@mdi/js'
+import { t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useSearchStore } from '../store/search.ts'
+import { VIEW_ID } from '../views/search.ts'
+
+const { currentView } = useNavigation(true)
+const searchStore = useSearchStore()
+
+/**
+ * When the route is changed from search view to something different
+ * we need to clear the search box.
+ */
+onBeforeNavigation((to, from, next) => {
+ if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) {
+ // we are leaving the search view so unset the query
+ searchStore.query = ''
+ searchStore.scope = 'filter'
+ } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) {
+ // fix the query if the user refreshed the view
+ if (searchStore.query && !to.query.query) {
+ // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3)
+ return next({
+ ...to,
+ query: {
+ ...to.query,
+ query: searchStore.query,
+ },
+ })
+ }
+ }
+ next()
+})
+
+/**
+ * Are we currently on the search view.
+ * Needed to disable the action menu (we cannot change the search mode there)
+ */
+const isSearchView = computed(() => currentView.value.id === VIEW_ID)
+
+/**
+ * Different searchbox label depending if filtering or searching
+ */
+const searchLabel = computed(() => {
+ if (searchStore.scope === 'globally') {
+ return t('files', 'Search globally by filename …')
+ }
+ return t('files', 'Search here by filename …')
+})
+</script>
+
+<template>
+ <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel">
+ <template #actions>
+ <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView">
+ <template #icon>
+ <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" />
+ </template>
+ <NcActionButton close-after-click @click="searchStore.scope = 'filter'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ {{ t('files', 'Filter and search from this location') }}
+ </NcActionButton>
+ <NcActionButton close-after-click @click="searchStore.scope = 'globally'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiSearchWeb" />
+ </template>
+ {{ t('files', 'Search globally') }}
+ </NcActionButton>
+ </NcActions>
+ </template>
+ </NcAppNavigationSearch>
+</template>
diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue
index d9baeeb1b07..b5a792d9029 100644
--- a/apps/files/src/components/LegacyView.vue
+++ b/apps/files/src/components/LegacyView.vue
@@ -33,7 +33,7 @@ export default {
},
methods: {
setFileInfo(fileInfo) {
- this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
+ this.component.setFileInfo(fileInfo)
},
},
}
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index 389d3f346da..46c8e5c9af4 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -33,9 +33,9 @@ import { subscribe } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
-import ChartPie from 'vue-material-design-icons/ChartPie.vue'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
+import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import logger from '../logger.ts'
@@ -58,7 +58,7 @@ export default {
computed: {
storageStatsTitle() {
const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false)
- const quotaByte = formatFileSize(this.storageStats?.quota, false, false)
+ const quotaByte = formatFileSize(this.storageStats?.total, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
index b766b230022..ca10935940d 100644
--- a/apps/files/src/components/NewNodeDialog.vue
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -26,6 +26,11 @@
:helper-text="validity"
:label="label"
:value.sync="localDefaultName" />
+
+ <!-- Hidden file warning -->
+ <NcNoteCard v-if="isHiddenFileName"
+ type="warning"
+ :text="t('files', 'Files starting with a dot are hidden by default')" />
</form>
</NcDialog>
</template>
@@ -35,12 +40,13 @@ import type { ComponentPublicInstance, PropType } from 'vue'
import { getUniqueName } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { extname } from 'path'
-import { nextTick, onMounted, ref, watch, watchEffect } from 'vue'
+import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import { getFilenameValidity } from '../utils/filenameValidity.ts'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
const props = defineProps({
/**
@@ -89,6 +95,11 @@ const nameInput = ref<ComponentPublicInstance>()
const formElement = ref<HTMLFormElement>()
const validity = ref('')
+const isHiddenFileName = computed(() => {
+ // Check if the name starts with a dot, which indicates a hidden file
+ return localDefaultName.value.trim().startsWith('.')
+})
+
/**
* Focus the filename input field
*/
diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue
index 88dfd27ce5c..d86e5da9d20 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -21,8 +21,8 @@
</template>
<script>
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'SidebarTab',
diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue
index bd3c28d585f..3f1db8dfd58 100644
--- a/apps/files/src/components/TemplateFiller.vue
+++ b/apps/files/src/components/TemplateFiller.vue
@@ -4,19 +4,24 @@
-->
<template>
- <NcModal>
+ <NcModal label-id="template-field-modal__label">
<div class="template-field-modal__content">
<form>
- <h3>{{ t('files', 'Fill template fields') }}</h3>
+ <h3 id="template-field-modal__label">
+ {{ t('files', 'Fill template fields') }}
+ </h3>
<div v-for="field in fields" :key="field.index">
- <component :is="getFieldComponent(field.type)" :field="field" @input="trackInput" />
+ <component :is="getFieldComponent(field.type)"
+ v-if="fieldHasLabel(field)"
+ :field="field"
+ @input="trackInput" />
</div>
</form>
</div>
<div class="template-field-modal__buttons">
- <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields…')" />
+ <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" />
<NcButton aria-label="Submit button"
type="primary"
@click="submit">
@@ -28,8 +33,10 @@
<script>
import { defineComponent } from 'vue'
-import { NcModal, NcButton, NcLoadingIcon } from '@nextcloud/vue'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcModal from '@nextcloud/vue/components/NcModal'
import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue'
import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue'
@@ -80,6 +87,9 @@ export default defineComponent({
return `Template${fieldComponentType}Field`
},
+ fieldHasLabel(field) {
+ return field.name || field.alias
+ },
async submit() {
this.loading = true
diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
index 632944f1bab..18536171bd2 100644
--- a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
+++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue
@@ -16,7 +16,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
-import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
export default defineComponent({
name: 'TemplateCheckboxField',
@@ -40,7 +40,7 @@ export default defineComponent({
computed: {
fieldLabel() {
- const label = this.field.name ?? this.field.alias ?? 'Unknown field'
+ const label = this.field.name || this.field.alias
return label.charAt(0).toUpperCase() + label.slice(1)
},
diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
index 7246b2743d6..f49819f7e7c 100644
--- a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
+++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue
@@ -21,7 +21,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
-import { NcTextField } from '@nextcloud/vue'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
export default defineComponent({
name: 'TemplateRichTextField',
@@ -45,7 +45,7 @@ export default defineComponent({
computed: {
fieldLabel() {
- const label = this.field.name ?? this.field.alias ?? 'Unknown field'
+ const label = this.field.name || this.field.alias
return (label.charAt(0).toUpperCase() + label.slice(1))
},
diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue
index 6b8e0eb77ba..3d668da8144 100644
--- a/apps/files/src/components/TransferOwnershipDialogue.vue
+++ b/apps/files/src/components/TransferOwnershipDialogue.vue
@@ -18,7 +18,7 @@
{{ t('files', 'Change') }}
</NcButton>
</p>
- <p class="new-owner-row">
+ <p class="new-owner">
<label for="targetUser">
<span>{{ t('files', 'New owner') }}</span>
</label>
@@ -27,9 +27,7 @@
:options="formatedUserSuggestions"
:multiple="false"
:loading="loadingUsers"
- label="displayName"
:user-select="true"
- class="middle-align"
@search="findUserDebounced" />
</p>
<p>
@@ -48,9 +46,9 @@ import axios from '@nextcloud/axios'
import debounce from 'debounce'
import { generateOcsUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import Vue from 'vue'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import logger from '../logger.ts'
@@ -90,6 +88,7 @@ export default {
user: user.uid,
displayName: user.displayName,
icon: 'icon-user',
+ subname: user.shareWithDisplayNameUnique,
}
})
},
@@ -156,6 +155,7 @@ export default {
Vue.set(this.userSuggestions, user.value.shareWith, {
uid: user.value.shareWith,
displayName: user.label,
+ shareWithDisplayNameUnique: user.shareWithDisplayNameUnique,
})
})
} catch (error) {
@@ -203,18 +203,15 @@ export default {
</script>
<style scoped lang="scss">
-.middle-align {
- vertical-align: middle;
-}
-
p {
margin-top: 12px;
margin-bottom: 12px;
}
-.new-owner-row {
+.new-owner {
display: flex;
- flex-wrap: wrap;
+ flex-direction: column;
+ max-width: 400px;
label {
display: flex;
@@ -225,11 +222,6 @@ p {
margin-inline-end: 8px;
}
}
-
- .multiselect {
- flex-grow: 1;
- max-width: 280px;
- }
}
.transfer-select-row {
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index b3d1b1002f4..4746fedf863 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -3,13 +3,16 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <div class="files-list" data-cy-files-list>
+ <div class="files-list"
+ :class="{ 'files-list--grid': gridMode }"
+ data-cy-files-list
+ @scroll.passive="onScroll">
<!-- Header -->
<div ref="before" class="files-list__before">
<slot name="before" />
</div>
- <div class="files-list__filters">
+ <div ref="filters" class="files-list__filters">
<slot name="filters" />
</div>
@@ -17,7 +20,18 @@
<slot name="header-overlay" />
</div>
- <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }">
+ <div v-if="dataSources.length === 0"
+ class="files-list__empty">
+ <slot name="empty" />
+ </div>
+
+ <table :aria-hidden="dataSources.length === 0"
+ :inert="dataSources.length === 0"
+ class="files-list__table"
+ :class="{
+ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'],
+ 'files-list__table--hidden': dataSources.length === 0,
+ }">
<!-- Accessibility table caption for screen readers -->
<caption v-if="caption" class="hidden-visually">
{{ caption }}
@@ -31,7 +45,6 @@
<!-- Body -->
<tbody :style="tbodyStyle"
class="files-list__tbody"
- :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="({key, item}, i) in renderedItems"
@@ -42,7 +55,7 @@
</tbody>
<!-- Footer -->
- <tfoot v-show="isReady"
+ <tfoot ref="footer"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@@ -118,6 +131,7 @@ export default defineComponent({
return {
index: this.scrollToIndex,
beforeHeight: 0,
+ footerHeight: 0,
headerHeight: 0,
tableHeight: 0,
resizeObserver: null as ResizeObserver | null,
@@ -143,18 +157,35 @@ export default defineComponent({
itemHeight() {
// Align with css in FilesListVirtual
// 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
- return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
},
+
// Grid mode only
itemWidth() {
// 166px + 16px x 2 (padding left and right)
return 166 + 16 + 16
},
- rowCount() {
- return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
+ /**
+ * The number of rows currently (fully!) visible
+ */
+ visibleRows(): number {
+ return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight)
},
- columnCount() {
+
+ /**
+ * Number of rows that will be rendered.
+ * This includes only visible + buffer rows.
+ */
+ rowCount(): number {
+ return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1
+ },
+
+ /**
+ * Number of columns.
+ * 1 for list view otherwise depending on the file list width.
+ */
+ columnCount(): number {
if (!this.gridMode) {
return 1
}
@@ -217,16 +248,18 @@ export default defineComponent({
* The total number of rows that are available
*/
totalRowCount() {
- return Math.floor(this.dataSources.length / this.columnCount)
+ return Math.ceil(this.dataSources.length / this.columnCount)
},
tbodyStyle() {
- const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
- const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
- const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
+ // The number of (virtual) rows above the currently rendered ones.
+ // start index is aligned so this should always be an integer
+ const rowsAbove = Math.round(this.startIndex / this.columnCount)
+ // The number of (virtual) rows below the currently rendered ones.
+ const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount)
+
return {
- paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
- paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
+ paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`,
minHeight: `${this.totalRowCount * this.itemHeight}px`,
}
},
@@ -238,15 +271,14 @@ export default defineComponent({
totalRowCount() {
if (this.scrollToIndex) {
- this.$nextTick(() => this.scrollTo(this.scrollToIndex))
+ this.scrollTo(this.scrollToIndex)
}
},
columnCount(columnCount, oldColumnCount) {
if (oldColumnCount === 0) {
- // We're initializing, the scroll position
- // is handled on mounted
- console.debug('VirtualList: columnCount is 0, skipping scroll')
+ // We're initializing, the scroll position is handled on mounted
+ logger.debug('VirtualList: columnCount is 0, skipping scroll')
return
}
// If the column count changes in grid view,
@@ -256,30 +288,28 @@ export default defineComponent({
},
mounted() {
- const before = this.$refs?.before as HTMLElement
- const root = this.$el as HTMLElement
- const thead = this.$refs?.thead as HTMLElement
+ this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
this.resizeObserver = new ResizeObserver(debounce(() => {
- this.beforeHeight = before?.clientHeight ?? 0
- this.headerHeight = thead?.clientHeight ?? 0
- this.tableHeight = root?.clientHeight ?? 0
+ this.updateHeightVariables()
logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
- }, 100, { immediate: false }))
-
- this.resizeObserver.observe(before)
- this.resizeObserver.observe(root)
- this.resizeObserver.observe(thead)
-
- if (this.scrollToIndex) {
- this.scrollTo(this.scrollToIndex)
- }
-
- // Adding scroll listener AFTER the initial scroll to index
- this.$el.addEventListener('scroll', this.onScroll, { passive: true })
-
- this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]>
+ }, 100))
+ this.resizeObserver.observe(this.$el)
+ this.resizeObserver.observe(this.$refs.before as HTMLElement)
+ this.resizeObserver.observe(this.$refs.filters as HTMLElement)
+ this.resizeObserver.observe(this.$refs.footer as HTMLElement)
+
+ this.$nextTick(() => {
+ // Make sure height values are initialized
+ this.updateHeightVariables()
+ // If we need to scroll to an index we do so in the next tick.
+ // This is needed to apply updates from the initialization of the height variables
+ // which will update the tbody styles until next tick.
+ if (this.scrollToIndex) {
+ this.scrollTo(this.scrollToIndex)
+ }
+ })
},
beforeDestroy() {
@@ -290,21 +320,60 @@ export default defineComponent({
methods: {
scrollTo(index: number) {
- if (!this.$el) {
+ if (!this.$el || this.index === index) {
return
}
- // Check if the content is smaller than the viewport, meaning no scrollbar
- const targetRow = Math.ceil(this.dataSources.length / this.columnCount)
- if (targetRow < this.rowCount) {
- logger.debug('VirtualList: Skip scrolling, nothing to scroll', { index, targetRow, rowCount: this.rowCount })
+ // Check if the content is smaller (not equal! keep the footer in mind) than the viewport
+ // meaning there is no scrollbar
+ if (this.totalRowCount < this.visibleRows) {
+ logger.debug('VirtualList: Skip scrolling, nothing to scroll', {
+ index,
+ totalRows: this.totalRowCount,
+ visibleRows: this.visibleRows,
+ })
return
}
- // Scroll to one row and a half before the index
- const scrollTop = this.indexToScrollPos(index)
- logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount, beforeHeight: this.beforeHeight })
- this.$el.scrollTop = scrollTop
+ // We can not scroll further as the last page of rows
+ // For the grid view we also need to account for all columns in that row (columnCount - 1)
+ const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1)
+ // The scroll position
+ let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex))
+
+ // First we need to update the internal index for rendering.
+ // This will cause the <tbody> element to be resized allowing us to set the correct scroll position.
+ this.index = index
+
+ // If this is not the first row we can add a half row from above.
+ // This is to help users understand the table is scrolled and not items did not just disappear.
+ // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area)
+ if (index >= this.columnCount && index <= clampedIndex) {
+ scrollTop -= (this.itemHeight / 2)
+ // As we render one half row more we also need to adjust the internal index
+ this.index = index - this.columnCount
+ } else if (index > clampedIndex) {
+ // If we are on the last page we cannot scroll any further
+ // but we can at least scroll the footer into view
+ if (index <= (clampedIndex + this.columnCount)) {
+ // We only show have of the footer for the first of the last page
+ // To still show the previous row partly. Same reasoning as above:
+ // help the user understand that the table is scrolled not "magically trimmed"
+ scrollTop += this.footerHeight / 2
+ } else {
+ // We reached the very end of the files list and we are focussing not the first visible row
+ // so all we now can do is scroll to the end (footer)
+ scrollTop += this.footerHeight
+ }
+ }
+
+ // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position
+ this.$nextTick(() => {
+ this.$el.scrollTop = scrollTop
+ logger.debug(`VirtualList: scrolling to index ${index}`, {
+ clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight,
+ })
+ })
},
onScroll() {
@@ -333,7 +402,22 @@ export default defineComponent({
// Convert index to scroll position
// It should be the opposite of `scrollPosToIndex`
indexToScrollPos(index: number): number {
- return (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
+ return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight
+ },
+
+ /**
+ * Update the height variables.
+ * To be called by resize observer and `onMount`
+ */
+ updateHeightVariables(): void {
+ this.tableHeight = this.$el?.clientHeight ?? 0
+ this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0
+ this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0
+
+ // Get the header height which consists of table header and filters
+ const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0
+ const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0
+ this.headerHeight = theadHeight + filterHeight
},
},
})