feat(files_trashbin): migrate to vue

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-01-13 17:32:57 +01:00
parent 8eb9505294
commit 29a7f7f6ef
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
39 changed files with 1475 additions and 703 deletions

View File

@ -51,7 +51,6 @@
* Initializes the files app
*/
initialize: function() {
this.navigation = OCP.Files.Navigation;
this.$showHiddenFiles = $('input#showhiddenfilesToggle');
var showHidden = $('#showHiddenFiles').val() === "1";
this.$showHiddenFiles.prop('checked', showHidden);
@ -135,8 +134,6 @@
OC.Plugins.attach('OCA.Files.App', this);
this._setupEvents();
// trigger URL change event handlers
this._onPopState({ ...OC.Util.History.parseUrlQuery(), view: this.navigation?.active?.id });
this._debouncedPersistShowHiddenFilesState = _.debounce(this._persistShowHiddenFilesState, 1200);
this._debouncedPersistCropImagePreviewsState = _.debounce(this._persistCropImagePreviewsState, 1200);
@ -145,6 +142,8 @@
OCP.WhatsNew.query(); // for Nextcloud server
sessionStorage.setItem('WhatsNewServerCheck', Date.now());
}
window._nc_event_bus.emit('files:legacy-view:initialized', this);
},
/**

View File

@ -0,0 +1,58 @@
<template>
<NcBreadcrumbs data-cy-files-content-breadcrumbs>
<!-- Current path sections -->
<NcBreadcrumb v-for="section in sections"
:key="section.dir"
:aria-label="t('files', `Go to the '{dir}' directory`, section)"
v-bind="section" />
</NcBreadcrumbs>
</template>
<script>
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
import { basename } from 'path'
export default {
name: 'BreadCrumbs',
components: {
NcBreadcrumbs,
NcBreadcrumb,
},
props: {
path: {
type: String,
default: '/',
},
},
computed: {
dirs() {
const cumulativePath = (acc) => (value) => (acc += `${value}/`)
return ['/', ...this.path.split('/').filter(Boolean).map(cumulativePath('/'))]
},
sections() {
return this.dirs.map(dir => {
const to = { ...this.$route, query: { dir } }
return {
dir,
to,
title: basename(dir),
}
})
},
},
}
</script>
<style lang="scss" scoped>
.breadcrumb {
// Take as much space as possible
flex: 1 1 100% !important;
width: 100%;
}
</style>

View File

@ -0,0 +1,134 @@
<!--
- @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>
<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>
<!-- Icon or preview -->
<td class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" />
</td>
<!-- Link to file and -->
<td class="files-list__row-name">
<a v-bind="linkTo">
{{ displayName }}
</a>
</td>
</Fragment>
</template>
<script lang="ts">
import { Folder, File } from '@nextcloud/files'
import { Fragment } from 'vue-fragment'
import { join } from 'path'
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import logger from '../logger'
export default {
name: 'FileEntry',
components: {
FolderIcon,
Fragment,
NcCheckboxRadioSwitch,
},
props: {
index: {
type: Number,
required: true,
},
source: {
type: [File, Folder],
required: true,
},
},
computed: {
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
fileid() {
return this.source.attributes.fileid
},
displayName() {
return this.source.attributes.displayName
|| this.source.basename
},
linkTo() {
if (this.source.type === 'folder') {
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
return {
is: 'router-link',
title: this.t('files', 'Open folder {name}', { name: this.displayName }),
to,
}
}
return {
href: this.source.source,
// TODO: Use first action title ?
title: this.t('files', 'Download file {name}', { name: this.displayName }),
}
},
selectedFiles: {
get() {
return this.$store.state.selection.selected
},
set(selection) {
logger.debug('Added node to selection', { selection })
this.$store.dispatch('selection/set', selection)
},
},
},
methods: {
/**
* Get a cached note from the store
*
* @param {number} fileId the file id to get
* @return {Folder|File}
*/
getNode(fileId) {
return this.$store.getters['files/getNode'](fileId)
},
t: translate,
},
}
</script>
<style scoped lang="scss">
@import '../mixins/fileslist-row.scss'
</style>

View File

@ -0,0 +1,122 @@
<!--
- @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>
<tr>
<th class="files-list__row-checkbox">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
<!-- Icon or preview -->
<th class="files-list__row-icon" />
<!-- Link to file and -->
<th class="files-list__row-name">
{{ t('files', 'Name') }}
</th>
</tr>
</template>
<script lang="ts">
import { translate } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import logger from '../logger'
import { File, Folder } from '@nextcloud/files'
export default {
name: 'FilesListHeader',
components: {
NcCheckboxRadioSwitch,
},
props: {
nodes: {
type: [File, Folder],
required: true,
},
},
computed: {
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
selectAllBind() {
return {
ariaLabel: this.isNoneSelected || this.isSomeSelected
? this.t('files', 'Select all')
: this.t('files', 'Unselect all'),
checked: this.isAllSelected,
indeterminate: this.isSomeSelected,
}
},
isAllSelected() {
return this.selectedFiles.length === this.nodes.length
},
isNoneSelected() {
return this.selectedFiles.length === 0
},
isSomeSelected() {
return !this.isAllSelected && !this.isNoneSelected
},
selectedFiles() {
return this.$store.state.selection.selected
},
},
methods: {
/**
* Get a cached note from the store
*
* @param {number} fileId the file id to get
* @return {Folder|File}
*/
getNode(fileId) {
return this.$store.getters['files/getNode'](fileId)
},
onToggleAll(selected) {
if (selected) {
const selection = this.nodes.map(node => node.attributes.fileid.toString())
logger.debug('Added all nodes to selection', { selection })
this.$store.dispatch('selection/set', selection)
} else {
logger.debug('Cleared selection')
this.$store.dispatch('selection/reset')
}
},
t: translate,
},
}
</script>
<style scoped lang="scss">
@import '../mixins/fileslist-row.scss'
</style>

View File

