Skip to content

Commit

Permalink
perf(files): fetch previews faster and cache properly
Browse files Browse the repository at this point in the history
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
  • Loading branch information
skjnldsv committed Apr 6, 2023
1 parent 2ff1c00 commit b761039
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 35 deletions.
5 changes: 5 additions & 0 deletions apps/files/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@
'url' => '/directEditing/{token}',
'verb' => 'GET'
],
[
'name' => 'api#serviceWorker',
'url' => '/preview-service-worker.js',
'verb' => 'GET'
],
[
'name' => 'view#index',
'url' => '/{view}',
Expand Down
20 changes: 20 additions & 0 deletions apps/files/lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@
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;
Expand Down Expand Up @@ -417,4 +419,22 @@ public function getNodeType($folderpath) {
$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;
}
}
130 changes: 120 additions & 10 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->
Expand Down Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -96,10 +110,6 @@ export default Vue.extend({
},
props: {
index: {
type: Number,
required: true,
},
source: {
type: [File, Folder],
required: true,
Expand All @@ -118,6 +128,8 @@ export default Vue.extend({
data() {
return {
userConfig,
backgroundImage: '',
backgroundFailed: false,
}
},
Expand Down Expand Up @@ -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: {
Expand All @@ -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>
50 changes: 35 additions & 15 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -53,7 +60,8 @@ export default Vue.extend({
name: 'FilesListVirtual',
components: {
VirtualList,
RecycleScroller,
FileEntry,
FilesListHeader,
},
Expand All @@ -69,7 +77,6 @@ export default Vue.extend({
FileEntry,
}
},
computed: {
files() {
return this.nodes.filter(node => node.type === 'file')
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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>
4 changes: 4 additions & 0 deletions apps/files/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -57,3 +58,6 @@ FilesList.$mount('#app-content-vue')

// Init legacy files views
processLegacyFilesViews()

// Register preview service worker
registerPreviewServiceWorker()
Loading

0 comments on commit b761039

Please sign in to comment.