aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-03-17 16:58:24 +0100
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-06 14:49:30 +0200
commitb761039cf1946cb64898b9117d1b15dd89080451 (patch)
tree27ef66889525e0fbe0f295d1bdc96fb3d8ac479c /apps/files
parent2ff1c00f556633c9c36a9328d4eb77eba2dd25e7 (diff)
downloadnextcloud-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.php5
-rw-r--r--apps/files/lib/Controller/ApiController.php20
-rw-r--r--apps/files/src/components/FileEntry.vue130
-rw-r--r--apps/files/src/components/FilesListVirtual.vue50
-rw-r--r--apps/files/src/main.js4
-rw-r--r--apps/files/src/services/ServiceWorker.js40
-rw-r--r--apps/files/src/views/FilesList.vue28
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
+ }
},
},