perf(files): fetch previews faster and cache properly

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/36534/head
John Molakvoæ 2023-03-17 16:58:24 +07:00
parent 2ff1c00f55
commit b761039cf1
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
9 changed files with 279 additions and 35 deletions

@ -133,6 +133,11 @@ $application->registerRoutes(
'url' => '/directEditing/{token}', 'url' => '/directEditing/{token}',
'verb' => 'GET' 'verb' => 'GET'
], ],
[
'name' => 'api#serviceWorker',
'url' => '/preview-service-worker.js',
'verb' => 'GET'
],
[ [
'name' => 'view#index', 'name' => 'view#index',
'url' => '/{view}', 'url' => '/{view}',

@ -42,10 +42,12 @@ use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig; use OCA\Files\Service\UserConfig;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StreamResponse;
use OCP\Files\File; use OCP\Files\File;
use OCP\Files\Folder; use OCP\Files\Folder;
use OCP\Files\NotFoundException; use OCP\Files\NotFoundException;
@ -417,4 +419,22 @@ class ApiController extends Controller {
$node = $this->userFolder->get($folderpath); $node = $this->userFolder->get($folderpath);
return $node->getType(); 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;
}
} }

@ -31,10 +31,18 @@
<!-- Icon or preview --> <!-- Icon or preview -->
<td class="files-list__row-icon"> <td class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" /> <FolderIcon v-if="source.type === 'folder'" />
<!-- Decorative image, should not be aria documented --> <!-- Decorative image, should not be aria documented -->
<span v-else-if="previewUrl" <span v-else-if="previewUrl && !backgroundFailed"
:style="{ backgroundImage: `url('${previewUrl}')` }" ref="previewImg"
class="files-list__row-icon-preview" /> 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> </td>
<!-- Link to file and --> <!-- Link to file and -->
@ -65,6 +73,7 @@ import { Folder, File } from '@nextcloud/files'
import { Fragment } from 'vue-fragment' import { Fragment } from 'vue-fragment'
import { join } from 'path' import { join } from 'path'
import { translate } from '@nextcloud/l10n' import { translate } from '@nextcloud/l10n'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue' import FolderIcon from 'vue-material-design-icons/Folder.vue'
import TrashCan from 'vue-material-design-icons/TrashCan.vue' import TrashCan from 'vue-material-design-icons/TrashCan.vue'
import Pencil from 'vue-material-design-icons/Pencil.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 NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import Vue from 'vue' import Vue from 'vue'
import logger from '../logger' import logger from '../logger.js'
import { useSelectionStore } from '../store/selection' import { useSelectionStore } from '../store/selection'
import { useFilesStore } from '../store/files' import { useFilesStore } from '../store/files'
import { loadState } from '@nextcloud/initial-state' import { loadState } from '@nextcloud/initial-state'
import { debounce } from 'debounce'
// TODO: move to store // TODO: move to store
// TODO: watch 'files:config:updated' event // TODO: watch 'files:config:updated' event
const userConfig = loadState('files', 'config', {}) const userConfig = loadState('files', 'config', {})
// The preview service worker cache name (see webpack config)
const SWCacheName = 'previews'
export default Vue.extend({ export default Vue.extend({
name: 'FileEntry', name: 'FileEntry',
components: { components: {
FileIcon,
FolderIcon, FolderIcon,
Fragment, Fragment,
NcActionButton, NcActionButton,
@ -96,10 +110,6 @@ export default Vue.extend({
}, },
props: { props: {
index: {
type: Number,
required: true,
},
source: { source: {
type: [File, Folder], type: [File, Folder],
required: true, required: true,
@ -118,6 +128,8 @@ export default Vue.extend({
data() { data() {
return { return {
userConfig, userConfig,
backgroundImage: '',
backgroundFailed: false,
} }
}, },
@ -171,6 +183,32 @@ export default Vue.extend({
return null 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: { methods: {
@ -180,15 +218,87 @@ export default Vue.extend({
* @param {number} fileId the file id to get * @param {number} fileId the file id to get
* @return {Folder|File} * @return {Folder|File}
*/ */
getNode(fileId) { getNode(fileId) {
return this.filesStore.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, t: translate,
}, },
}) })
</script> </script>
<style scoped lang="scss"> <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> </style>

@ -20,30 +20,37 @@
- -
--> -->
<template> <template>
<VirtualList class="files-list" <RecycleScroller ref="recycleScroller"
:data-component="FileEntry" class="files-list"
:data-key="getFileId" key-field="source"
:data-sources="nodes" :items="nodes"
:estimate-size="55" :item-size="55"
:table-mode="true" :table-mode="true"
item-class="files-list__row" item-class="files-list__row"
wrap-class="files-list__body"> item-tag="tr"
<template #before> 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"> <caption v-show="false" class="files-list__caption">
{{ summary }} {{ summary }}
</caption> </caption>
</template> </template> -->
<template #header> <template #before>
<FilesListHeader :nodes="nodes" /> <FilesListHeader :nodes="nodes" />
</template> </template>
</VirtualList> </RecycleScroller>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Folder, File } from '@nextcloud/files' import { Folder, File } from '@nextcloud/files'
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n' import { translate, translatePlural } from '@nextcloud/l10n'
import VirtualList from 'vue-virtual-scroll-list'
import Vue from 'vue' import Vue from 'vue'
import FileEntry from './FileEntry.vue' import FileEntry from './FileEntry.vue'
@ -53,7 +60,8 @@ export default Vue.extend({
name: 'FilesListVirtual', name: 'FilesListVirtual',
components: { components: {
VirtualList, RecycleScroller,
FileEntry,
FilesListHeader, FilesListHeader,
}, },
@ -69,7 +77,6 @@ export default Vue.extend({
FileEntry, FileEntry,
} }
}, },
computed: { computed: {
files() { files() {
return this.nodes.filter(node => node.type === 'file') 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: { methods: {
getFileId(node) { getFileId(node) {
return node.attributes.fileid return node.attributes.fileid
@ -101,6 +113,7 @@ export default Vue.extend({
<style scoped lang="scss"> <style scoped lang="scss">
.files-list { .files-list {
--row-height: 55px; --row-height: 55px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px; --checkbox-size: 24px;
--clickable-area: 44px; --clickable-area: 44px;
@ -111,25 +124,32 @@ export default Vue.extend({
height: 100%; height: 100%;
&::v-deep { &::v-deep {
tbody, thead, tfoot { tbody, .vue-recycle-scroller__slot {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
} }
thead { // Table header
.vue-recycle-scroller__slot {
// Pinned on top when scrolling // Pinned on top when scrolling
position: sticky; position: sticky;
z-index: 10; z-index: 10;
top: 0; top: 0;
height: var(--row-height);
background-color: var(--color-main-background); background-color: var(--color-main-background);
} }
tr { tr {
position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
} }
} }
</style> </style>

@ -6,6 +6,7 @@ import Vue from 'vue'
import { createPinia, PiniaVuePlugin } from 'pinia' import { createPinia, PiniaVuePlugin } from 'pinia'
import NavigationService from './services/Navigation.ts' import NavigationService from './services/Navigation.ts'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import NavigationView from './views/Navigation.vue' import NavigationView from './views/Navigation.vue'
import FilesListView from './views/FilesList.vue' import FilesListView from './views/FilesList.vue'
@ -57,3 +58,6 @@ FilesList.$mount('#app-content-vue')
// Init legacy files views // Init legacy files views
processLegacyFilesViews() processLegacyFilesViews()
// Register preview service worker
registerPreviewServiceWorker()

@ -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.')
}
}

@ -56,7 +56,7 @@
</NcEmptyContent> </NcEmptyContent>
<!-- File list --> <!-- File list -->
<FilesListVirtual v-else :nodes="dirContents" /> <FilesListVirtual v-else ref="filesListVirtual" :nodes="dirContents" />
</NcAppContent> </NcAppContent>
</template> </template>
@ -116,6 +116,8 @@ export default Vue.extend({
return { return {
loading: true, loading: true,
promise: null, promise: null,
sortKey: 'basename',
sortAsc: true,
} }
}, },
@ -160,7 +162,18 @@ export default Vue.extend({
* @return {Node[]} * @return {Node[]}
*/ */
dirContents() { 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? // TODO: preserve selection on browsing?
this.selectionStore.reset() this.selectionStore.reset()
this.fetchContent() this.fetchContent()
},
paths(paths) { // Scroll to top, force virtual scroller to re-render
logger.debug('Paths changed', { paths }) if (this.$refs?.filesListVirtual?.$el) {
}, this.$refs.filesListVirtual.$el.scrollTop = 0
}
currentFolder(currentFolder) {
logger.debug('currentFolder changed', { currentFolder })
}, },
}, },

@ -107,6 +107,7 @@
"vue-multiselect": "^2.1.6", "vue-multiselect": "^2.1.6",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#feat/table", "vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#feat/table",
"vue-virtual-scroller": "^1.1.2",
"vuedraggable": "^2.24.3", "vuedraggable": "^2.24.3",
"vuex": "^3.6.2", "vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0", "vuex-router-sync": "^5.0.0",
@ -172,7 +173,8 @@
"wait-on": "^6.0.1", "wait-on": "^6.0.1",
"webpack": "^5.77.0", "webpack": "^5.77.0",
"webpack-cli": "^5.0.1", "webpack-cli": "^5.0.1",
"webpack-merge": "^5.8.0" "webpack-merge": "^5.8.0",
"workbox-webpack-plugin": "^6.5.4"
}, },
"browserslist": [ "browserslist": [
"extends @nextcloud/browserslist-config" "extends @nextcloud/browserslist-config"

@ -4,6 +4,8 @@ const path = require('path')
const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-modules-except') const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-modules-except')
const webpack = require('webpack') const webpack = require('webpack')
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const WorkboxPlugin = require('workbox-webpack-plugin')
const modules = require('./webpack.modules.js') const modules = require('./webpack.modules.js')
const formatOutputFromModules = (modules) => { const formatOutputFromModules = (modules) => {
@ -161,6 +163,37 @@ module.exports = {
// and global one). // and global one).
ICAL: 'ical.js', ICAL: 'ical.js',
}), }),
new WorkboxPlugin.GenerateSW({
swDest: 'preview-service-worker.js',
clientsClaim: true,
skipWaiting: true,
exclude: [/.*/], // don't do pre-caching
inlineWorkboxRuntime: true,
sourcemap: false,
// Define runtime caching rules.
runtimeCaching: [{
// Match any preview file request
// /apps/files_trashbin/preview?fileId=156380&a=1
// /core/preview?fileId=155842&a=1
urlPattern: /^.*\/(apps|core)(\/[a-z-_]+)?\/preview.*/i,
// Apply a strategy.
handler: 'CacheFirst',
options: {
// Use a custom cache name.
cacheName: 'previews',
// Only cache 10000 images.
expiration: {
maxAgeSeconds: 3600 * 24 * 7, // one week
maxEntries: 10000,
},
},
}],
}),
], ],
externals: { externals: {
OC: 'OC', OC: 'OC',