diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-03-17 16:58:24 +0100 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:30 +0200 |
commit | b761039cf1946cb64898b9117d1b15dd89080451 (patch) | |
tree | 27ef66889525e0fbe0f295d1bdc96fb3d8ac479c /apps/files | |
parent | 2ff1c00f556633c9c36a9328d4eb77eba2dd25e7 (diff) | |
download | nextcloud-server-b761039cf1946cb64898b9117d1b15dd89080451.tar.gz nextcloud-server-b761039cf1946cb64898b9117d1b15dd89080451.zip |
perf(files): fetch previews faster and cache properly
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/appinfo/routes.php | 5 | ||||
-rw-r--r-- | apps/files/lib/Controller/ApiController.php | 20 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 130 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 50 | ||||
-rw-r--r-- | apps/files/src/main.js | 4 | ||||
-rw-r--r-- | apps/files/src/services/ServiceWorker.js | 40 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 28 |
7 files changed, 243 insertions, 34 deletions
diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index e4af2367906..a82490f7cae 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -134,6 +134,11 @@ $application->registerRoutes( 'verb' => 'GET' ], [ + 'name' => 'api#serviceWorker', + 'url' => '/preview-service-worker.js', + 'verb' => 'GET' + ], + [ 'name' => 'view#index', 'url' => '/{view}', 'verb' => 'GET', diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index 9baf5e97892..85507132edd 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -42,10 +42,12 @@ use OCA\Files\Service\TagService; use OCA\Files\Service\UserConfig; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StreamResponse; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\NotFoundException; @@ -417,4 +419,22 @@ class ApiController extends Controller { $node = $this->userFolder->get($folderpath); return $node->getType(); } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function serviceWorker(): StreamResponse { + $response = new StreamResponse(__DIR__ . '/../../../../dist/preview-service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + return $response; + } } diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 9f1df025e1f..84990a5ba39 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -31,10 +31,18 @@ <!-- Icon or preview --> <td class="files-list__row-icon"> <FolderIcon v-if="source.type === 'folder'" /> + <!-- Decorative image, should not be aria documented --> - <span v-else-if="previewUrl" - :style="{ backgroundImage: `url('${previewUrl}')` }" - class="files-list__row-icon-preview" /> + <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 /> </td> <!-- Link to file and --> @@ -65,6 +73,7 @@ import { Folder, File } from '@nextcloud/files' import { Fragment } from 'vue-fragment' import { join } from 'path' import { translate } from '@nextcloud/l10n' +import FileIcon from 'vue-material-design-icons/File.vue' import FolderIcon from 'vue-material-design-icons/Folder.vue' import TrashCan from 'vue-material-design-icons/TrashCan.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' @@ -73,19 +82,24 @@ import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import Vue from 'vue' -import logger from '../logger' +import logger from '../logger.js' import { useSelectionStore } from '../store/selection' import { useFilesStore } from '../store/files' import { loadState } from '@nextcloud/initial-state' +import { debounce } from 'debounce' // TODO: move to store // TODO: watch 'files:config:updated' event const userConfig = loadState('files', 'config', {}) +// The preview service worker cache name (see webpack config) +const SWCacheName = 'previews' + export default Vue.extend({ name: 'FileEntry', components: { + FileIcon, FolderIcon, Fragment, NcActionButton, @@ -96,10 +110,6 @@ export default Vue.extend({ }, props: { - index: { - type: Number, - required: true, - }, source: { type: [File, Folder], required: true, @@ -118,6 +128,8 @@ export default Vue.extend({ data() { return { userConfig, + backgroundImage: '', + backgroundFailed: false, } }, @@ -171,6 +183,32 @@ export default Vue.extend({ return null } }, + + mimeUrl() { + const mimeType = this.source.mime || 'application/octet-stream' + const mimeUrl = window.OC?.MimeType?.getIconUrl?.(mimeType) + if (mimeUrl) { + return `url(${mimeUrl})` + } + return '' + }, + }, + + watch: { + source() { + this.resetPreview() + this.debounceIfNotCached() + }, + }, + + mounted() { + // Init the debounce function on mount and + // not when the module is imported ⚠ + this.debounceGetPreview = debounce(function() { + this.fetchAndApplyPreview() + }, 150, false) + + this.debounceIfNotCached() }, methods: { @@ -180,15 +218,87 @@ export default Vue.extend({ * @param {number} fileId the file id to get * @return {Folder|File} */ - getNode(fileId) { + getNode(fileId) { return this.filesStore.getNode(fileId) }, + async debounceIfNotCached() { + if (!this.previewUrl) { + return + } + + // Check if we already have this preview cached + const isCached = await this.isCachedPreview(this.previewUrl) + if (isCached) { + logger.debug('Preview already cached', { fileId: this.source.attributes.fileid, backgroundFailed: this.backgroundFailed }) + this.backgroundImage = `url(${this.previewUrl})` + this.backgroundFailed = false + return + } + + // We don't have this preview cached or it expired, requesting it + this.debounceGetPreview() + }, + + fetchAndApplyPreview() { + logger.debug('Fetching preview', { fileId: this.source.attributes.fileid }) + this.img = new Image() + this.img.onload = () => { + this.backgroundImage = `url(${this.previewUrl})` + } + this.img.onerror = (a, b, c) => { + this.backgroundFailed = true + logger.error('Failed to fetch preview', { fileId: this.source.attributes.fileid, a, b, c }) + } + this.img.src = this.previewUrl + }, + + resetPreview() { + // Reset the preview + this.backgroundImage = '' + this.backgroundFailed = false + + // If we're already fetching a preview, cancel it + if (this.img) { + // Do not fail on cancel + this.img.onerror = null + this.img.src = '' + delete this.img + } + }, + + isCachedPreview(previewUrl) { + return caches.open(SWCacheName) + .then(function(cache) { + return cache.match(previewUrl) + .then(function(response) { + return !!response // or `return response ? true : false`, or similar. + }) + }) + }, + t: translate, }, }) </script> <style scoped lang="scss"> -@import '../mixins/fileslist-row.scss' +@import '../mixins/fileslist-row.scss'; + +.files-list__row-icon-preview:not([style*="background"]) { + background: linear-gradient(110deg, var(--color-loading-dark) 0%, var(--color-loading-dark) 25%, var(--color-loading-light) 50%, var(--color-loading-dark) 75%, var(--color-loading-dark) 100%); + background-size: 400%; + animation: preview-gradient-slide 1s ease infinite; +} +</style> + +<style> +@keyframes preview-gradient-slide { + from { + background-position: 100% 0%; + } + to { + background-position: 0% 0%; + } +} </style> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 8193c3bed1b..62a4e0e42eb 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -20,30 +20,37 @@ - --> <template> - <VirtualList class="files-list" - :data-component="FileEntry" - :data-key="getFileId" - :data-sources="nodes" - :estimate-size="55" + <RecycleScroller ref="recycleScroller" + class="files-list" + key-field="source" + :items="nodes" + :item-size="55" :table-mode="true" item-class="files-list__row" - wrap-class="files-list__body"> - <template #before> + item-tag="tr" + list-class="files-list__body" + list-tag="tbody" + role="table"> + <template #default="{ item }"> + <FileEntry :source="item" /> + </template> + + <!-- <template #before> <caption v-show="false" class="files-list__caption"> {{ summary }} </caption> - </template> + </template> --> - <template #header> + <template #before> <FilesListHeader :nodes="nodes" /> </template> - </VirtualList> + </RecycleScroller> </template> <script lang="ts"> import { Folder, File } from '@nextcloud/files' +import { RecycleScroller } from 'vue-virtual-scroller' import { translate, translatePlural } from '@nextcloud/l10n' -import VirtualList from 'vue-virtual-scroll-list' import Vue from 'vue' import FileEntry from './FileEntry.vue' @@ -53,7 +60,8 @@ export default Vue.extend({ name: 'FilesListVirtual', components: { - VirtualList, + RecycleScroller, + FileEntry, FilesListHeader, }, @@ -69,7 +77,6 @@ export default Vue.extend({ FileEntry, } }, - computed: { files() { return this.nodes.filter(node => node.type === 'file') @@ -88,6 +95,11 @@ export default Vue.extend({ }, }, + mounted() { + // Make the root recycle scroller a table for proper semantics + this.$el.querySelector('.vue-recycle-scroller__slot').setAttribute('role', 'thead') + }, + methods: { getFileId(node) { return node.attributes.fileid @@ -101,6 +113,7 @@ export default Vue.extend({ <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; @@ -111,25 +124,32 @@ export default Vue.extend({ height: 100%; &::v-deep { - tbody, thead, tfoot { + tbody, .vue-recycle-scroller__slot { display: flex; flex-direction: column; width: 100%; + // Necessary for virtual scrolling absolute + position: relative; } - thead { + // Table header + .vue-recycle-scroller__slot { // Pinned on top when scrolling position: sticky; z-index: 10; top: 0; + height: var(--row-height); background-color: var(--color-main-background); } tr { + position: absolute; display: flex; align-items: center; + width: 100%; border-bottom: 1px solid var(--color-border); } } } + </style> diff --git a/apps/files/src/main.js b/apps/files/src/main.js index 56332374051..48b981359ed 100644 --- a/apps/files/src/main.js +++ b/apps/files/src/main.js @@ -6,6 +6,7 @@ import Vue from 'vue' import { createPinia, PiniaVuePlugin } from 'pinia' import NavigationService from './services/Navigation.ts' +import registerPreviewServiceWorker from './services/ServiceWorker.js' import NavigationView from './views/Navigation.vue' import FilesListView from './views/FilesList.vue' @@ -57,3 +58,6 @@ FilesList.$mount('#app-content-vue') // Init legacy files views processLegacyFilesViews() + +// Register preview service worker +registerPreviewServiceWorker() diff --git a/apps/files/src/services/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js new file mode 100644 index 00000000000..223c0681323 --- /dev/null +++ b/apps/files/src/services/ServiceWorker.js @@ -0,0 +1,40 @@ +/** + * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + * + * @author Gary Kim <gary@garykim.dev> + * + * @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 { generateUrl } from '@nextcloud/router' +import logger from '../logger.js' + +export default () => { + if ('serviceWorker' in navigator) { + // Use the window load event to keep the page load performant + window.addEventListener('load', async () => { + try { + const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true }) + const registration = await navigator.serviceWorker.register(url, { scope: '/' }) + logger.debug('SW registered: ', { registration }) + } catch (error) { + logger.error('SW registration failed: ', { error }) + } + }) + } else { + logger.debug('Service Worker is not enabled on this browser.') + } +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index abca146138e..5e9a098c853 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -56,7 +56,7 @@ </NcEmptyContent> <!-- File list --> - <FilesListVirtual v-else :nodes="dirContents" /> + <FilesListVirtual v-else ref="filesListVirtual" :nodes="dirContents" /> </NcAppContent> </template> @@ -116,6 +116,8 @@ export default Vue.extend({ return { loading: true, promise: null, + sortKey: 'basename', + sortAsc: true, } }, @@ -160,7 +162,18 @@ export default Vue.extend({ * @return {Node[]} */ dirContents() { - return (this.currentFolder?.children || []).map(this.getNode) + return [...(this.currentFolder?.children || []).map(this.getNode)] + .sort((a, b) => { + if (a.type === 'folder' && b.type !== 'folder') { + return this.sortAsc ? -1 : 1 + } + + if (a.type !== 'folder' && b.type === 'folder') { + return this.sortAsc ? 1 : -1 + } + + return (a[this.sortKey] || a.basename).localeCompare(b[this.sortKey] || b.basename) * (this.sortAsc ? 1 : -1) + }) }, /** @@ -206,14 +219,11 @@ export default Vue.extend({ // TODO: preserve selection on browsing? this.selectionStore.reset() this.fetchContent() - }, - paths(paths) { - logger.debug('Paths changed', { paths }) - }, - - currentFolder(currentFolder) { - logger.debug('currentFolder changed', { currentFolder }) + // Scroll to top, force virtual scroller to re-render + if (this.$refs?.filesListVirtual?.$el) { + this.$refs.filesListVirtual.$el.scrollTop = 0 + } }, }, |