@ -0,0 +1,124 @@
<!--
- @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>
<VirtualList class="files-list"
:data-component="FileEntry"
:data-key="getFileId"
:data-sources="nodes"
:estimate-size="55"
:table-mode="true"
item-class="files-list__row"
wrap-class="files-list__body">
<template #before>
<caption v-show="false" class="files-list__caption">
{{ summary }}
</caption>
</template>
<template #header>
<FilesListHeader :nodes="nodes" />
</template>
</VirtualList>
</template>
<script lang="ts">
import { Folder, File } from '@nextcloud/files'
import { translate, translatePlural } from '@nextcloud/l10n'
import VirtualList from 'vue-virtual-scroll-list'
import FileEntry from './FileEntry.vue'
import FilesListHeader from './FilesListHeader.vue'
export default {
name: 'FilesListVirtual',
components: {
VirtualList,
FilesListHeader,
},
props: {
nodes: {
type: [File, Folder],
required: true,
},
},
data() {
return {
FileEntry,
}
},
computed: {
files() {
return this.nodes.filter(node => node.type === 'file')
},
summaryFile() {
const count = this.files.length
return translatePlural('files', '{count} file', '{count} files', count, { count })
},
summaryFolder() {
const count = this.nodes.length - this.files.length
return translatePlural('files', '{count} folder', '{count} folders', count, { count })
},
summary() {
return translate('files', '{summaryFile} and {summaryFolder}', this)
},
},
methods: {
getFileId(node) {
return node.attributes.fileid
},
t: translate,
},
}
</script>
<style scoped lang="scss">
.files-list {
--row-height: 55px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: 44px;
--icon-preview-size: 32px;
display: block;
overflow: auto;
height: 100%;
&::v-deep {
tbody, thead, tfoot {
display: flex;
flex-direction: column;
width: 100%;
}
thead, .files-list__row {
border-bottom: 1px solid var(--color-border);
}
}
}
</style>

View File

@ -4,12 +4,15 @@ import processLegacyFilesViews from './legacy/navigationMapper.js'
import Vue from 'vue'
import NavigationService from './services/Navigation.ts'
import NavigationView from './views/Navigation.vue'
import FilesListView from './views/FilesList.vue'
import SettingsService from './services/Settings.js'
import SettingsModel from './models/Setting.js'
import router from './router/router.js'
import store from './store/index.ts'
// Init private and public Files namespace
window.OCA.Files = window.OCA.Files ?? {}
@ -35,5 +38,17 @@ const FilesNavigationRoot = new View({
})
FilesNavigationRoot.$mount('#app-navigation-files')
// Init content list view
const ListView = Vue.extend(FilesListView)
const FilesList = new ListView({
name: 'FilesListRoot',
propsData: {
Navigation,
},
router,
store,
})
FilesList.$mount('#app-content-vue')
// Init legacy files views
processLegacyFilesViews()

View File

@ -0,0 +1,63 @@
/**
* @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/>.
*
*/
td, th {
height: var(--row-height);
vertical-align: middle;
padding: 0px;
border: none;
}
.files-list__row-checkbox {
width: var(--row-height);
&::v-deep .checkbox-radio-switch {
--icon-size: var(--checkbox-size);
display: flex;
justify-content: center;
label.checkbox-radio-switch__label {
margin: 0;
height: var(--clickable-area);
width: var(--clickable-area);
padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2)
}
.checkbox-radio-switch__icon {
margin: 0 !important;
}
}
}
.files-list__row-icon {
// Remove left padding to look nicer with the checkbox
// => ico preview size + one checkbox td padding
width: calc(var(--icon-preview-size) + var(--checkbox-padding));
padding-right: var(--checkbox-padding);
color: var(--color-primary-element);
& > span {
justify-content: flex-start;
}
&::v-deep svg {
width: var(--icon-preview-size);
height: var(--icon-preview-size);
}
}

View File

