]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat(files): actions api
authorJohn Molakvoæ <skjnldsv@protonmail.com>
Thu, 23 Mar 2023 07:37:37 +0000 (08:37 +0100)
committerJohn Molakvoæ <skjnldsv@protonmail.com>
Thu, 6 Apr 2023 12:49:31 +0000 (14:49 +0200)
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
apps/files/js/app.js
apps/files/src/actions/deleteAction.ts
apps/files/src/components/CustomSvgIconRender.vue [new file with mode: 0644]
apps/files/src/components/FileEntry.vue
apps/files/src/components/FilesListHeader.vue
apps/files/src/components/FilesListVirtual.vue
apps/files/src/mixins/fileslist-row.scss
apps/files_trashbin/src/actions/restoreAction.ts [new file with mode: 0644]
apps/files_trashbin/src/css/trashbin.css
apps/files_trashbin/src/main.ts

index 36afd9a80b753e8adabdefc75a95a547cd55ba19..8ebd506c1a3eb417a15ac487f3dfc2c1f2931875 100644 (file)
                        }
 
                        window._nc_event_bus.emit('files:legacy-view:initialized', this);
+
+                       this.navigation = OCP.Files.Navigation
                },
 
                /**
                 * @return view id
                 */
                getActiveView: function() {
-                       return this.navigation.active
+                       return this.navigation
+                               && this.navigation.active
                                && this.navigation.active.id;
                },
 
index b1bf2cb2105c84a34d99050afd5c62848e444771..cd12c15ba107d6156e01bea5e5086973c8206a03 100644 (file)
@@ -23,6 +23,7 @@ import { registerFileAction, Permission, FileAction } from '@nextcloud/files'
 import { translate as t } from '@nextcloud/l10n'
 import axios from '@nextcloud/axios'
 import TrashCan from '@mdi/svg/svg/trash-can.svg?raw'
