123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688 |
- <!--
- - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
- <template>
- <NcAppContent :page-heading="pageHeading" data-cy-files-content>
- <div class="files-list__header">
- <!-- Current folder breadcrumbs -->
- <BreadCrumbs :path="dir" @reload="fetchContent">
- <template #actions>
- <!-- Sharing button -->
- <NcButton v-if="canShare && filesListWidth >= 512"
- :aria-label="shareButtonLabel"
- :class="{ 'files-list__header-share-button--shared': shareButtonType }"
- :title="shareButtonLabel"
- class="files-list__header-share-button"
- type="tertiary"
- @click="openSharingSidebar">
- <template #icon>
- <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
- <AccountPlusIcon v-else :size="20" />
- </template>
- </NcButton>
-
- <!-- Disabled upload button -->
- <NcButton v-if="!canUpload || isQuotaExceeded"
- :aria-label="cantUploadLabel"
- :title="cantUploadLabel"
- class="files-list__header-upload-button--disabled"
- :disabled="true"
- type="secondary">
- <template #icon>
- <PlusIcon :size="20" />
- </template>
- {{ t('files', 'New') }}
- </NcButton>
-
- <!-- Uploader -->
- <UploadPicker v-else-if="currentFolder"
- :content="dirContents"
- :destination="currentFolder"
- :multiple="true"
- class="files-list__header-upload-button"
- @failed="onUploadFail"
- @uploaded="onUpload" />
- </template>
- </BreadCrumbs>
-
- <NcButton v-if="filesListWidth >= 512 && enableGridView"
- :aria-label="gridViewButtonLabel"
- :title="gridViewButtonLabel"
- class="files-list__header-grid-button"
- type="tertiary"
- @click="toggleGridView">
- <template #icon>
- <ListViewIcon v-if="userConfig.grid_view" />
- <ViewGridIcon v-else />
- </template>
- </NcButton>
-
- <!-- Secondary loading indicator -->
- <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
- </div>
-
- <!-- Drag and drop notice -->
- <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" />
-
- <!-- Initial loading -->
- <NcLoadingIcon v-if="loading && !isRefreshing"
- class="files-list__loading-icon"
- :size="38"
- :name="t('files', 'Loading current folder')" />
-
- <!-- Empty content placeholder -->
- <NcEmptyContent v-else-if="!loading && isEmptyDir"
- :name="currentView?.emptyTitle || t('files', 'No files in here')"
- :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
- 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>
- <NcIconSvgWrapper :svg="currentView.icon" />
- </template>
- </NcEmptyContent>
-
- <!-- File list -->
- <FilesListVirtual v-else
- ref="filesListVirtual"
- :current-folder="currentFolder"
- :current-view="currentView"
- :nodes="dirContentsSorted" />
- </NcAppContent>
- </template>
-
- <script lang="ts">
- import type { Route } from 'vue-router'
- import type { Upload } from '@nextcloud/upload'
- import type { UserConfig } from '../types.ts'
- import type { View, ContentsWithRoot } from '@nextcloud/files'
-
- import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
- import { Folder, Node, Permission } from '@nextcloud/files'
- import { getCapabilities } from '@nextcloud/capabilities'
- import { join, dirname } from 'path'
- import { orderBy } from 'natural-orderby'
- import { Parser } from 'xml2js'
- import { showError } from '@nextcloud/dialogs'
- import { translate, translatePlural } from '@nextcloud/l10n'
- import { Type } from '@nextcloud/sharing'
- import { UploadPicker } from '@nextcloud/upload'
- import { loadState } from '@nextcloud/initial-state'
- import { defineComponent } from 'vue'
-
- import LinkIcon from 'vue-material-design-icons/Link.vue'
- import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
- 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 NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
- import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
- import PlusIcon from 'vue-material-design-icons/Plus.vue'
- import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
- import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
-
- import { action as sidebarAction } from '../actions/sidebarAction.ts'
- import { useFilesStore } from '../store/files.ts'
- import { usePathsStore } from '../store/paths.ts'
- import { useSelectionStore } from '../store/selection.ts'
- import { useUploaderStore } from '../store/uploader.ts'
- import { useUserConfigStore } from '../store/userconfig.ts'
- import { useViewConfigStore } from '../store/viewConfig.ts'
- import BreadCrumbs from '../components/BreadCrumbs.vue'
- import FilesListVirtual from '../components/FilesListVirtual.vue'
- import filesListWidthMixin from '../mixins/filesListWidth.ts'
- import filesSortingMixin from '../mixins/filesSorting.ts'
- import logger from '../logger.js'
- import DragAndDropNotice from '../components/DragAndDropNotice.vue'
- import debounce from 'debounce'
-
- const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
-
- export default defineComponent({
- name: 'FilesList',
-
- components: {
- BreadCrumbs,
- DragAndDropNotice,
- FilesListVirtual,
- LinkIcon,
- ListViewIcon,
- NcAppContent,
- NcButton,
- NcEmptyContent,
- NcIconSvgWrapper,
- NcLoadingIcon,
- PlusIcon,
- AccountPlusIcon,
- UploadPicker,
- ViewGridIcon,
- },
-
- mixins: [
- filesListWidthMixin,
- filesSortingMixin,
- ],
-
- setup() {
- const filesStore = useFilesStore()
- const pathsStore = usePathsStore()
- const selectionStore = useSelectionStore()
- const uploaderStore = useUploaderStore()
- const userConfigStore = useUserConfigStore()
- const viewConfigStore = useViewConfigStore()
-
- const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
-
- return {
- filesStore,
- pathsStore,
- selectionStore,
- uploaderStore,
- userConfigStore,
- viewConfigStore,
- enableGridView,
- }
- },
-
- data() {
- return {
- filterText: '',
- loading: true,
- promise: null,
- Type,
-
- _unsubscribeStore: () => {},
- }
- },
-
- computed: {
- userConfig(): UserConfig {
- return this.userConfigStore.userConfig
- },
-
- currentView(): View {
- return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files'))
- },
-
- pageHeading(): string {
- return this.currentView?.name ?? this.t('files', 'Files')
- },
-
- /**
- * The current directory query.
- */
- dir(): string {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
-
- /**
- * The current folder.
- */
- currentFolder(): Folder | undefined {
- if (!this.currentView?.id) {
- return
- }
-
- if (this.dir === '/') {
- return this.filesStore.getRoot(this.currentView.id)
- }
- const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
- return this.filesStore.getNode(fileId)
- },
-
- /**
- * Directory content sorting parameters
- * Provided by an extra computed property for caching
- */
- sortingParameters() {
- const identifiers = [
- // 1: Sort favorites first if enabled
- ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
- // 2: Sort folders first if sorting by name
- ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []),
- // 3: Use sorting mode if NOT basename (to be able to use displayName too)
- ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
- // 4: Use displayName if available, fallback to name
- v => v.attributes?.displayName || v.basename,
- // 5: Finally, use basename if all previous sorting methods failed
- v => v.basename,
- ]
- const orders = [
- // (for 1): always sort favorites before normal files
- ...(this.userConfig.sort_favorites_first ? ['asc'] : []),
- // (for 2): always sort folders before files
- ...(this.userConfig.sort_folders_first ? ['asc'] : []),
- // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
- ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
- // (also for 3 so make sure not to conflict with 2 and 3)
- ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
- // for 4: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- // for 5: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- ]
- return [identifiers, orders] as const
- },
-
- /**
- * The current directory contents.
- */
- dirContentsSorted(): Node[] {
- if (!this.currentView) {
- return []
- }
-
- let filteredDirContent = [...this.dirContents]
- // Filter based on the filterText obtained from nextcloud:unified-search.search event.
- if (this.filterText) {
- filteredDirContent = filteredDirContent.filter(node => {
- return node.attributes.basename.toLowerCase().includes(this.filterText.toLowerCase())
- })
- console.debug('Files view filtered', filteredDirContent)
- }
-
- const customColumn = (this.currentView?.columns || [])
- .find(column => column.id === this.sortingMode)
-
- // Custom column must provide their own sorting methods
- if (customColumn?.sort && typeof customColumn.sort === 'function') {
- const results = [...this.dirContents].sort(customColumn.sort)
- return this.isAscSorting ? results : results.reverse()
- }
-
- return orderBy(
- filteredDirContent,
- ...this.sortingParameters,
- )
- },
-
- dirContents(): Node[] {
- const showHidden = this.userConfigStore?.userConfig.show_hidden
- return (this.currentFolder?._children || [])
- .map(this.getNode)
- .filter(file => {
- if (!showHidden) {
- return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.')
- }
-
- return !!file
- })
- },
-
- /**
- * The current directory is empty.
- */
- isEmptyDir(): boolean {
- 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(): boolean {
- return this.currentFolder !== undefined
- && !this.isEmptyDir
- && this.loading
- },
-
- /**
- * Route to the previous directory.
- */
- toPreviousDir(): Route {
- const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
- return { ...this.$route, query: { dir } }
- },
-
- shareAttributes(): number[] | undefined {
- if (!this.currentFolder?.attributes?.['share-types']) {
- return undefined
- }
- return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[]
- },
- shareButtonLabel() {
- if (!this.shareAttributes) {
- return this.t('files', 'Share')
- }
-
- if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
- return this.t('files', 'Shared by link')
- }
- return this.t('files', 'Shared')
- },
- shareButtonType(): Type | null {
- if (!this.shareAttributes) {
- return null
- }
-
- // If all types are links, show the link icon
- if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
- return Type.SHARE_TYPE_LINK
- }
-
- return Type.SHARE_TYPE_USER
- },
-
- gridViewButtonLabel() {
- return this.userConfig.grid_view
- ? this.t('files', 'Switch to list view')
- : this.t('files', 'Switch to grid view')
- },
-
- /**
- * Check if the current folder has create permissions
- */
- canUpload() {
- return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
- },
- isQuotaExceeded() {
- return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
- },
- cantUploadLabel() {
- if (this.isQuotaExceeded) {
- return this.t('files', 'Your have used your space quota and cannot upload files anymore')
- }
- return this.t('files', 'You don’t have permission to upload or create files here')
- },
-
- /**
- * Check if current folder has share permissions
- */
- canShare() {
- return isSharingEnabled
- && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
- },
- },
-
- watch: {
- currentView(newView, oldView) {
- if (newView?.id === oldView?.id) {
- return
- }
-
- logger.debug('View changed', { newView, oldView })
- this.selectionStore.reset()
- this.resetSearch()
- this.fetchContent()
- },
-
- dir(newDir, oldDir) {
- logger.debug('Directory changed', { newDir, oldDir })
- // TODO: preserve selection on browsing?
- this.selectionStore.reset()
- this.resetSearch()
- this.fetchContent()
-
- // Scroll to top, force virtual scroller to re-render
- if (this.$refs?.filesListVirtual?.$el) {
- this.$refs.filesListVirtual.$el.scrollTop = 0
- }
- },
-
- dirContents(contents) {
- logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
- emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
- },
- },
-
- mounted() {
- this.fetchContent()
- subscribe('files:node:updated', this.onUpdatedNode)
- subscribe('nextcloud:unified-search.search', this.onSearch)
- subscribe('nextcloud:unified-search.reset', this.onSearch)
-
- // reload on settings change
- this._unsubscribeStore = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
- },
-
- unmounted() {
- unsubscribe('files:node:updated', this.onUpdatedNode)
- unsubscribe('nextcloud:unified-search.search', this.onSearch)
- unsubscribe('nextcloud:unified-search.reset', this.onSearch)
- this._unsubscribeStore()
- },
-
- methods: {
- async fetchContent() {
- this.loading = true
- const dir = this.dir
- const currentView = this.currentView
-
- if (!currentView) {
- logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
- return
- }
-
- // 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
- this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
- try {
- const { folder, contents } = await this.promise
- logger.debug('Fetched contents', { dir, folder, contents })
-
- // Update store
- this.filesStore.updateNodes(contents)
-
- // Define current directory children
- // TODO: make it more official
- this.$set(folder, '_children', contents.map(node => node.fileid))
-
- // If we're in the root dir, define the root
- if (dir === '/') {
- this.filesStore.setRoot({ service: currentView.id, root: folder })
- } else {
- // Otherwise, add the folder to the store
- if (folder.fileid) {
- this.filesStore.updateNodes([folder])
- this.pathsStore.addPath({ service: currentView.id, fileid: folder.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.pathsStore.addPath({ service: currentView.id, fileid: node.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.filesStore.getNode(fileId)
- },
-
- /**
- * The upload manager have finished handling the queue
- * @param {Upload} upload the uploaded data
- */
- onUpload(upload: Upload) {
- // Let's only refresh the current Folder
- // Navigating to a different folder will refresh it anyway
- const destinationSource = dirname(upload.source)
- const needsRefresh = destinationSource === this.currentFolder?.source
-
- // TODO: fetch uploaded files data only
- // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
- if (needsRefresh) {
- // fetchContent will cancel the previous ongoing promise
- this.fetchContent()
- }
- },
-
- async onUploadFail(upload: Upload) {
- const status = upload.response?.status || 0
-
- // Check known status codes
- if (status === 507) {
- showError(this.t('files', 'Not enough free space'))
- return
- } else if (status === 404 || status === 409) {
- showError(this.t('files', 'Target folder does not exist any more'))
- return
- } else if (status === 403) {
- showError(this.t('files', 'Operation is blocked by access control'))
- return
- }
-
- // Else we try to parse the response error message
- try {
- const parser = new Parser({ trim: true, explicitRoot: false })
- const response = await parser.parseStringPromise(upload.response?.data)
- const message = response['s:message'][0] as string
- if (typeof message === 'string' && message.trim() !== '') {
- // The server message is also translated
- showError(this.t('files', 'Error during upload: {message}', { message }))
- return
- }
- } catch (error) {
- logger.error('Error while parsing', { error })
- }
-
- // Finally, check the status code if we have one
- if (status !== 0) {
- showError(this.t('files', 'Error during upload, status code {status}', { status }))
- return
- }
-
- showError(this.t('files', 'Unknown error during upload'))
- },
-
- /**
- * Refreshes the current folder on update.
- *
- * @param node is the file/folder being updated.
- */
- onUpdatedNode(node?: Node) {
- if (node?.fileid === this.currentFolder?.fileid) {
- this.fetchContent()
- }
- },
- /**
- * Handle search event from unified search.
- *
- * @param searchEvent is event object.
- */
- onSearch: debounce(function(searchEvent) {
- console.debug('Files app handling search event from unified search...', searchEvent)
- this.filterText = searchEvent.query
- }, 500),
-
- /**
- * Reset the search query
- */
- resetSearch() {
- this.filterText = ''
- },
-
- openSharingSidebar() {
- if (!this.currentFolder) {
- logger.debug('No current folder found for opening sharing sidebar')
- return
- }
-
- if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
- window.OCA.Files.Sidebar.setActiveTab('sharing')
- }
- sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
- },
- toggleGridView() {
- this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
- },
-
- t: translate,
- n: translatePlural,
- },
- })
- </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%;
- position: relative !important;
- }
-
- .files-list {
- &__header {
- display: flex;
- align-items: center;
- // Do not grow or shrink (vertically)
- flex: 0 0;
- max-width: 100%;
- // Align with the navigation toggle icon
- margin-block: var(--app-navigation-padding, 4px);
- margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
-
- >* {
- // Do not grow or shrink (horizontally)
- // Only the breadcrumbs shrinks
- flex: 0 0;
- }
-
- &-share-button {
- color: var(--color-text-maxcontrast) !important;
-
- &--shared {
- color: var(--color-main-text) !important;
- }
- }
- }
-
- &__refresh-icon {
- flex: 0 0 44px;
- width: 44px;
- height: 44px;
- }
-
- &__loading-icon {
- margin: auto;
- }
- }
- </style>
|