@ -19,19 +19,27 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type Node from '@nextcloud/files/dist/files/node'
/* eslint-disable */
import type { Folder, Node } from '@nextcloud/files'
import isSvg from 'is-svg'
import logger from '../logger.js'
export type ContentsWithRoot = {
folder: Folder,
contents: Node[]
}
export interface Column {
/** Unique column ID */
id: string
/** Translated column title */
title: string
/** Property key from Node main or additional attributes.
Will be used if no custom sort function is provided.
Sorting will be done by localCompare */
/**
* Property key from Node main or additional attributes.
* Will be used if no custom sort function is provided.
* Sorting will be done by localCompare
*/
property: string
/** Special function used to sort Nodes between them */
sortFunction?: (nodeA: Node, nodeB: Node) => number;
@ -45,8 +53,15 @@ export interface Navigation {
id: string
/** Translated view name */
name: string
/** Method return the content of the provided path */
getFiles: (path: string) => Node[]
/**
* Method return the content of the provided path
* This ideally should be a cancellable promise.
* promise.cancel(reason) will be called when the directory
* change and the promise is not resolved yet.
* You _must_ also return the current directory
* information alongside with its content.
*/
getContents: (path: string) => Promise<ContentsWithRoot[]>
/** The view icon as an inline svg */
icon: string
/** The view order */
@ -150,8 +165,8 @@ const isValidNavigation = function(view: Navigation): boolean {
* TODO: remove when support for legacy views is removed
*/
if (!view.legacy) {
if (!view.getFiles || typeof view.getFiles !== 'function') {
throw new Error('Navigation getFiles is required and must be a function')
if (!view.getContents || typeof view.getContents !== 'function') {
throw new Error('Navigation getContents is required and must be a function')
}
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {

View File

@ -0,0 +1,97 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import type { Folder, Node } from '@nextcloud/files'
import Vue from 'vue'
import type { FileStore, RootStore, RootOptions, Service } from '../types'
const state = {
files: {} as FileStore,
roots: {} as RootStore,
}
const getters = {
/**
* Get a file or folder by id
*/
getNode: (state) => (id: number): Node|undefined => state.files[id],
/**
* Get a list of files or folders by their IDs
* Does not return undefined values
*/
getNodes: (state) => (ids: number[]): Node[] => ids
.map(id => state.files[id])
.filter(Boolean),
/**
* Get a file or folder by id
*/
getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
}
const mutations = {
updateNodes: (state, nodes: Node[]) => {
nodes.forEach(node => {
if (!node.attributes.fileid) {
return
}
Vue.set(state.files, node.attributes.fileid, node)
// state.files = {
// ...state.files,
// [node.attributes.fileid]: node,
// }
})
},
setRoot: (state, { service, root }: RootOptions) => {
state.roots = {
...state.roots,
[service]: root,
}
}
}
const actions = {
/**
* Insert valid nodes into the store.
* Roots (that does _not_ have a fileid) should
* be defined in the roots store
*/
addNodes: (context, nodes: Node[]) => {
context.commit('updateNodes', nodes)
},
/**
* Set the root of a service
*/
setRoot(context, { service, root }: RootOptions) {
context.commit('setRoot', { service, root })
}
}
export default {
namespaced: true,
state,
getters,
mutations,
actions,
}

View File

@ -0,0 +1,16 @@
import Vue from 'vue'
import Vuex, { Store } from 'vuex'
import files from './files'
import paths from './paths'
import selection from './selection'
Vue.use(Vuex)
export default new Store({
modules: {
files,
paths,
selection,
},
})

View File

@ -0,0 +1,71 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import type { Folder } from '@nextcloud/files'
import Vue from 'vue'
import type { PathOptions, ServicePaths, ServiceStore } from '../types'
const module = {
state: {
services: {
files: {} as ServicePaths,
} as ServiceStore,
},
getters: {
getPath(state: { services: ServiceStore }) {
return (service: string, path: string): number|undefined => {
if (!state.services[service]) {
return undefined
}
return state.services[service][path]
}
},
},
mutations: {
addPath: (state, opts: PathOptions) => {
// If it doesn't exists, init the service state
if (!state.services[opts.service]) {
// TODO: investigate why Vue.set is not working
state.services = {
[opts.service]: {} as ServicePaths,
...state.services
}
}
// Now we can set the path
Vue.set(state.services[opts.service], opts.path, opts.fileid)
}
},
actions: {
addPath: (context, opts: PathOptions) => {
context.commit('addPath', opts)
},
}
}
export default {
namespaced: true,
...module,
}

View File

@ -0,0 +1,51 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import type { Folder } from '@nextcloud/files'
import Vue from 'vue'
import type { PathOptions, ServicePaths, ServiceStore } from '../types'
const module = {
state: {
selected: [] as number[]
},
mutations: {
set: (state, selection: number[]) => {
Vue.set(state, 'selected', selection)
}
},
actions: {
set: (context, selection = [] as number[]) => {
context.commit('set', selection)
},
reset(context) {
context.commit('set', [])
}
}
}
export default {
namespaced: true,
...module,
}

56
apps/files/src/types.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import type { Folder } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
// Global definitions
export type Service = string
// Files store
export type FileStore = {
[id: number]: Node
}
export type RootStore = {
[service: Service]: Folder
}
export interface RootOptions {
root: Folder
service: Service
}
// Paths store
export type ServicePaths = {
[path: string]: number
}
export type ServiceStore = {
[service: Service]: ServicePaths
}
export interface PathOptions {
service: Service
path: string
fileid: number
}

View File

@ -0,0 +1,318 @@
<!--
- @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>
<NcAppContent v-show="!currentView?.legacy"
:class="{'app-content--hidden': currentView?.legacy}"
data-cy-files-content>
<div class="files-list__header">
<!-- Current folder breadcrumbs -->
<BreadCrumbs :path="dir" />
<!-- Secondary loading indicator -->
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
</div>
<!-- Initial loading -->
<NcLoadingIcon v-if="loading && !isRefreshing"
class="files-list__loading-icon"
:size="38"
:title="t('files', 'Loading current folder')" />
<!-- Empty content placeholder -->
<NcEmptyContent v-else-if="!loading && isEmptyDir"
:title="t('files', 'No files in here')"
:description="t('files', 'No files or folders have been deleted yet')"
data-cy-files-content-empty>
<template #action>
<NcButton v-if="dir !== '/'"
aria-label="t('files', 'Go to the previous folder')"
type="primary"
:to="toPreviousDir">
{{ t('files', 'Go back') }}
</NcButton>
</template>
<template #icon>
<TrashCan />
</template>
</NcEmptyContent>
<!-- File list -->
<FilesListVirtual v-else :nodes="dirContents" />
</NcAppContent>
</template>
<script lang="ts">
import { Folder } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import TrashCan from 'vue-material-design-icons/TrashCan.vue'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import logger from '../logger.js'
import Navigation from '../services/Navigation'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import { ContentsWithRoot } from '../services/Navigation'
import { join } from 'path'
export default {
name: 'FilesList',
components: {
BreadCrumbs,
FilesListVirtual,
NcAppContent,
NcButton,
NcEmptyContent,
NcLoadingIcon,
TrashCan,
},
props: {
// eslint-disable-next-line vue/prop-name-casing
Navigation: {
type: Navigation,
required: true,
},
},
data() {
return {
loading: true,
promise: null,
}
},
computed: {
currentViewId() {
return this.$route.params.view || 'files'
},
/** @return {Navigation} */
currentView() {
return this.views.find(view => view.id === this.currentViewId)
},
/** @return {Navigation[]} */
views() {
return this.Navigation.views
},
/**
* The current directory query.
* @return {string}
*/
dir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
},
/**
* The current folder.
* @return {Folder|undefined}
*/
currentFolder() {
if (this.dir === '/') {
return this.$store.getters['files/getRoot'](this.currentViewId)
}
const fileId = this.$store.getters['paths/getPath'](this.currentViewId, this.dir)
return this.$store.getters['files/getNode'](fileId)
},
/**
* The current directory contents.
* @return {Node[]}
*/
dirContents() {
return (this.currentFolder?.children || []).map(this.getNode)
},
/**
* The current directory is empty.
*/
isEmptyDir() {
return this.dirContents.length === 0
},
/**
* We are refreshing the current directory.
* But we already have a cached version of it
* that is not empty.
*/
isRefreshing() {
return this.currentFolder !== undefined
&& !this.isEmptyDir
&& this.loading
},
/**
* Route to the previous directory.
*/
toPreviousDir() {
const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
return { ...this.$route, query: { dir } }
},
},
watch: {
currentView(newView, oldView) {
if (newView?.id === oldView?.id) {
return
}
logger.debug('View changed', { newView, oldView })
this.$store.dispatch('selection/reset')
this.fetchContent()
},
dir(newDir, oldDir) {
logger.debug('Directory changed', { newDir, oldDir })
// TODO: preserve selection on browsing?
this.$store.dispatch('selection/reset')
this.fetchContent()
},
paths(paths) {
logger.debug('Paths changed', { paths })
},
currentFolder(currentFolder) {
logger.debug('currentFolder changed', { currentFolder })
},
},
methods: {
async fetchContent() {
if (this.currentView?.legacy) {
return
}
this.loading = true
const dir = this.dir
const currentView = this.currentView
// If we have a cancellable promise ongoing, cancel it
if (typeof this.promise?.cancel === 'function') {
this.promise.cancel()
logger.debug('Cancelled previous ongoing fetch')
}
// Fetch the current dir contents
/** @type {Promise<ContentsWithRoot>} */
this.promise = currentView.getContents(dir)
try {
const { folder, contents } = await this.promise
logger.debug('Fetched contents', { dir, folder, contents })
// Update store
this.$store.dispatch('files/addNodes', contents)
// Define current directory children
folder.children = contents.map(node => node.attributes.fileid)
// If we're in the root dir, define the root
if (dir === '/') {
console.debug('files', 'Setting root', { service: currentView.id, folder })
this.$store.dispatch('files/setRoot', { service: currentView.id, root: folder })
} else
// Otherwise, add the folder to the store
if (folder.attributes.fileid) {
this.$store.dispatch('files/addNodes', [folder])
this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: folder.attributes.fileid, path: dir })
} else {
// If we're here, the view API messed up
logger.error('Invalid root folder returned', { dir, folder, currentView })
}
// Update paths store
const folders = contents.filter(node => node.type === 'folder')
folders.forEach(node => {
this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: node.attributes.fileid, path: join(dir, node.basename) })
})
} catch (error) {
logger.error('Error while fetching content', { error })
} finally {
this.loading = false
}
},
/**
* Get a cached note from the store
*
* @param {number} fileId the file id to get
* @return {Folder|File}
*/
getNode(fileId) {
return this.$store.getters['files/getNode'](fileId)
},
t: translate,
},
}
</script>
<style scoped lang="scss">
.app-content {
// Virtual list needs to be full height and is scrollable
display: flex;
overflow: hidden;
flex-direction: column;
max-height: 100%;
// TODO: remove after all legacy views are migrated
// Hides the legacy app-content if shown view is not legacy
&:not(&--hidden)::v-deep + #app-content {
display: none;
}
}
$margin: 4px;
$navigationToggleSize: 50px;
.files-list {
&__header {
display: flex;
align-content: center;
// Do not grow or shrink (vertically)
flex: 0 0;
// Align with the navigation toggle icon
margin: $margin $margin $margin $navigationToggleSize;
> * {
// Do not grow or shrink (horizontally)
// Only the breadcrumbs shrinks
flex: 0 0;
}
}
&__refresh-icon {
flex: 0 0 44px;
width: 44px;
height: 44px;
}
&__loading-icon {
margin: auto;
}
}
</style>