+import logger from '../logger'
 
 registerFileAction(new FileAction({
        id: 'delete',
@@ -38,12 +39,9 @@ registerFileAction(new FileAction({
                        .every(permission => (permission & Permission.DELETE) !== 0)
        },
        async exec(node) {
-               try {
-                       await axios.delete(node.source)
-                       return true
-               } catch (error) {
-                       console.error(error)
-                       return false
-               }
+               // No try...catch here, let the files app handle the error
+               await axios.delete(node.source)
+               return true
        },
+       order: 100,
 }))
diff --git a/apps/files/src/components/CustomSvgIconRender.vue b/apps/files/src/components/CustomSvgIconRender.vue
new file mode 100644 (file)
index 0000000..f025319
--- /dev/null
@@ -0,0 +1,63 @@
+<!--
+  - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+  -
+  - @author Gary Kim <gary@garykim.dev>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+<template>
+       <span class="custom-svg-icon" />
+</template>
+
+<script>
+// eslint-disable-next-line import/named
+import { sanitize } from 'dompurify'
+
+export default {
+       name: 'CustomSvgIconRender',
+       props: {
+               svg: {
+                       type: String,
+                       required: true,
+               },
+       },
+       mounted() {
+               this.$el.innerHTML = sanitize(this.svg)
+       },
+}
+</script>
+<style lang="scss" scoped>
+.custom-svg-icon {
+       display: flex;
+       align-items: center;
+       align-self: center;
+       justify-content: center;
+       justify-self: center;
+       width: 44px;
+       height: 44px;
+       opacity: 1;
+
+       ::v-deep svg {
+               // mdi icons have a size of 24px
+               // 22px results in roughly 16px inner size
+               height: 22px;
+               width: 22px;
+               fill: currentColor;
+       }
+}
+
+</style>
index d507fe6945cfaf9e94c407f40903443a7d16d59c..ea9615af596400f4c8bf6e612b4f4f19818a2929 100644 (file)
   - along with this program. If not, see <http://www.gnu.org/licenses/>.
   -
   -->
+
+<template>
+       <Fragment>
+               <td class="files-list__row-checkbox">
+                       <NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
+                               :checked.sync="selectedFiles"
+                               :value="fileid.toString()"
+                               name="selectedFiles" />
+               </td>
+
+               <!-- Link to file -->
+               <td class="files-list__row-name">
+                       <a v-bind="linkTo">
+                               <!-- Icon or preview -->
+                               <span class="files-list__row-icon">
+                                       <FolderIcon v-if="source.type === 'folder'" />
+
+                                       <!-- Decorative image, should not be aria documented -->
+                                       <span v-else-if="previewUrl && !backgroundFailed"
+                                               ref="previewImg"
+                                               class="files-list__row-icon-preview"
+                                               :style="{ backgroundImage }" />
+
+                                       <span v-else-if="mimeUrl"
+                                               class="files-list__row-icon-preview files-list__row-icon-preview--mime"
+                                               :style="{ backgroundImage: mimeUrl }" />
+
+                                       <FileIcon v-else />
+                               </span>
+
+                               <!-- File name -->
+                               {{ displayName }}
+                       </a>
+               </td>
+
+               <!-- Actions -->
+               <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
+                       <!-- Inline actions -->
+                       <template v-for="action in enabledInlineActions">
+                               <CustomElementRender v-if="action.renderInline"
+                                       :key="action.id"
+                                       :element="action.renderInline(source, currentView)" />
+                               <NcButton v-else
+                                       :key="action.id"
+                                       type="tertiary"
+                                       @click="onActionClick(action)">
+                                       <template #icon>
+                                               <NcLoadingIcon v-if="loading === action.id" :size="18" />
+                                               <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
+                                       </template>
+                                       {{ action.displayName([source], currentView) }}
+                               </NcButton>
+                       </template>
+
+                       <!-- Menu actions -->
+                       <NcActions ref="actionsMenu" :force-menu="true">
+                               <NcActionButton v-for="action in enabledMenuActions"
+                                       :key="action.id"
+                                       :class="'files-list__row-action-' + action.id"
+                                       @click="onActionClick(action)">
+                                       <template #icon>
+                                               <NcLoadingIcon v-if="loading === action.id" :size="18" />
+                                               <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
+                                       </template>
+                                       {{ action.displayName([source], currentView) }}
+                               </NcActionButton>
+                       </NcActions>
+               </td>
+
+               <!-- Size -->
+               <th v-if="isSizeAvailable"
+                       :style="{ opacity: sizeOpacity }"
+                       class="files-list__row-size">
+                       <span>{{ size }}</span>
+               </th>
+
+               <!-- View columns -->
+               <td v-for="column in columns"
+                       :key="column.id"
+                       :class="`files-list__row-${currentView?.id}-${column.id}`"
+                       class="files-list__row-column--custom">
+                       <CustomElementRender :element="column.render(source)" />
+               </td>
+       </Fragment>
+</template>
+
 <script lang='ts'>
 import { debounce } from 'debounce'
-import { Folder, File } from '@nextcloud/files'
+import { Folder, File, getFileActions, formatFileSize } from '@nextcloud/files'
 import { Fragment } from 'vue-fragment'
 import { join } from 'path'
 import { loadState } from '@nextcloud/initial-state'
@@ -30,14 +116,16 @@ import FileIcon from 'vue-material-design-icons/File.vue'
 import FolderIcon from 'vue-material-design-icons/Folder.vue'
 import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
 import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
 import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import Pencil from 'vue-material-design-icons/Pencil.vue'
-import TrashCan from 'vue-material-design-icons/TrashCan.vue'
-import Vue from 'vue'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import Vue, { CreateElement } from 'vue'
+import { showError } from '@nextcloud/dialogs'
 
 import { useFilesStore } from '../store/files'
 import { useSelectionStore } from '../store/selection'
 import CustomElementRender from './CustomElementRender.vue'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
 import logger from '../logger.js'
 
 // TODO: move to store
@@ -47,24 +135,32 @@ const userConfig = loadState('files', 'config', {})
 // The preview service worker cache name (see webpack config)
 const SWCacheName = 'previews'
 
+// The registered actions list
+const actions = getFileActions()
+
 export default Vue.extend({
        name: 'FileEntry',
 
        components: {
                CustomElementRender,
+               CustomSvgIconRender,
                FileIcon,
                FolderIcon,
                Fragment,
                NcActionButton,
                NcActions,
+               NcButton,
                NcCheckboxRadioSwitch,
-               Pencil,
-               TrashCan,
+               NcLoadingIcon,
        },
 
        props: {
+               isSizeAvailable: {
+                       type: Boolean,
+                       default: false,
+               },
                source: {
-                       type: [File, Folder],
+                       type: Object,
                        required: true,
                },
        },
@@ -80,9 +176,10 @@ export default Vue.extend({
 
        data() {
                return {
-                       userConfig,
-                       backgroundImage: '',
                        backgroundFailed: false,
+                       backgroundImage: '',
+                       loading: '',
+                       userConfig,
                }
        },
 
@@ -108,6 +205,26 @@ export default Vue.extend({
                        return this.source.attributes.displayName
                                || this.source.basename
                },
+               size() {
+                       const size = parseInt(this.source.size, 10) || 0
+                       if (!size || size < 0) {
+                               return this.t('files', 'Pending')
+                       }
+                       return formatFileSize(size, true)
+               },
+
+               sizeOpacity() {
+                       const size = parseInt(this.source.size, 10) || 0
+                       if (!size || size < 0) {
+                               return 1
+                       }
+
+                       // Whatever theme is active, the contrast will pass WCAG AA
+                       // with color main text over main background and an opacity of 0.7
+                       const minOpacity = 0.7
+                       const maxOpacitySize = 10 * 1024 * 1024
+                       return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
+               },
 
                linkTo() {
                        if (this.source.type === 'folder') {
@@ -130,7 +247,7 @@ export default Vue.extend({
                                return this.selectionStore.selected
                        },
                        set(selection) {
-                               logger.debug('Added node to selection', { selection })
+                               logger.debug('Changed nodes selection', { selection })
                                this.selectionStore.set(selection)
                        },
                },
@@ -154,15 +271,41 @@ export default Vue.extend({
                        }
                        return ''
                },
+
+               enabledActions() {
+                       return actions
+                               .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
+                               .sort((a, b) => (a.order || 0) - (b.order || 0))
+               },
+
+               enabledMenuActions() {
+                       return actions
+                               .filter(action => !action.inline)
+               },
+
+               enabledInlineActions() {
+                       return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+               },
+
+               uniqueId() {
+                       return this.hashCode(this.source.source)
+               },
        },
 
        watch: {
+               /**
+                * When the source changes, reset the preview
+                * and fetch the new one.
+                */
                source() {
-                       this.resetPreview()
+                       this.resetState()
                        this.debounceIfNotCached()
                },
        },
 
+       /**
+        * The row is mounted once and reused as we scroll.
+        */
        mounted() {
                // Init the debounce function on mount and
                // not when the module is imported ⚠
@@ -173,6 +316,10 @@ export default Vue.extend({
                this.debounceIfNotCached()
        },
 
+       beforeDestroy() {
+               this.resetState()
+       },
+
        methods: {
                /**
                 * Get a cached note from the store
@@ -202,7 +349,7 @@ export default Vue.extend({
                        this.debounceGetPreview()
                },
 
-                fetchAndApplyPreview() {
+               fetchAndApplyPreview() {
                        logger.debug('Fetching preview', { fileId: this.source.attributes.fileid })
                        this.img = new Image()
                        this.img.onload = () => {
@@ -215,7 +362,10 @@ export default Vue.extend({
                        this.img.src = this.previewUrl
                },
 
-               resetPreview() {
+               resetState() {
+                       // Reset loading state
+                       this.loading = ''
+
                        // Reset the preview
                        this.backgroundImage = ''
                        this.backgroundFailed = false
@@ -227,6 +377,9 @@ export default Vue.extend({
                                this.img.src = ''
                                delete this.img
                        }
+
+                       // Close menu
+                       this.$refs.actionsMenu.closeMenu()
                },
 
                isCachedPreview(previewUrl) {
@@ -239,111 +392,31 @@ export default Vue.extend({
                                })
                },
 
-               t: translate,
-       },
-
-       /**
-        * While a bit more complex, this component is pretty straightforward.
-        * For performance reasons, we're using a render function instead of a template.
-        */
-       render(createElement) {
-               // Checkbox
-               const checkbox = createElement('td', {
-                       staticClass: 'files-list__row-checkbox',
-               }, [createElement('NcCheckboxRadioSwitch', {
-                       attrs: {
-                               'aria-label': this.t('files', 'Select the row for {displayName}', {
-                                       displayName: this.displayName,
-                               }),
-                               checked: this.selectedFiles,
-                               value: this.fileid.toString(),
-                               name: 'selectedFiles',
-                       },
-                       on: {
-                               'update:checked': ($event) => {
-                                       this.selectedFiles = $event
-                               },
-                       },
-               })])
-
-               // Icon
-               const iconContent = () => {
-                       // Folder icon
-                       if (this.source.type === 'folder') {
-                               return createElement('FolderIcon')
+               hashCode(str) {
+                       let hash = 0
+                       for (let i = 0, len = str.length; i < len; i++) {
+                               const chr = str.charCodeAt(i)
+                               hash = (hash << 5) - hash + chr
+                               hash |= 0 // Convert to 32bit integer
                        }
-                       // Render cached preview or fallback to mime icon if defined
-                       const renderPreview = this.previewUrl && !this.backgroundFailed
-                       if (renderPreview || this.mimeUrl) {
-                               return createElement('span', {
-                                       ref: 'previewImg',
-                                       class: {
-                                               'files-list__row-icon-preview': true,
-                                               'files-list__row-icon-preview--mime': !renderPreview,
-                                       },
-                                       style: {
-                                               backgroundImage: renderPreview
-                                                       ? this.backgroundImage
-                                                       : this.mimeUrl,
-                                       },
-                               })
+                       return hash
+               },
+
+               async onActionClick(action) {
+                       const displayName = action.displayName([this.source], this.currentView)
+                       try {
+                               this.loading = action.id
+                               await action.exec(this.source, this.currentView)
+                       } catch (e) {
+                               logger.error('Error while executing action', { action, e })
+                               showError(this.t('files', 'Error while executing action "{displayName}"', { displayName }))
+                       } finally {
+                               this.loading = ''
                        }
-                       // Empty file icon
-                       return createElement('FileIcon')
-               }
-               const icon = createElement('td', {
-                       staticClass: 'files-list__row-icon',
-               }, [iconContent()])
-
-               // Name
-               const name = createElement('td', {
-                       staticClass: 'files-list__row-name',
-               }, [
-                       createElement(this.linkTo?.is || 'a', {
-                               attrs: this.linkTo,
-                       }, this.displayName),
-               ])
-
-               // Actions
-               const actions = createElement('td', {
-                       staticClass: 'files-list__row-actions',
-               }, [createElement('NcActions', [
-                       createElement('NcActionButton', [
-                               this.t('files', 'Rename'),
-                               createElement('Pencil', {
-                                       slot: 'icon',
-                               }),
-                       ]),
-                       createElement('NcActionButton', [
-                               this.t('files', 'Delete'),
-                               createElement('TrashCan', {
-                                       slot: 'icon',
-                               }),
-                       ]),
-               ])])
-
-               // Columns
-               const columns = this.columns.map(column => {
-                       return createElement('td', {
-                               class: {
-                                       [`files-list__row-${this.currentView?.id}-${column.id}`]: true,
-                                       'files-list__row-column--custom': true,
-                               },
-                               key: column.id,
-                       }, [createElement('CustomElementRender', {
-                               props: {
-                                       element: column.render(this.source),
-                               },
-                       })])
-               })
-
-               return createElement('Fragment', [
-                       checkbox,
-                       icon,
-                       name,
-                       actions,
-                       ...columns,
-               ])
+               },
+
+               t: translate,
+               formatFileSize,
        },
 })
 </script>
index 81b56331f9c9f967216a8272a305a5953f67b616..1fe6d230a205b7ed12ebe5456c7d6c0b982f883f 100644 (file)
                        <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
                </th>
 
-               <!-- Icon or preview -->
-               <th class="files-list__row-icon" />
-
-               <!-- Link to file and -->
+               <!-- Link to file -->
                <th class="files-list__row-name files-list__row--sortable"
                        @click="toggleSortBy('basename')">
+                       <!-- Icon or preview -->
+                       <span class="files-list__row-icon" />
+
+                       <!-- Name -->
                        {{ t('files', 'Name') }}
                        <template v-if="defaultFileSorting === 'basename'">
                                <MenuUp v-if="defaultFileSortingDirection === 'asc'" />
                <!-- Actions -->
                <th class="files-list__row-actions" />
 
+               <!-- Size -->
+               <th v-if="isSizeAvailable"
+                       class="files-list__row-size"
+                       @click="toggleSortBy('size')">
+                       {{ t('files', 'Size') }}
+                       <template v-if="defaultFileSorting === 'size'">
+                               <MenuUp v-if="defaultFileSortingDirection === 'asc'" />
+                               <MenuDown v-else />
+                       </template>
+               </th>
+
                <!-- Custom views columns -->
                <th v-for="column in columns"
                        :key="column.id"
@@ -51,7 +63,6 @@
 </template>
 
 <script lang="ts">
-import { File, Folder } from '@nextcloud/files'
 import { mapState } from 'pinia'
 import { translate } from '@nextcloud/l10n'
 import MenuDown from 'vue-material-design-icons/MenuDown.vue'
@@ -65,6 +76,8 @@ import { useSortingStore } from '../store/sorting'
 import logger from '../logger.js'
 import Navigation from '../services/Navigation'
 
+Vue.config.performance = true
+
 export default Vue.extend({
        name: 'FilesListHeader',
 
@@ -75,8 +88,12 @@ export default Vue.extend({
        },
 
        props: {
+               isSizeAvailable: {
+                       type: Boolean,
+                       default: false,
+               },
                nodes: {
-                       type: [File, Folder],
+                       type: Array,
                        required: true,
                },
        },
index 62a4e0e42ebadec714135589bfdd62d6dd6ed037..3f055f8b87886e7859ebef377965b992b12f6183 100644 (file)
@@ -32,7 +32,7 @@
                list-tag="tbody"
                role="table">
                <template #default="{ item }">
-                       <FileEntry :source="item" />
+                       <FileEntry :is-size-available="isSizeAvailable" :source="item" />
                </template>
 
                <!-- <template #before>
                </template> -->
 
                <template #before>
-                       <FilesListHeader :nodes="nodes" />
+                       <FilesListHeader :nodes="nodes" :is-size-available="isSizeAvailable" />
                </template>
        </RecycleScroller>
 </template>
 
 <script lang="ts">
-import { Folder, File } from '@nextcloud/files'
 import { RecycleScroller } from 'vue-virtual-scroller'
 import { translate, translatePlural } from '@nextcloud/l10n'
 import Vue from 'vue'
@@ -67,7 +66,7 @@ export default Vue.extend({
 
        props: {
                nodes: {
-                       type: [File, Folder],
+                       type: Array,
                        required: true,
                },
        },
@@ -93,6 +92,9 @@ export default Vue.extend({
                summary() {
                        return translate('files', '{summaryFile} and {summaryFolder}', this)
                },
+               isSizeAvailable() {
+                       return this.nodes.some(node => node.attributes.size !== undefined)
+               },
        },
 
        mounted() {
@@ -113,6 +115,7 @@ export default Vue.extend({
 <style scoped lang="scss">
 .files-list {
        --row-height: 55px;
+       --cell-margin: 14px;
 
        --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
        --checkbox-size: 24px;
index 6c3da968b70c796e7123fa7d79c3d928b67c042f..9ad821eb860666b581e7e5bfac17b536dc110e68 100644 (file)
 td, th {
        display: flex;
        align-items: center;
-       flex: 0 0 var(--row-height);
-       justify-content: center;
+       flex: 0 0 auto;
+       justify-content: left;
        width: var(--row-height);
        height: var(--row-height);
+       margin: 0;
        padding: 0;
+       color: var(--color-text-maxcontrast);
        border: none;
+
+       span {
+               overflow: hidden;
+               white-space: nowrap;
+               text-overflow: ellipsis;
+       }
 }
 
 .files-list__row {
@@ -37,7 +45,7 @@ td, th {
 }
 
 .files-list__row-checkbox {
-       width: var(--row-height);
+       justify-content: center;
        &::v-deep .checkbox-radio-switch {
                display: flex;
                justify-content: center;
@@ -58,8 +66,11 @@ td, th {
 }
 
 .files-list__row-icon {
-       flex: 0 0 var(--icon-preview-size);
-       justify-content: left;
+       display: flex;
+       align-items: center;
+       justify-content: center;
+       width: var(--icon-preview-size);
+       height: 100%;
        // Show same padding as the checkbox right padding for visual balance
        margin-right: var(--checkbox-padding);
        color: var(--color-primary-element);
@@ -74,26 +85,49 @@ td, th {
        }
 
        &-preview {
+               overflow: hidden;
                width: var(--icon-preview-size);
                height: var(--icon-preview-size);
+               border-radius: var(--border-radius);
+               background-repeat: no-repeat;
                // Center and contain the preview
                background-position: center;
-               background-repeat: no-repeat;
                background-size: contain;
-               border-radius: var(--border-radius);
-               overflow: hidden;
        }
 }
 
 .files-list__row-name {
-       flex: 1 1 100%;
-       justify-content: left;
+       // Prevent link from overflowing
+       overflow: hidden;
+       // Take as much space as possible
+       flex: 1 1 auto;
+
+       a {
+               display: flex;
+               align-items: center;
+               // Fill cell height and width
+               width: 100%;
+               height: 100%;
+       }
 }
 
-.files-list__row-column--custom {
-       overflow: hidden;
-       flex: 1 1 calc(var(--row-height) * 3);
+.files-list__row-actions {
        width: auto;
-       min-width: var(--row-height);
-       justify-content: normal;
+
+       & ~ td,
+       & ~ th {
+               // Add margin to all cells after the actions
+               margin: 0 var(--cell-margin);
+       }
+}
+
+.files-list__row-size {
+       justify-content: right;
+       width: calc(var(--row-height) * 1.5);
+       // opacity varies with the size
+       color: var(--color-main-text);
+}
+
+.files-list__row-column--custom {
+       width: calc(var(--row-height) * 2);
 }
diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/actions/restoreAction.ts
new file mode 100644 (file)
index 0000000..d65ff3f
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { registerFileAction, Permission, FileAction } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import History from '@mdi/svg/svg/history.svg?raw'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+
+registerFileAction(new FileAction({
+       id: 'restore',
+       displayName() {
+               return t('files_trashbin', 'Restore')
+       },
+       iconSvgInline: () => History,
+       enabled(nodes, view) {
+               // Only available in the trashbin view
+               if (view.id !== 'trashbin') {
+                       return false
+               }
+
+               // Only available if all nodes have read permission
+               return nodes.length > 0 && nodes
+                       .map(node => node.permissions)
+                       .every(permission => (permission & Permission.READ) !== 0)
+       },
+       async exec(node) {
+               // No try...catch here, let the files app handle the error
+               await axios({
+                       method: 'MOVE',
+                       url: node.source,
+                       headers: {
+                               destination: generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`),
+                       },
+               })
+               return true
+       },
+       order: 1,
+       inline: () => true,
+}))
index dd6cd8af591d83023ae5d337985113b9a7bbf275..40bbdfb037b3baadfb2bfd38fe74a3d3864cf36d 100644 (file)
@@ -1,2 +1,3 @@
 .files-list__row-trashbin-deleted {
-}
\ No newline at end of file
+       
+}
index d9cd2841b238789a9d8134d3e009f8ccd4d27f42..7cd6cf850f86a55a57e404996178b6ec879ccd68 100644 (file)
@@ -27,6 +27,9 @@ import moment from '@nextcloud/moment'
 
 import getContents from './services/trashbin'
 
+// Register restore action
+import './actions/restoreAction'
+
 const Navigation = window.OCP.Files.Navigation as NavigationService
 Navigation.register({
        id: 'trashbin',
@@ -40,13 +43,16 @@ Navigation.register({
                {
                        id: 'deleted',
                        title: t('files_trashbin', 'Deleted'),
-                       render(mount, node) {
+                       render(node) {
                                const deletionTime = node.attributes?.['trashbin-deletion-time']
+                               const span = document.createElement('span')
                                if (deletionTime) {
-                                       mount.innerText = moment.unix(deletionTime).fromNow()
-                                       return
+                                       span.title = moment.unix(deletionTime).format('LLL')
+                                       span.textContent = moment.unix(deletionTime).fromNow()
+                                       return span
                                }
-                               mount.innerText = translate('files_trashbin', 'Deleted a long time ago')
+                               span.textContent = translate('files_trashbin', 'Deleted a long time ago')
+                               return span
                        },
                        sort(nodeA, nodeB) {
                                const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0