feat: Load limited depth tree

Signed-off-by: Christopher Ng <chrng8@gmail.com>
pull/47122/head
Christopher Ng 2024-08-07 20:52:29 +07:00
parent cfec6fcb1a
commit 44bc57bc57
7 changed files with 109 additions and 41 deletions

@ -9,6 +9,7 @@
:key="view.id"
class="files-navigation__item"
allow-collapse
:loading="view.loading"
:data-cy-files-navigation-item="view.id"
:exact="useExactRouteMatching(view)"
:icon="view.iconClass"
@ -17,11 +18,14 @@
:pinned="view.sticky"
:to="generateToNavigation(view)"
:style="style"
@update:open="onToggleExpand(view)">
@update:open="(open) => onOpen(open, view)">
<template v-if="view.icon" #icon>
<NcIconSvgWrapper :svg="view.icon" />
</template>
<!-- Hack to force the collapse icon to be displayed -->
<li v-if="view.loadChildViews && !view.loaded" style="display: none" />
<!-- Recursively nest child views -->
<FilesNavigationItem v-if="hasChildViews(view)"
:parent="view"
@ -142,14 +146,18 @@ export default defineComponent({
/**
* Expand/collapse a a view with children and permanently
* save this setting in the server.
* @param view View to toggle
* @param open True if open
* @param view View
*/
onToggleExpand(view: View) {
async onOpen(open: boolean, view: View) {
// Invert state
const isExpanded = this.isExpanded(view)
// Update the view expanded state, might not be necessary
view.expanded = !isExpanded
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
if (open && view.loadChildViews) {
await view.loadChildViews(view)
}
},
/**

@ -6,6 +6,7 @@ import type { View } from '@nextcloud/files'
import type { ShallowRef } from 'vue'
import { getNavigation } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
/**
@ -35,6 +36,7 @@ export function useNavigation() {
onMounted(() => {
navigation.addEventListener('update', onUpdateViews)
navigation.addEventListener('updateActive', onUpdateActive)
subscribe('files:navigation:updated', onUpdateViews)
})
onUnmounted(() => {
navigation.removeEventListener('update', onUpdateViews)

@ -13,23 +13,17 @@ import {
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { dirname, encodePath } from '@nextcloud/paths'
import { dirname, encodePath, joinPaths } from '@nextcloud/paths'
import { getContents as getFiles } from './Files.ts'
export const folderTreeId = 'folders'
export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
interface TreeNodeData {
// eslint-disable-next-line no-use-before-define
type Tree = Array<{
id: number,
basename: string,
displayName?: string,
// eslint-disable-next-line no-use-before-define
children?: Tree,
}
interface Tree {
[basename: string]: TreeNodeData,
}
children: Tree,
}>
export interface TreeNode {
source: string,
@ -39,27 +33,35 @@ export interface TreeNode {
displayName?: string,
}
const getTreeNodes = (tree: Tree, nodes: TreeNode[] = [], currentPath: string = ''): TreeNode[] => {
for (const basename in tree) {
const path = `${currentPath}/${basename}`
export const folderTreeId = 'folders'
export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
const getTreeNodes = (tree: Tree, currentPath: string = '/', nodes: TreeNode[] = []): TreeNode[] => {
for (const { id, basename, displayName, children } of tree) {
const path = joinPaths(currentPath, basename)
const node: TreeNode = {
source: `${sourceRoot}${path}`,
path,
fileid: tree[basename].id,
fileid: id,
basename,
displayName: tree[basename].displayName,
}
if (displayName) {
node.displayName = displayName
}
nodes.push(node)
if (tree[basename].children) {
getTreeNodes(tree[basename].children, nodes, path)
if (children.length > 0) {
getTreeNodes(children, path, nodes)
}
}
return nodes
}
export const getFolderTreeNodes = async (): Promise<TreeNode[]> => {
const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'))
const nodes = getTreeNodes(tree)
export const getFolderTreeNodes = async (path: string = '/', depth: number = 1): Promise<TreeNode[]> => {
const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'), {
params: new URLSearchParams({ path, depth: String(depth) }),
})
const nodes = getTreeNodes(tree, path)
return nodes
}

@ -39,10 +39,11 @@
<script lang="ts">
import type { View } from '@nextcloud/files'
import type { ViewConfig } from '../types.ts'
import { emit } from '@nextcloud/event-bus'
import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { emit, subscribe } from '@nextcloud/event-bus'
import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
import IconCog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
@ -144,6 +145,11 @@ export default defineComponent({
},
},
created() {
subscribe('files:folder-tree:initialized', this.loadExpandedViews)
subscribe('files:folder-tree:expanded', this.loadExpandedViews)
},
beforeMount() {
// This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
const view = this.views.find(({ id }) => id === this.currentViewId)!
@ -152,6 +158,20 @@ export default defineComponent({
},
methods: {
async loadExpandedViews() {
const viewConfigs = this.viewConfigStore.getConfigs()
const viewsToLoad: View[] = (Object.entries(viewConfigs) as Array<[string, ViewConfig]>)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([viewId, config]) => config.expanded === true)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([viewId, config]) => this.$navigation.views.find(view => view.id === viewId))
.filter(Boolean) // Only registered views
.filter(view => view.loadChildViews && !view.loaded)
for (const view of viewsToLoad) {
await view.loadChildViews(view)
}
},
/**
* Set the view as active on the navigation and handle internal state
* @param view View to set active

@ -5,9 +5,10 @@
import type { TreeNode } from '../services/FolderTree.ts'
import PQueue from 'p-queue'
import { Folder, Node, View, getNavigation } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { subscribe } from '@nextcloud/event-bus'
import { emit, subscribe } from '@nextcloud/event-bus'
import { isSamePath } from '@nextcloud/paths'
import { loadState } from '@nextcloud/initial-state'
@ -29,6 +30,41 @@ const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).
const Navigation = getNavigation()
const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
const registerTreeNodes = async (path: string = '/') => {
await queue.add(async () => {
const nodes = await getFolderTreeNodes(path)
const promises = nodes.map(node => registerQueue.add(() => registerTreeNodeView(node)))
await Promise.allSettled(promises)
})
}
const getLoadChildViews = (node: TreeNode | Folder) => {
return async (view: View): Promise<void> => {
// @ts-expect-error Custom property on View instance
if (view.loaded) {
return
}
// @ts-expect-error Custom property
view.loading = true
try {
await registerTreeNodes(node.path)
} catch (error) {
// Skip duplicate view registration errors
}
// @ts-expect-error Custom property
view.loading = false
// @ts-expect-error Custom property
view.loaded = true
// @ts-expect-error No payload
emit('files:navigation:updated')
// @ts-expect-error No payload
emit('files:folder-tree:expanded')
}
}
const registerTreeNodeView = (node: TreeNode) => {
Navigation.register(new View({
id: encodeSource(node.source),
@ -40,6 +76,7 @@ const registerTreeNodeView = (node: TreeNode) => {
order: 0, // TODO Allow undefined order for natural sort
getContents,
loadChildViews: getLoadChildViews(node),
params: {
view: folderTreeId,
@ -60,6 +97,7 @@ const registerFolderView = (folder: Folder) => {
order: 0, // TODO Allow undefined order for natural sort
getContents,
loadChildViews: getLoadChildViews(folder),
params: {
view: folderTreeId,
@ -133,14 +171,12 @@ const registerFolderTreeRoot = () => {
}
const registerFolderTreeChildren = async () => {
const nodes = await getFolderTreeNodes()
for (const node of nodes) {
registerTreeNodeView(node)
}
await registerTreeNodes()
subscribe('files:node:created', onCreateNode)
subscribe('files:node:deleted', onDeleteNode)
subscribe('files:node:moved', onMoveNode)
// @ts-expect-error No payload
emit('files:folder-tree:initialized')
}
export const registerFolderTreeView = async () => {

14
package-lock.json generated

@ -20,7 +20,7 @@
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/dialogs": "^5.3.5",
"@nextcloud/event-bus": "^3.3.1",
"@nextcloud/files": "^3.7.0",
"@nextcloud/files": "^3.8.0",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
@ -4697,23 +4697,23 @@
"license": "MIT"
},
"node_modules/@nextcloud/files": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.7.0.tgz",
"integrity": "sha512-u7Hwt7/13empViLvwHPQk1AnKjhDYf7tkXeCLaO6e03am2uqBlYwc3iUS4cZye5CuaEeJeW251jPUGTtRXjjWQ==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.8.0.tgz",
"integrity": "sha512-5oi61suf2nDcXPTA4BSxl7EomJBCWrmc6ZGaokaj+jREOsSVlS+nR3ID/6eMqZSsqODpAARK56djyUPmiHOLWQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nextcloud/auth": "^2.3.0",
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.2.0",
"@nextcloud/paths": "^2.2.1",
"@nextcloud/router": "^3.0.1",
"@nextcloud/sharing": "^0.2.2",
"@nextcloud/sharing": "^0.2.3",
"cancelable-promise": "^4.3.1",
"is-svg": "^5.0.1",
"typedoc-plugin-missing-exports": "^3.0.0",
"typescript-event-target": "^1.1.1",
"webdav": "^5.6.0"
"webdav": "^5.7.0"
},
"engines": {
"node": "^20.0.0",

@ -48,7 +48,7 @@
"@nextcloud/capabilities": "^1.2.0",
"@nextcloud/dialogs": "^5.3.5",
"@nextcloud/event-bus": "^3.3.1",
"@nextcloud/files": "^3.7.0",
"@nextcloud/files": "^3.8.0",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",