View File

@ -32,13 +32,20 @@
:title="view.name"
:to="generateToNavigation(view)"
@update:open="onToggleExpand(view)">
<!-- Sanitized icon as svg if provided -->
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
<!-- Child views if any -->
<NcAppNavigationItem v-for="child in childViews[view.id]"
:key="child.id"
:data-cy-files-navigation-item="child.id"
:exact="true"
:icon="child.iconClass"
:title="child.name"
:to="generateToNavigation(child)" />
:to="generateToNavigation(child)">
<!-- Sanitized icon as svg if provided -->
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
</NcAppNavigationItem>
</NcAppNavigationItem>
</template>
@ -74,6 +81,7 @@ import axios from '@nextcloud/axios'
import Cog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import logger from '../logger.js'
import Navigation from '../services/Navigation.ts'
@ -86,10 +94,11 @@ export default {
components: {
Cog,
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
NcIconSvgWrapper,
SettingsModal,
NavigationQuota,
},
props: {
@ -151,7 +160,16 @@ export default {
watch: {
currentView(view, oldView) {
logger.debug('View changed', { id: view.id, view })
// If undefined, it means we're initializing the view
// This is handled by the legacy-view:initialized event
if (view?.id === oldView?.id) {
return
}
this.Navigation.setActive(view.id)
logger.debug('Navigation changed', { id: view.id, view })
// debugger
this.showView(view, oldView)
},
},
@ -163,6 +181,12 @@ export default {
}
subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
// TODO: remove this once the legacy navigation is gone
subscribe('files:legacy-view:initialized', () => {
logger.debug('Legacy view initialized', { ...this.currentView })
this.showView(this.currentView)
})
},
methods: {
@ -174,7 +198,7 @@ export default {
// Closing any opened sidebar
window?.OCA?.Files?.Sidebar?.close?.()
if (view.legacy) {
if (view?.legacy) {
const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
el.classList.add('hidden')
@ -188,7 +212,6 @@ export default {
logger.debug('Triggering legacy navigation event', params)
window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
}
this.Navigation.setActive(view)

View File

@ -1,13 +1,11 @@
<div id="app-navigation-files" role="navigation"></div>
<div class="hidden">
<ul class="with-icon" tabindex="0">
<?php
$pinned = 0;
foreach ($_['navigationItems'] as $item) {
$pinned = NavigationListElements($item, $l, $pinned);
}
$pinned = 0;
foreach ($_['navigationItems'] as $item) {
$pinned = NavigationListElements($item, $l, $pinned);
}
?>
</ul>
</div>

View File

@ -1,5 +1,9 @@
<?php /** @var \OCP\IL10N $l */ ?>
<?php $_['appNavigation']->printPage(); ?>
<!-- New files vue container -->
<div id="app-content-vue" class="hidden"></div>
<div id="app-content" tabindex="0">
<input type="checkbox" class="hidden-visually" id="showgridview"
@ -8,8 +12,6 @@
<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>"
title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label>
<!-- New files vue container -->
<div id="app-content-vue" class="hidden"></div>
<!-- Legacy views -->
<?php foreach ($_['appContents'] as $content) { ?>

View File

@ -21,6 +21,7 @@ return array(
'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php',
'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Hooks' => $baseDir . '/../lib/Hooks.php',
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => $baseDir . '/../lib/Sabre/AbstractTrash.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => $baseDir . '/../lib/Sabre/AbstractTrashFile.php',

View File

@ -36,6 +36,7 @@ class ComposerStaticInitFiles_Trashbin
'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php',
'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php',
'OCA\\Files_Trashbin\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php',
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrash.php',
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrashFile.php',

View File

@ -26,8 +26,10 @@
namespace OCA\Files_Trashbin\AppInfo;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_Trashbin\Capabilities;
use OCA\Files_Trashbin\Expiration;
use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts;
use OCA\Files_Trashbin\Trash\ITrashManager;
use OCA\Files_Trashbin\Trash\TrashManager;
use OCA\Files_Trashbin\UserMigration\TrashbinMigrator;
@ -55,6 +57,11 @@ class Application extends App implements IBootstrap {
$context->registerServiceAlias('principalBackend', Principal::class);
$context->registerUserMigrator(TrashbinMigrator::class);
$context->registerEventListener(
LoadAdditionalScriptsEvent::class,
LoadAdditionalScripts::class
);
}
public function boot(IBootContext $context): void {
@ -68,18 +75,6 @@ class Application extends App implements IBootstrap {
\OCP\Util::connectHook('OC_Filesystem', 'post_write', 'OCA\Files_Trashbin\Hooks', 'post_write_hook');
// pre and post-rename, disable trash logic for the copy+unlink case
\OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Trashbin\Trashbin', 'ensureFileScannedHook');
\OCA\Files\App::getNavigationManager()->add(function () {
$l = \OC::$server->getL10N(self::APP_ID);
return [
'id' => 'trashbin',
'appname' => self::APP_ID,
'script' => 'list.php',
'order' => 50,
'name' => $l->t('Deleted files'),
'classes' => 'pinned',
];
});
}
public function registerTrashBackends(IServerContainer $serverContainer, ILogger $logger, IAppManager $appManager, ITrashManager $trashManager) {

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, 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/>.
*
*/
namespace OCA\Files_Trashbin\Listeners;
use OCA\Files_Trashbin\AppInfo\Application;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
class LoadAdditionalScripts implements IEventListener {
public function handle(Event $event): void {
if (!($event instanceof LoadAdditionalScriptsEvent)) {
return;
}
Util::addScript(Application::APP_ID, 'main');
}
}

View File

@ -0,0 +1,39 @@
/**
* @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 type NavigationService from '../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
import getContents from './services/trashbin'
const Navigation = window.OCP.Files.Navigation as NavigationService
Navigation.register({
id: 'trashbin',
name: t('files_trashbin', 'Deleted files'),
icon: DeleteSvg,
order: 50,
sticky: true,
getContents,
})

View File

@ -0,0 +1,33 @@
/**
* @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 { createClient } from 'webdav'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
export const rootPath = `/trashbin/${getCurrentUser()?.uid}/trash`
export const rootUrl = generateRemoteUrl('dav' + rootPath)
const client = createClient(rootUrl, {
headers: {
requesttoken: getRequestToken(),
},
})
export default client

View File

@ -0,0 +1,95 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import { getCurrentUser } from '@nextcloud/auth'
import { File, Folder, parseWebdavPermissions } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ContentsWithRoot } from '../../../files/src/services/Navigation'
import client, { rootPath } from './client'
const data = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<nc:trashbin-filename />
<nc:trashbin-deletion-time />
<nc:trashbin-original-location />
<nc:trashbin-title />
<d:getlastmodified />
<d:getetag />
<d:getcontenttype />
<d:resourcetype />
<oc:fileid />
<oc:permissions />
<oc:size />
<d:getcontentlength />
</d:prop>
</d:propfind>`
const resultToNode = function(node: FileStat): File | Folder {
const permissions = parseWebdavPermissions(node.props?.permissions)
const owner = getCurrentUser()?.uid as string
const nodeData = {
id: node.props?.fileid as number || 0,
source: generateRemoteUrl('dav' + rootPath + node.filename),
mtime: new Date(node.lastmod),
mime: node.mime as string,
size: node.props?.size as number || 0,
permissions,
owner,
root: rootPath,
attributes: {
...node,
...node.props,
// Override displayed name on the list
displayName: node.props?.['trashbin-filename'],
},
}
return node.type === 'file'
? new File(nodeData)
: new Folder(nodeData)
}
export default async (path: string = '/'): Promise<ContentsWithRoot> => {
// TODO: use only one request when webdav-client supports it
// @see https://github.com/perry-mitchell/webdav-client/pull/334
const rootResponse = await client.stat(path, {
details: true,
data,
}) as ResponseDataDetailed<FileStat>
const contentsResponse = await client.getDirectoryContents(path, {
details: true,
data,
}) as ResponseDataDetailed<FileStat[]>
return {
folder: resultToNode(rootResponse.data) as Folder,
contents: contentsResponse.data.map(resultToNode),
}
}

View File

@ -1,22 +0,0 @@
/*
* Copyright (c) 2014
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
#app-content-trashbin tbody tr[data-type="file"] td a.name,
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext,
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext span {
cursor: default;
}
#app-content-trashbin .summary :last-child {
padding: 0;
}
#app-content-trashbin .files-filestable .summary .filesize {
display: none;
}

View File

@ -1,70 +0,0 @@
/**
* @copyright 2014 Vincent Petry <pvince81@owncloud.com>
*
* @author Vincent Petry <vincent@nextcloud.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/>.
*
*/
describe('OCA.Trashbin.App tests', function() {
var App = OCA.Trashbin.App;
beforeEach(function() {
$('#testArea').append(
'<div id="app-navigation">' +
'<ul><li data-id="files"><a>Files</a></li>' +
'<li data-id="trashbin"><a>Trashbin</a></li>' +
'</div>' +
'<div id="app-content">' +
'<div id="app-content-files" class="hidden">' +
'</div>' +
'<div id="app-content-trashbin" class="hidden">' +
'</div>' +
'</div>' +
'</div>'
);
App.initialize($('#app-content-trashbin'));
});
afterEach(function() {
App._initialized = false;
App.fileList = null;
});
describe('initialization', function() {
it('creates a custom filelist instance', function() {
App.initialize();
expect(App.fileList).toBeDefined();
expect(App.fileList.$el.is('#app-content-trashbin')).toEqual(true);
});
it('registers custom file actions', function() {
var fileActions;
App.initialize();
fileActions = App.fileList.fileActions;
expect(fileActions.actions.all).toBeDefined();
expect(fileActions.actions.all.Restore).toBeDefined();
expect(fileActions.actions.all.Delete).toBeDefined();
expect(fileActions.actions.all.Rename).not.toBeDefined();
expect(fileActions.actions.all.Download).not.toBeDefined();
expect(fileActions.defaults.dir).toEqual('Open');
});
});
});

View File

@ -1,397 +0,0 @@
/**
* @copyright 2014 Vincent Petry <pvince81@owncloud.com>
*
* @author Abijeet <abijeetpatro@gmail.com>
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Jan C. Borchardt <hey@jancborchardt.net>
* @author Jan-Christoph Borchardt <hey@jancborchardt.net>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Robin Appelman <robin@icewind.nl>
* @author Vincent Petry <vincent@nextcloud.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/>.
*
*/
describe('OCA.Trashbin.FileList tests', function () {
var testFiles, alertStub, notificationStub, fileList, client;
beforeEach(function () {
alertStub = sinon.stub(OC.dialogs, 'alert');
notificationStub = sinon.stub(OC.Notification, 'show');
client = new OC.Files.Client({
host: 'localhost',
port: 80,
root: '/remote.php/dav/trashbin/user',
useHTTPS: OC.getProtocol() === 'https'
});
// init parameters and test table elements
$('#testArea').append(
'<div id="app-content">' +
// set this but it shouldn't be used (could be the one from the
// files app)
'<input type="hidden" id="permissions" value="31"></input>' +
// dummy controls
'<div class="files-controls">' +
' <div class="actions creatable"></div>' +
' <div class="notCreatable"></div>' +
'</div>' +
// dummy table
// TODO: at some point this will be rendered by the fileList class itself!
'<table class="files-filestable list-container view-grid">' +
'<thead><tr><th class="hidden column-name">' +
'<input type="checkbox" id="select_all_trash" class="select-all">' +
'<span class="name">Name</span>' +
'<span class="selectedActions hidden">' +
'<a href="" class="actions-selected"><span class="icon icon-more"></span><span>Actions</span>' +
'</span>' +
'</th></tr></thead>' +
'<tbody class="files-fileList"></tbody>' +
'<tfoot></tfoot>' +
'</table>' +
'<div class="emptyfilelist emptycontent">Empty content message</div>' +
'</div>'
);
testFiles = [{
id: 1,
type: 'file',
name: 'One.txt.d11111',
displayName: 'One.txt',
mtime: 11111000,
mimetype: 'text/plain',
etag: 'abc'
}, {
id: 2,
type: 'file',
name: 'Two.jpg.d22222',
displayName: 'Two.jpg',
mtime: 22222000,
mimetype: 'image/jpeg',
etag: 'def',
}, {
id: 3,
type: 'file',
name: 'Three.pdf.d33333',
displayName: 'Three.pdf',
mtime: 33333000,
mimetype: 'application/pdf',
etag: '123',
}, {
id: 4,
type: 'dir',
mtime: 99999000,
name: 'somedir.d99999',
displayName: 'somedir',
mimetype: 'httpd/unix-directory',
etag: '456'
}];
// register file actions like the trashbin App does
var fileActions = OCA.Trashbin.App._createFileActions(fileList);
fileList = new OCA.Trashbin.FileList(
$('#app-content'), {
fileActions: fileActions,
multiSelectMenu: [{
name: 'restore',
displayName: t('files', 'Restore'),
iconClass: 'icon-history',
},
{
name: 'delete',
displayName: t('files', 'Delete'),
iconClass: 'icon-delete',
}
],
client: client
}
);
});
afterEach(function () {
testFiles = undefined;
fileList.destroy();
fileList = undefined;
notificationStub.restore();
alertStub.restore();
});
describe('Initialization', function () {
it('Sorts by mtime by default', function () {
expect(fileList._sort).toEqual('mtime');
expect(fileList._sortDirection).toEqual('desc');
});
it('Always returns read and delete permission', function () {
expect(fileList.getDirectoryPermissions()).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE);
});
});
describe('Breadcrumbs', function () {
beforeEach(function () {
var data = {
status: 'success',
data: {
files: testFiles,
permissions: 1
}
};
fakeServer.respondWith(/\/index\.php\/apps\/files_trashbin\/ajax\/list.php\?dir=%2Fsubdir/, [
200, {
"Content-Type": "application/json"
},
JSON.stringify(data)
]);
});
it('links the breadcrumb to the trashbin view', function () {
fileList.changeDirectory('/subdir', false, true);
fakeServer.respond();
var $crumbs = fileList.$el.find('.files-controls .crumb');
expect($crumbs.length).toEqual(3);
expect($crumbs.eq(1).find('a').text()).toEqual('Home');
expect($crumbs.eq(1).find('a').attr('href'))
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/');
expect($crumbs.eq(2).find('a').text()).toEqual('subdir');
expect($crumbs.eq(2).find('a').attr('href'))
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/subdir');
});
});
describe('Rendering rows', function () {
it('renders rows with the correct data when in root', function () {
// dir listing is false when in root
fileList.setFiles(testFiles);
var $rows = fileList.$el.find('tbody tr');
var $tr = $rows.eq(0);
expect($rows.length).toEqual(4);
expect($tr.attr('data-id')).toEqual('1');
expect($tr.attr('data-type')).toEqual('file');
expect($tr.attr('data-file')).toEqual('One.txt.d11111');
expect($tr.attr('data-size')).not.toBeDefined();
expect($tr.attr('data-etag')).toEqual('abc');
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
expect($tr.attr('data-mime')).toEqual('text/plain');
expect($tr.attr('data-mtime')).toEqual('11111000');
expect($tr.find('a.name').attr('href')).toEqual('#');
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]);
});
it('renders rows with the correct data when in root after calling setFiles with the same data set', function () {
// dir listing is false when in root
fileList.setFiles(testFiles);
fileList.setFiles(fileList.files);
var $rows = fileList.$el.find('tbody tr');
var $tr = $rows.eq(0);
expect($rows.length).toEqual(4);
expect($tr.attr('data-id')).toEqual('1');
expect($tr.attr('data-type')).toEqual('file');
expect($tr.attr('data-file')).toEqual('One.txt.d11111');
expect($tr.attr('data-size')).not.toBeDefined();
expect($tr.attr('data-etag')).toEqual('abc');
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
expect($tr.attr('data-mime')).toEqual('text/plain');
expect($tr.attr('data-mtime')).toEqual('11111000');
expect($tr.find('a.name').attr('href')).toEqual('#');
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]);
});
it('renders rows with the correct data when in subdirectory', function () {
fileList.setFiles(testFiles.map(function (file) {
file.name = file.displayName;
return file;
}));
var $rows = fileList.$el.find('tbody tr');
var $tr = $rows.eq(0);
expect($rows.length).toEqual(4);
expect($tr.attr('data-id')).toEqual('1');
expect($tr.attr('data-type')).toEqual('file');
expect($tr.attr('data-file')).toEqual('One.txt');
expect($tr.attr('data-size')).not.toBeDefined();
expect($tr.attr('data-etag')).toEqual('abc');
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
expect($tr.attr('data-mime')).toEqual('text/plain');
expect($tr.attr('data-mtime')).toEqual('11111000');
expect($tr.find('a.name').attr('href')).toEqual('#');
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
expect(fileList.findFileEl('One.txt')[0]).toEqual($tr[0]);
});
it('does not render a size column', function () {
expect(fileList.$el.find('tbody tr .filesize').length).toEqual(0);
});
});
describe('File actions', function () {
describe('Deleting single files', function () {
// TODO: checks ajax call
// TODO: checks spinner
// TODO: remove item after delete
// TODO: bring back item if delete failed
});
describe('Restoring single files', function () {
// TODO: checks ajax call
// TODO: checks spinner
// TODO: remove item after restore
// TODO: bring back item if restore failed
});
});
describe('file previews', function () {
// TODO: check that preview URL is going through files_trashbin
});
describe('loading file list', function () {
// TODO: check that ajax URL is going through files_trashbin
});
describe('breadcrumbs', function () {
// TODO: test label + URL
});
describe('elementToFile', function () {
var $tr;
beforeEach(function () {
fileList.setFiles(testFiles);
$tr = fileList.findFileEl('One.txt.d11111');
});
it('converts data attributes to file info structure', function () {
var fileInfo = fileList.elementToFile($tr);
expect(fileInfo.id).toEqual(1);
expect(fileInfo.name).toEqual('One.txt.d11111');
expect(fileInfo.displayName).toEqual('One.txt');
expect(fileInfo.mtime).toEqual(11111000);
expect(fileInfo.etag).toEqual('abc');
expect(fileInfo.permissions).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE);
expect(fileInfo.mimetype).toEqual('text/plain');
expect(fileInfo.type).toEqual('file');
});
});
describe('Global Actions', function () {
beforeEach(function () {
fileList.setFiles(testFiles);
fileList.findFileEl('One.txt.d11111').find('input:checkbox').click();
fileList.findFileEl('Three.pdf.d33333').find('input:checkbox').click();
fileList.findFileEl('somedir.d99999').find('input:checkbox').click();
fileList.$el.find('.actions-selected').click();
});
afterEach(function () {
fileList.$el.find('.actions-selected').click();
});
describe('Delete', function () {
it('Shows trashbin actions', function () {
// visible because a few files were selected
expect($('.selectedActions').is(':visible')).toEqual(true);
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true);
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true);
// check
fileList.$el.find('.select-all').click();
// stays visible
expect($('.selectedActions').is(':visible')).toEqual(true);
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true);
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true);
// uncheck
fileList.$el.find('.select-all').click();
// becomes hidden now
expect($('.selectedActions').is(':visible')).toEqual(false);
expect($('.selectedActions .item-delete').is(':visible')).toEqual(false);
expect($('.selectedActions .item-restore').is(':visible')).toEqual(false);
});
it('Deletes selected files when "Delete" clicked', function (done) {
var request;
var promise = fileList._onClickDeleteSelected({
preventDefault: function () {
}
});
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"];
expect(fakeServer.requests.length).toEqual(files.length);
for (var i = 0; i < files.length; i++) {
request = fakeServer.requests[i];
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
request.respond(200);
}
return promise.then(function () {
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0);
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0);
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0);
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1);
}).then(done, done);
});
it('Deletes all files when all selected when "Delete" clicked', function (done) {
var request;
$('.select-all').click();
var promise = fileList._onClickDeleteSelected({
preventDefault: function () {
}
});
expect(fakeServer.requests.length).toEqual(1);
request = fakeServer.requests[0];
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash');
request.respond(200);
return promise.then(function () {
expect(fileList.isEmpty).toEqual(true);
}).then(done, done);
});
});
describe('Restore', function () {
it('Restores selected files when "Restore" clicked', function (done) {
var request;
var promise = fileList._onClickRestoreSelected({
preventDefault: function () {
}
});
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"];
expect(fakeServer.requests.length).toEqual(files.length);
for (var i = 0; i < files.length; i++) {
request = fakeServer.requests[i];
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]);
request.respond(200);
}
return promise.then(function() {
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0);
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0);
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0);
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1);
}).then(done, done);
});
it('Restores all files when all selected when "Restore" clicked', function (done) {
var request;
$('.select-all').click();
var promise = fileList._onClickRestoreSelected({
preventDefault: function () {
}
});
var files = ["One.txt.d11111", "Two.jpg.d22222", "Three.pdf.d33333", "somedir.d99999"];
expect(fakeServer.requests.length).toEqual(files.length);
for (var i = 0; i < files.length; i++) {
request = fakeServer.requests[i];
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]);
request.respond(200);
}
return promise.then(function() {
expect(fileList.isEmpty).toEqual(true);
}).then(done, done);
});
});
});
});

