perf(files): fetch previews faster and cache properly

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/36534/head
John Molakvoæ 2 years ago
parent 2ff1c00f55
commit b761039cf1
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
  1. 5
      apps/files/appinfo/routes.php
  2. 20
      apps/files/lib/Controller/ApiController.php
  3. 130
      apps/files/src/components/FileEntry.vue
  4. 50
      apps/files/src/components/FilesListVirtual.vue
  5. 4
      apps/files/src/main.js
  6. 40
      apps/files/src/services/ServiceWorker.js
  7. 28
      apps/files/src/views/FilesList.vue
  8. 4
      package.json
  9. 33
      webpack.common.js

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

@ -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;
}
}

@ -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>

@ -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>

@ -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()

@ -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>
<!-- 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
}
},
},

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

@ -4,6 +4,8 @@ const path = require('path')
const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-modules-except')
const webpack = require('webpack')
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const WorkboxPlugin = require('workbox-webpack-plugin')
const modules = require('./webpack.modules.js')
const formatOutputFromModules = (modules) => {
@ -161,6 +163,37 @@ module.exports = {
// and global one).
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: {
OC: 'OC',

Loading…
Cancel
Save