aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components/FileEntry
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components/FileEntry')
-rw-r--r--apps/files/src/components/FileEntry/CollectivesIcon.vue4
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue29
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue344
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue98
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue282
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue148
6 files changed, 472 insertions, 433 deletions
diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue
index 4316183e3ff..e22b30f4378 100644
--- a/apps/files/src/components/FileEntry/CollectivesIcon.vue
+++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
<span :aria-hidden="!title"
:aria-label="title"
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index d5b73cc22a6..c66cb8fbd7f 100644
--- a/apps/files/src/components/FileEntry/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" />
</template>
@@ -28,7 +11,7 @@ import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import StarSvg from '@mdi/svg/svg/star.svg?raw'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
/**
* A favorite icon to be used for overlaying favorite entries like the file preview / icon
@@ -73,8 +56,8 @@ export default defineComponent({
:deep() {
svg {
// We added a stroke for a11y so we must increase the size to include the stroke
- width: 26px !important;
- height: 26px !important;
+ width: 20px !important;
+ height: 20px !important;
// Override NcIconSvgWrapper defaults of 20px
max-width: unset !important;
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index 6e5d78518f7..5c537d878fe 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<td class="files-list__row-actions"
data-cy-files-list-row-actions>
@@ -39,36 +22,72 @@
type="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
- :open.sync="openedMenu"
- @close="openedSubmenu = null">
- <!-- Default actions list-->
- <NcActionButton v-for="action in enabledMenuActions"
+ :open="openedMenu"
+ @close="onMenuClose"
+ @closed="onMenuClosed">
+ <!-- Non-destructive actions list -->
+ <!-- Please keep this block in sync with the destructive actions block below -->
+ <NcActionButton v-for="action, index in renderedNonDestructiveActions"
:key="action.id"
:ref="`action-${action.id}`"
+ class="files-list__row-action"
:class="{
[`files-list__row-action-${action.id}`]: true,
- [`files-list__row-action--menu`]: isMenu(action.id)
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
}"
- :close-after-click="!isMenu(action.id)"
+ :close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
- :is-menu="isMenu(action.id)"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
- <NcLoadingIcon v-if="loading === action.id" :size="18" />
- <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
</template>
- {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }}
+ {{ actionDisplayName(action) }}
</NcActionButton>
+ <!-- Destructive actions list -->
+ <template v-if="renderedDestructiveActions.length > 0">
+ <NcActionSeparator />
+ <NcActionButton v-for="action, index in renderedDestructiveActions"
+ :key="action.id"
+ :ref="`action-${action.id}`"
+ class="files-list__row-action"
+ :class="{
+ [`files-list__row-action-${action.id}`]: true,
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ 'files-list__row-action--destructive': true,
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
- <NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
+ <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
- {{ actionDisplayName(openedSubmenu) }}
+ {{ t('files', 'Back') }}
</NcActionButton>
<NcActionSeparator />
@@ -79,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="loading === action.id" :size="18" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@@ -94,24 +114,28 @@
<script lang="ts">
import type { PropType } from 'vue'
+import type { FileAction, Node } from '@nextcloud/files'
-import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
+import { DefaultType, NodeStatus } from '@nextcloud/files'
+import { defineComponent, inject } from 'vue'
+import { t } from '@nextcloud/l10n'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
-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 ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
-import Vue, { defineComponent } from 'vue'
-
import CustomElementRender from '../CustomElementRender.vue'
-import logger from '../../logger.js'
-
-// The registered actions list
-const actions = getFileActions()
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { executeAction } from '../../utils/actionUtils.ts'
+import { useActiveStore } from '../../store/active.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import actionsMixins from '../../mixins/actionsMixin.ts'
+import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryActions',
@@ -126,15 +150,9 @@ export default defineComponent({
NcLoadingIcon,
},
+ mixins: [actionsMixins],
+
props: {
- filesListWidth: {
- type: Number,
- required: true,
- },
- loading: {
- type: String,
- required: true,
- },
opened: {
type: Boolean,
default: false,
@@ -149,41 +167,46 @@ export default defineComponent({
},
},
- data() {
+ setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory: currentDir } = useRouteParameters()
+
+ const activeStore = useActiveStore()
+ const filesListWidth = useFileListWidth()
+ const enabledFileActions = inject<FileAction[]>('enabledFileActions', [])
return {
- openedSubmenu: null as FileAction | null,
+ activeStore,
+ currentDir,
+ currentView,
+ enabledFileActions,
+ filesListWidth,
+ t,
}
},
computed: {
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentView(): View {
- return this.$navigation.active as View
+ isActive() {
+ return this.activeStore?.activeNode?.source === this.source.source
},
+
isLoading() {
return this.source.status === NodeStatus.LOADING
},
- // Sorted actions that are enabled for this node
- enabledActions() {
- if (this.source.attributes.failed) {
- return []
- }
-
- return actions
- .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
- .sort((a, b) => (a.order || 0) - (b.order || 0))
- },
-
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
- return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ return this.enabledFileActions.filter(action => {
+ try {
+ return action?.inline?.(this.source, this.currentView)
+ } catch (error) {
+ logger.error('Error while checking if action is inline', { action, error })
+ return false
+ }
+ })
},
// Enabled action that are displayed inline with a custom render function
@@ -191,12 +214,7 @@ export default defineComponent({
if (this.gridMode) {
return []
}
- return this.enabledActions.filter(action => typeof action.renderInline === 'function')
- },
-
- // Default actions
- enabledDefaultActions() {
- return this.enabledActions.filter(action => !!action?.default)
+ return this.enabledFileActions.filter(action => typeof action.renderInline === 'function')
},
// Actions shown in the menu
@@ -211,7 +229,7 @@ export default defineComponent({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
- ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
+ ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
@@ -224,16 +242,12 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
- enabledSubmenuActions() {
- return this.enabledActions
- .filter(action => action.parent)
- .reduce((arr, action) => {
- if (!arr[action.parent]) {
- arr[action.parent] = []
- }
- arr[action.parent].push(action)
- return arr
- }, {} as Record<string, FileAction>)
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
},
openedMenu: {
@@ -253,96 +267,91 @@ 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 state changes
+ openedMenu() {
+ this.openedSubmenu = null
},
},
+ created() {
+ useHotKey('Escape', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+
+ useHotKey('a', this.onKeyDown, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
methods: {
actionDisplayName(action: FileAction) {
- if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
- // if an inline action is rendered in the menu for
- // lack of space we use the title first if defined
- const title = action.title([this.source], this.currentView)
- if (title) return title
+ try {
+ if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
+ // if an inline action is rendered in the menu for
+ // lack of space we use the title first if defined
+ const title = action.title([this.source], this.currentView)
+ if (title) return title
+ }
+ return action.displayName([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ // Not ideal, but better than nothing
+ return action.id
}
- return action.displayName([this.source], this.currentView)
},
- async onActionClick(action, isSubmenu = false) {
- // Skip click on loading
- if (this.isLoading || this.loading !== '') {
- return
+ isLoadingAction(action: FileAction) {
+ if (!this.isActive) {
+ return false
}
+ return this.activeStore?.activeAction?.id === action.id
+ },
+ async onActionClick(action) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
return
}
- const displayName = action.displayName([this.source], this.currentView)
- try {
- // Set the loading marker
- this.$emit('update:loading', action.id)
- Vue.set(this.source, 'status', NodeStatus.LOADING)
-
- const success = await action.exec(this.source, this.currentView, this.currentDir)
+ // Make sure we set the node as active
+ this.activeStore.activeNode = this.source
- // If the action returns null, we stay silent
- if (success === null || success === undefined) {
- return
- }
+ // Execute the action
+ await executeAction(action)
+ },
- if (success) {
- showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
- return
- }
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } catch (e) {
- logger.error('Error while executing action', { action, e })
- showError(t('files', '"{displayName}" action failed', { displayName }))
- } finally {
- // Reset the loading marker
- this.$emit('update:loading', '')
- Vue.set(this.source, 'status', undefined)
-
- // If that was a submenu, we just go back after the action
- if (isSubmenu) {
- this.openedSubmenu = null
- }
+ onKeyDown(event: KeyboardEvent) {
+ // Don't react to the event if the file row is not active
+ if (!this.isActive) {
+ return
}
- },
- execDefaultAction(event) {
- if (this.enabledDefaultActions.length > 0) {
- event.preventDefault()
- event.stopPropagation()
- // Execute the first default action if any
- this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
+
+ // ESC close the action menu if opened
+ if (event.key === 'Escape' && this.openedMenu) {
+ this.openedMenu = false
}
- },
- isMenu(id: string) {
- return this.enabledSubmenuActions[id]?.length > 0
+ // a open the action menu
+ if (event.key === 'a' && !this.openedMenu) {
+ this.openedMenu = true
+ }
},
- async onBackToMenuClick(action: FileAction) {
+ onMenuClose() {
+ // We reset the submenu state when the menu is closing
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()
- }
- })
},
- t,
+ onMenuClosed() {
+ // We reset the actions menu state when the menu is finally closed
+ this.openedMenu = false
+ },
},
})
</script>
@@ -365,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 747ff8d6cc9..5b80a971118 100644
--- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -1,46 +1,36 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<td class="files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
- <NcLoadingIcon v-if="isLoading" />
+ <NcLoadingIcon v-if="isLoading" :name="loadingLabel" />
<NcCheckboxRadioSwitch v-else
:aria-label="ariaLabel"
:checked="isSelected"
+ data-cy-files-list-row-checkbox
@update:checked="onSelectionChange" />
</td>
</template>
<script lang="ts">
-import { Node, FileType } from '@nextcloud/files'
+import type { Node } from '@nextcloud/files'
+import type { PropType } from 'vue'
+import type { FileSource } from '../../types.ts'
+
+import { FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import { type PropType, defineComponent } from 'vue'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+import { defineComponent } from 'vue'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import { useActiveStore } from '../../store/active.ts'
import { useKeyboardStore } from '../../store/keyboard.ts'
import { useSelectionStore } from '../../store/selection.ts'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
export default defineComponent({
name: 'FileEntryCheckbox',
@@ -72,21 +62,29 @@ export default defineComponent({
setup() {
const selectionStore = useSelectionStore()
const keyboardStore = useKeyboardStore()
+ const activeStore = useActiveStore()
+
return {
+ activeStore,
keyboardStore,
selectionStore,
+ t,
}
},
computed: {
+ isActive() {
+ return this.activeStore.activeNode?.source === this.source.source
+ },
+
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
- return this.selectedFiles.includes(this.fileid)
+ return this.selectedFiles.includes(this.source.source)
},
index() {
- return this.nodes.findIndex((node: Node) => node.fileid === this.fileid)
+ return this.nodes.findIndex((node: Node) => node.source === this.source.source)
},
isFile() {
return this.source.type === FileType.File
@@ -96,6 +94,28 @@ export default defineComponent({
? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename })
: t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename })
},
+ loadingLabel() {
+ return this.isFile
+ ? t('files', 'File is loading')
+ : t('files', 'Folder is loading')
+ },
+ },
+
+ created() {
+ // ctrl+space toggle selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ })
+
+ // ctrl+shift+space toggle range selection
+ useHotKey(' ', this.onToggleSelect, {
+ stop: true,
+ prevent: true,
+ ctrl: true,
+ shift: true,
+ })
},
methods: {
@@ -105,20 +125,20 @@ export default defineComponent({
// Get the last selected and select all files in between
if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
- const isAlreadySelected = this.selectedFiles.includes(this.fileid)
+ const isAlreadySelected = this.selectedFiles.includes(this.source.source)
const start = Math.min(newSelectedIndex, lastSelectedIndex)
const end = Math.max(lastSelectedIndex, newSelectedIndex)
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
- .map(file => file.fileid)
+ .map(file => file.source)
.slice(start, end + 1)
- .filter(Boolean) as number[]
+ .filter(Boolean) as FileSource[]
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
- .filter(fileid => !isAlreadySelected || fileid !== this.fileid)
+ .filter(source => !isAlreadySelected || source !== this.source.source)
logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
// Keep previous lastSelectedIndex to be use for further shift selections
@@ -127,8 +147,8 @@ export default defineComponent({
}
const selection = selected
- ? [...this.selectedFiles, this.fileid]
- : this.selectedFiles.filter(fileid => fileid !== this.fileid)
+ ? [...this.selectedFiles, this.source.source]
+ : this.selectedFiles.filter(source => source !== this.source.source)
logger.debug('Updating selection', { selection })
this.selectionStore.set(selection)
@@ -139,7 +159,15 @@ export default defineComponent({
this.selectionStore.reset()
},
- t,
+ onToggleSelect() {
+ // Don't react if the node is not active
+ if (!this.isActive) {
+ return
+ }
+
+ logger.debug('Toggling selection for file', { source: this.source })
+ this.onSelectionChange(!this.isSelected)
+ },
},
})
</script>
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue
index 3b2faa4e506..418f9581eb6 100644
--- a/apps/files/src/components/FileEntry/FileEntryName.vue
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -1,28 +1,12 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<!-- Rename input -->
<form v-if="isRenaming"
- v-on-click-outside="stopRenaming"
+ ref="renameForm"
+ v-on-click-outside="onRename"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
@@ -33,46 +17,44 @@
:required="true"
:value.sync="newName"
enterkeyhint="done"
- @keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<component :is="linkTo.is"
v-else
ref="basename"
- :aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
- v-bind="linkTo.params"
- @click="$emit('click', $event)">
- <!-- File name -->
- <span class="files-list__row-name-text">
- <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
- <span class="files-list__row-name-" v-text="displayName" />
- <span class="files-list__row-name-ext" v-text="extension" />
+ v-bind="linkTo.params">
+ <!-- Filename -->
+ <span class="files-list__row-name-text" dir="auto">
+ <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues-->
+ <span class="files-list__row-name-" v-text="basename" />
+ <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" />
</span>
</component>
</template>
<script lang="ts">
+import type { FileAction, Node } from '@nextcloud/files'
import type { PropType } from 'vue'
-import { emit } from '@nextcloud/event-bus'
-import { FileType, NodeStatus, Permission } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
+import { FileType, NodeStatus } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-import Vue from 'vue'
+import { defineComponent, inject } from 'vue'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import { getFilenameValidity } from '../../utils/filenameValidity.ts'
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation.ts'
import { useRenamingStore } from '../../store/renaming.ts'
-import logger from '../../logger.js'
+import { useRouteParameters } from '../../composables/useRouteParameters.ts'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
-const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
-
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryName',
components: {
@@ -80,18 +62,20 @@ export default Vue.extend({
},
props: {
- displayName: {
+ /**
+ * The filename without extension
+ */
+ basename: {
type: String,
required: true,
},
+ /**
+ * The extension of the filename
+ */
extension: {
type: String,
required: true,
},
- filesListWidth: {
- type: Number,
- required: true,
- },
nodes: {
type: Array as PropType<Node[]>,
required: true,
@@ -107,9 +91,23 @@ export default Vue.extend({
},
setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const { directory } = useRouteParameters()
+ const filesListWidth = useFileListWidth()
const renamingStore = useRenamingStore()
+ const userConfigStore = useUserConfigStore()
+
+ const defaultFileAction = inject<FileAction | undefined>('defaultFileAction')
+
return {
+ currentView,
+ defaultFileAction,
+ directory,
+ filesListWidth,
+
renamingStore,
+ userConfigStore,
}
},
@@ -121,24 +119,24 @@ export default Vue.extend({
return this.isRenaming && this.filesListWidth < 512
},
newName: {
- get() {
- return this.renamingStore.newName
+ get(): string {
+ return this.renamingStore.newNodeName
},
- set(newName) {
- this.renamingStore.newName = newName
+ set(newName: string) {
+ this.renamingStore.newNodeName = newName
},
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
- [FileType.File]: t('files', 'File name'),
+ [FileType.File]: t('files', 'Filename'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
},
linkTo() {
- if (this.source.attributes.failed) {
+ if (this.source.status === NodeStatus.FAILED) {
return {
is: 'span',
params: {
@@ -147,32 +145,20 @@ export default Vue.extend({
}
}
- const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
- if (enabledDefaultActions?.length > 0) {
- const action = enabledDefaultActions[0]
- const displayName = action.displayName([this.source], this.currentView)
+ if (this.defaultFileAction) {
+ const displayName = this.defaultFileAction.displayName([this.source], this.currentView)
return {
- is: 'a',
+ is: 'button',
params: {
+ 'aria-label': displayName,
title: displayName,
- role: 'button',
- tabindex: '0',
- },
- }
- }
-
- if (this.source?.permissions & Permission.READ) {
- return {
- is: 'a',
- params: {
- download: this.source.basename,
- href: this.source.source,
- title: t('files', 'Download file {name}', { name: this.displayName }),
tabindex: '0',
},
}
}
+ // nothing interactive here, there is no default action
+ // so if not even the download action works we only can show the list entry
return {
is: 'span',
}
@@ -181,7 +167,7 @@ export default Vue.extend({
watch: {
/**
- * If renaming starts, select the file name
+ * If renaming starts, select the filename
* in the input, without the extension.
* @param renaming
*/
@@ -193,71 +179,51 @@ export default Vue.extend({
}
},
},
- },
- methods: {
- /**
- * Check if the file name is valid and update the
- * input validity using browser's native validation.
- * @param event the keyup event
- */
- checkInputValidity(event?: KeyboardEvent) {
- const input = event.target as HTMLInputElement
+ newName() {
+ // Check validity of the new name
const newName = this.newName.trim?.() || ''
- logger.debug('Checking input validity', { newName })
- try {
- this.isFileNameValid(newName)
- input.setCustomValidity('')
- input.title = ''
- } catch (e) {
- input.setCustomValidity(e.message)
- input.title = e.message
- } finally {
- input.reportValidity()
- }
- },
- isFileNameValid(name) {
- const trimmedName = name.trim()
- if (trimmedName === '.' || trimmedName === '..') {
- throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
- } else if (trimmedName.length === 0) {
- throw new Error(t('files', 'File name cannot be empty.'))
- } else if (trimmedName.indexOf('/') !== -1) {
- throw new Error(t('files', '"/" is not allowed inside a file name.'))
- } else if (trimmedName.match(OC.config.blacklist_files_regex)) {
- throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
- } else if (this.checkIfNodeExists(name)) {
- throw new Error(t('files', '{newName} already exists.', { newName: name }))
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
+ if (!input) {
+ return
}
- const char = forbiddenCharacters.find((char) => trimmedName.includes(char))
- if (char) {
- throw new Error(t('files', '"{char}" is not allowed inside a file name.', { char }))
+ let validity = getFilenameValidity(newName)
+ // Checking if already exists
+ if (validity === '' && this.checkIfNodeExists(newName)) {
+ validity = t('files', 'Another entry with the same name already exists.')
}
-
- return true
+ this.$nextTick(() => {
+ if (this.isRenaming) {
+ input.setCustomValidity(validity)
+ input.reportValidity()
+ }
+ })
},
- checkIfNodeExists(name) {
+ },
+
+ methods: {
+ checkIfNodeExists(name: string) {
return this.nodes.find(node => node.basename === name && node !== this.source)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
- const extLength = (this.source.extension || '').split('').length
- const length = this.source.basename.split('').length - extLength
- const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
+ const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input')
if (!input) {
logger.error('Could not find the rename input')
return
}
- input.setSelectionRange(0, length)
input.focus()
+ const length = this.source.basename.length - (this.source.extension ?? '').length
+ input.setSelectionRange(0, length)
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
+
stopRenaming() {
if (!this.isRenaming) {
return
@@ -269,72 +235,37 @@ export default Vue.extend({
// Rename and move the file
async onRename() {
- const oldName = this.source.basename
- const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
- if (newName === '') {
- showError(t('files', 'Name cannot be empty'))
+ const form = this.$refs.renameForm as HTMLFormElement
+ if (!form.checkValidity()) {
+ showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName))
return
}
- if (oldName === newName) {
+ const oldName = this.source.basename
+ if (newName === oldName) {
this.stopRenaming()
return
}
- // Checking if already exists
- if (this.checkIfNodeExists(newName)) {
- showError(t('files', 'Another entry with the same name already exists'))
- return
- }
-
- // Set loading state
- this.loading = 'renaming'
- Vue.set(this.source, 'status', NodeStatus.LOADING)
-
- // Update node
- this.source.rename(newName)
-
- logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
- await axios({
- method: 'MOVE',
- url: oldEncodedSource,
- headers: {
- Destination: this.source.encodedSource,
- Overwrite: 'F',
- },
- })
-
- // Success 🎉
- emit('files:node:updated', this.source)
- emit('files:node:renamed', this.source)
- showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
-
- // Reset the renaming store
- this.stopRenaming()
- this.$nextTick(() => {
- this.$refs.basename.focus()
- })
- } catch (error) {
- logger.error('Error while renaming file', { error })
- this.source.rename(oldName)
- this.$refs.renameInput.focus()
-
- // TODO: 409 means current folder does not exist, redirect ?
- if (error?.response?.status === 404) {
- showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
- return
- } else if (error?.response?.status === 412) {
- showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
- return
+ const status = await this.renamingStore.rename()
+ if (status) {
+ showSuccess(
+ t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }),
+ )
+ this.$nextTick(() => {
+ const nameContainer = this.$refs.basename as HTMLElement | undefined
+ nameContainer?.focus()
+ })
+ } else {
+ // Was cancelled - meaning the renaming state is just reset
}
-
- // Unknown error
- showError(t('files', 'Could not rename "{oldName}"', { oldName }))
- } finally {
- this.loading = false
- Vue.set(this.source, 'status', undefined)
+ } catch (error) {
+ logger.error(error as Error)
+ showError((error as Error).message)
+ // And ensure we reset to the renaming state
+ this.startRenaming()
}
},
@@ -342,3 +273,16 @@ export default Vue.extend({
},
})
</script>
+
+<style scoped lang="scss">
+button.files-list__row-name-link {
+ background-color: unset;
+ border: none;
+ font-weight: normal;
+
+ &:active {
+ // No active styles - handled by the row entry
+ background-color: unset !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index d1bb78a105e..3d0fffe7584 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<span class="files-list__row-icon">
<template v-if="source.type === 'folder'">
@@ -31,16 +14,23 @@
</template>
</template>
- <!-- Decorative image, should not be aria documented -->
- <img v-else-if="previewUrl && backgroundFailed !== true"
- ref="previewImg"
- alt=""
- class="files-list__row-icon-preview"
- :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
- loading="lazy"
- :src="previewUrl"
- @error="onBackgroundError"
- @load="backgroundFailed = false">
+ <!-- Decorative images, should not be aria documented -->
+ <span v-else-if="previewUrl" class="files-list__row-icon-preview-container">
+ <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
+ ref="canvas"
+ class="files-list__row-icon-blurhash"
+ aria-hidden="true" />
+ <img v-if="backgroundFailed !== true"
+ :key="source.fileid"
+ ref="previewImg"
+ alt=""
+ class="files-list__row-icon-preview"
+ :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
+ loading="lazy"
+ :src="previewUrl"
+ @error="onBackgroundError"
+ @load="onBackgroundLoad">
+ </span>
<FileIcon v-else v-once />
@@ -60,11 +50,13 @@ import type { PropType } from 'vue'
import type { UserConfig } from '../../types.ts'
import { Node, FileType } from '@nextcloud/files'
-import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
+import { generateUrl } from '@nextcloud/router'
+import { ShareType } from '@nextcloud/sharing'
+import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
+import { decode } from 'blurhash'
+import { defineComponent } from 'vue'
-import Vue from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
@@ -72,16 +64,18 @@ import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
-import NetworkIcon from 'vue-material-design-icons/Network.vue'
+import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
-import { useUserConfigStore } from '../../store/userconfig.ts'
import CollectivesIcon from './CollectivesIcon.vue'
import FavoriteIcon from './FavoriteIcon.vue'
+
import { isLivePhoto } from '../../services/LivePhotos'
+import { useUserConfigStore } from '../../store/userconfig.ts'
+import logger from '../../logger.ts'
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryPreview',
components: {
@@ -115,21 +109,25 @@ export default Vue.extend({
setup() {
const userConfigStore = useUserConfigStore()
+ const isPublic = isPublicShare()
+ const publicSharingToken = getSharingToken()
+
return {
userConfigStore,
+
+ isPublic,
+ publicSharingToken,
}
},
data() {
return {
backgroundFailed: undefined as boolean | undefined,
+ backgroundLoaded: false,
}
},
computed: {
- fileid() {
- return this.source?.fileid?.toString?.()
- },
isFavorite(): boolean {
return this.source.attributes.favorite === 1
},
@@ -150,11 +148,28 @@ export default Vue.extend({
return null
}
+ if (this.source.attributes['has-preview'] !== true
+ && this.source.mime !== undefined
+ && this.source.mime !== 'application/octet-stream'
+ ) {
+ const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
+ mime: this.source.mime,
+ })
+ const url = new URL(window.location.origin + previewUrl)
+ return url.href
+ }
+
try {
const previewUrl = this.source.attributes.previewUrl
- || generateUrl('/core/preview?fileId={fileid}', {
- fileid: this.fileid,
- })
+ || (this.isPublic
+ ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
+ token: this.publicSharingToken,
+ file: this.source.path,
+ })
+ : generateUrl('/core/preview?fileId={fileid}', {
+ fileid: String(this.source.fileid),
+ })
+ )
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
@@ -162,6 +177,10 @@ export default Vue.extend({
url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
+ // Etag to force refresh preview on change
+ const etag = this.source?.attributes?.etag || ''
+ url.searchParams.set('v', etag.slice(0, 6))
+
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
@@ -195,7 +214,7 @@ export default Vue.extend({
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
- if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
+ if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) {
return LinkIcon
}
@@ -212,10 +231,22 @@ export default Vue.extend({
return AccountGroupIcon
case 'collective':
return CollectivesIcon
+ case 'shared':
+ return AccountPlusIcon
}
return null
},
+
+ hasBlurhash() {
+ return this.source.attributes['metadata-blurhash'] !== undefined
+ },
+ },
+
+ mounted() {
+ if (this.hasBlurhash && this.$refs.canvas) {
+ this.drawBlurhash()
+ }
},
methods: {
@@ -223,17 +254,44 @@ export default Vue.extend({
reset() {
// Reset background state to cancel any ongoing requests
this.backgroundFailed = undefined
- if (this.$refs.previewImg) {
- this.$refs.previewImg.src = ''
+ this.backgroundLoaded = false
+ const previewImg = this.$refs.previewImg as HTMLImageElement | undefined
+ if (previewImg) {
+ previewImg.src = ''
}
},
+ onBackgroundLoad() {
+ this.backgroundFailed = false
+ this.backgroundLoaded = true
+ },
+
onBackgroundError(event) {
// Do not fail if we just reset the background
if (event.target?.src === '') {
return
}
this.backgroundFailed = true
+ this.backgroundLoaded = false
+ },
+
+ drawBlurhash() {
+ const canvas = this.$refs.canvas as HTMLCanvasElement
+
+ const width = canvas.width
+ const height = canvas.height
+
+ const pixels = decode(this.source.attributes['metadata-blurhash'], width, height)
+
+ const ctx = canvas.getContext('2d')
+ if (ctx === null) {
+ logger.error('Cannot create context for blurhash canvas')
+ return
+ }
+
+ const imageData = ctx.createImageData(width, height)
+ imageData.data.set(pixels)
+ ctx.putImageData(imageData, 0, 0)
},
t,