View File

@ -10,6 +10,7 @@ module.exports = {
'@babel/preset-env',
{
useBuiltIns: false,
modules: 'auto',
},
],
],

View File

@ -1,135 +0,0 @@
/**
* @copyright Bernhard Posselt 2014
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @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 $ from 'jquery'
let dynamicSlideToggleEnabled = false
const Apps = {
enableDynamicSlideToggle() {
dynamicSlideToggleEnabled = true
},
}
/**
* Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings
*
* @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar')
*/
Apps.showAppSidebar = function($el) {
const $appSidebar = $el || $('#app-sidebar')
$appSidebar.removeClass('disappear').show()
$('#app-content').trigger(new $.Event('appresized'))
}
/**
* Shows the #app-sidebar and removes .with-app-sidebar from subsequent
* siblings
*
* @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar')
*/
Apps.hideAppSidebar = function($el) {
const $appSidebar = $el || $('#app-sidebar')
$appSidebar.hide().addClass('disappear')
$('#app-content').trigger(new $.Event('appresized'))
}
/**
* Provides a way to slide down a target area through a button and slide it
* up if the user clicks somewhere else. Used for the news app settings and
* add new field.
*
* Usage:
* <button data-apps-slide-toggle=".slide-area">slide</button>
* <div class=".slide-area" class="hidden">I'm sliding up</div>
*/
export const registerAppsSlideToggle = () => {
let buttons = $('[data-apps-slide-toggle]')
if (buttons.length === 0) {
$('#app-navigation').addClass('without-app-settings')
}
$(document).click(function(event) {
if (dynamicSlideToggleEnabled) {
buttons = $('[data-apps-slide-toggle]')
}
buttons.each(function(index, button) {
const areaSelector = $(button).data('apps-slide-toggle')
const area = $(areaSelector)
/**
*
*/
function hideArea() {
area.slideUp(OC.menuSpeed * 4, function() {
area.trigger(new $.Event('hide'))
})
area.removeClass('opened')
$(button).removeClass('opened')
}
/**
*
*/
function showArea() {
area.slideDown(OC.menuSpeed * 4, function() {
area.trigger(new $.Event('show'))
})
area.addClass('opened')
$(button).addClass('opened')
const input = $(areaSelector + ' [autofocus]')
if (input.length === 1) {
input.focus()
}
}
// do nothing if the area is animated
if (!area.is(':animated')) {
// button toggles the area
if ($(button).is($(event.target).closest('[data-apps-slide-toggle]'))) {
if (area.is(':visible')) {
hideArea()
} else {
showArea()
}
// all other areas that have not been clicked but are open
// should be slid up
} else {
const closest = $(event.target).closest(areaSelector)
if (area.is(':visible') && closest[0] !== area[0]) {
hideArea()
}
}
}
})
})
}
export default Apps

View File

@ -30,7 +30,6 @@ import {
processAjaxError,
registerXHRForErrorProcessing,
} from './xhr-error.js'
import Apps from './apps.js'
import { AppConfig, appConfig } from './appconfig.js'
import { appSettings } from './appsettings.js'
import appswebroots from './appswebroots.js'
@ -45,8 +44,8 @@ import {
import {
build as buildQueryString,
parse as parseQueryString,
} from './query-string.js'
import Config from './config.js'
} from './query-string'
import Config from './config'
import {
coreApps,
menuSpeed,
@ -58,30 +57,30 @@ import {
PERMISSION_SHARE,
PERMISSION_UPDATE,
TAG_FAVORITE,
} from './constants.js'
import ContactsMenu from './contactsmenu.js'
import { currentUser, getCurrentUser } from './currentuser.js'
import Dialogs from './dialogs.js'
import EventSource from './eventsource.js'
import { get, set } from './get_set.js'
import { getCapabilities } from './capabilities.js'
} from './constants'
import ContactsMenu from './contactsmenu'
import { currentUser, getCurrentUser } from './currentuser'
import Dialogs from './dialogs'
import EventSource from './eventsource'
import { get, set } from './get_set'
import { getCapabilities } from './capabilities'
import {
getHost,
getHostName,
getPort,
getProtocol,
} from './host.js'
} from './host'
import {
getToken as getRequestToken,
} from './requesttoken.js'
} from './requesttoken'
import {
hideMenus,
registerMenu,
showMenu,
unregisterMenu,
} from './menu.js'
import { isUserAdmin } from './admin.js'
import L10N from './l10n.js'
} from './menu'
import { isUserAdmin } from './admin'
import L10N from './l10n'
import {
getCanonicalLocale,
getLanguage,
@ -141,7 +140,6 @@ export default {
addScript,
addStyle,
Apps,
AppConfig,
appConfig,
appSettings,

View File

@ -165,6 +165,8 @@ export default {
},
_onPopState(e) {
debugger
if (this._cancelPop) {
this._cancelPop = false
return

View File

@ -35,11 +35,9 @@ import OC from './OC/index.js'
import './globals.js'
import './jquery/index.js'
import { initCore } from './init.js'
import { registerAppsSlideToggle } from './OC/apps.js'
window.addEventListener('DOMContentLoaded', function() {
initCore()
registerAppsSlideToggle()
// fallback to hashchange when no history support
if (window.history.pushState) {

View File

@ -1,7 +1,7 @@
/**
* @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl>
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
@ -19,9 +19,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
declare module '*.svg' {
const content: any
export default content
}
import './app.js'
import './filelist.js'
import './trash.scss'
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}
window.OCA.Trashbin = OCA.Trashbin

34
cypress.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
/**
* @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/>.
*
*/
/* eslint-disable */
import { mount } from 'cypress/vue2'
type MountParams = Parameters<typeof mount>;
type OptionsParam = MountParams[1];
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}

View File

@ -19,21 +19,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/* eslint-disable */
import { mount } from 'cypress/vue2'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
// Example use:
// cy.mount(MyComponent)
Cypress.Commands.add('mount', (component, optionsOrProps) => {

View File

@ -44,7 +44,7 @@
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^4.0.0-beta.2",
"@nextcloud/event-bus": "^3.0.2",
"@nextcloud/files": "^3.0.0-beta.5",
"@nextcloud/files": "^3.0.0-beta.7",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^2.1.0",
"@nextcloud/logger": "^2.5.0",
@ -99,15 +99,17 @@
"vue": "^2.7.14",
"vue-click-outside": "^1.1.0",
"vue-cropperjs": "^4.2.0",
"vue-fragment": "^1.6.0",
"vue-infinite-loading": "^2.4.5",
"vue-localstorage": "^0.6.2",
"vue-material-design-icons": "^5.0.0",
"vue-multiselect": "^2.1.6",
"vue-router": "^3.6.5",
"vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#feat/table",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0",
"webdav": "^4.11.0"
"webdav": "^5.0.0-r1"
},
"devDependencies": {
"@babel/node": "^7.20.7",
@ -160,6 +162,7 @@
"sass-loader": "^13.2.0",
"sinon": "<= 5.0.7",
"style-loader": "^3.3.1",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tslib": "^2.4.1",
"typescript": "^4.9.3",

View File

@ -1,8 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["./apps/**/*.ts", "./core/**/*.ts"],
"include": ["./apps/**/*.ts", "./core/**/*.ts", "./*.d.ts"],
"compilerOptions": {
"types": ["node"],
"types": ["cypress", "node", "vue"],
"outDir": "./dist/",
"target": "ESNext",
"module": "esnext",

View File

@ -171,6 +171,7 @@ module.exports = {
alias: {
// make sure to use the handlebar runtime when importing
handlebars: 'handlebars/runtime',
vue$: path.resolve('./node_modules/vue'),
},
extensions: ['*', '.ts', '.js', '.vue'],
symlinks: true,

View File

@ -63,7 +63,7 @@ module.exports = {
'personal-settings': path.join(__dirname, 'apps/files_sharing/src', 'personal-settings.js'),
},
files_trashbin: {
files_trashbin: path.join(__dirname, 'apps/files_trashbin/src', 'files_trashbin.js'),
main: path.join(__dirname, 'apps/files_trashbin/src', 'main.ts'),
},
files_versions: {
files_versions: path.join(__dirname, 'apps/files_versions/src', 'files_versions_tab.js'),