perf(files): initialize folder tree from current path and store

Initialize the folder tree based on the current directory.
Also only include views needed.

If possible reuse nodes from files store to prevent API call.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-02-11 23:30:39 +01:00
parent e5c1d80a00
commit 95d7b5608b
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
2 changed files with 160 additions and 108 deletions

View file

@ -112,5 +112,5 @@ export function getSourceParent(source: string): string {
if (parent === sourceRoot) {
return folderTreeId
}
return encodeSource(parent)
return `${folderTreeId}::${encodeSource(parent)}`
}

View file

@ -1,17 +1,17 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Node } from '@nextcloud/files'
import type { IFolder, INode, IView } from '@nextcloud/files'
import type { TreeNode } from '../services/FolderTree.ts'
import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw'
import { emit, subscribe } from '@nextcloud/event-bus'
import { subscribe } from '@nextcloud/event-bus'
import { FileType, getNavigation, View } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import { isSamePath } from '@nextcloud/paths'
import PQueue from 'p-queue'
import {
@ -21,70 +21,109 @@ import {
getSourceParent,
sourceRoot,
} from '../services/FolderTree.ts'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree
let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden
interface IFolderTreeView extends IView {
loading?: boolean
loaded?: boolean
}
const Navigation = getNavigation()
const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree
let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden
const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
const folderTreeView: IFolderTreeView = new View({
id: folderTreeId,
/**
*
* @param path
*/
async function registerTreeChildren(path: string = '/') {
await queue.add(async () => {
// preload up to 2 depth levels for faster navigation
const nodes = await getFolderTreeNodes(path, 2)
const promises = nodes.map((node) => registerQueue.add(() => registerNodeView(node)))
await Promise.allSettled(promises)
})
}
name: t('files', 'Folder tree'),
caption: t('files', 'List of your files and folders.'),
/**
*
* @param node
*/
function getLoadChildViews(node: TreeNode | Folder) {
return async (view: View): Promise<void> => {
// @ts-expect-error Custom property on View instance
if (view.loading || view.loaded) {
icon: FolderMultipleSvg,
order: 50, // Below all other views
getContents,
async loadChildViews(view) {
const treeView = view as IFolderTreeView
if (treeView.loading || treeView.loaded) {
return
}
// @ts-expect-error Custom property
view.loading = true
await registerTreeChildren(node.path)
// @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')
treeView.loading = true
try {
const dir = new URLSearchParams(window.location.search).get('dir') ?? '/'
const tree = await getFolderTreeNodes(dir, 1, true)
registerNodeViews(tree, dir)
treeView.loaded = true
subscribe('files:node:created', onCreateNode)
subscribe('files:node:deleted', onDeleteNode)
subscribe('files:node:moved', onMoveNode)
subscribe('files:config:updated', onUserConfigUpdated)
} finally {
treeView.loading = false
}
},
})
/**
* Register the folder tree feature
*/
export async function registerFolderTreeView() {
if (!isFolderTreeEnabled) {
return
}
Navigation.register(folderTreeView)
}
/**
* Helper to register node views in the navigation.
*
* @param node
* @param nodes - The nodes to register
* @param path - The path to expand by default, if any
*/
function registerNodeView(node: TreeNode | Folder) {
const registeredView = Navigation.views.find((view) => view.id === node.encodedSource)
if (registeredView) {
Navigation.remove(registeredView.id)
async function registerNodeViews(nodes: (TreeNode | IFolder)[], path?: string) {
const views: IView[] = []
for (const node of nodes) {
const isRegistered = Navigation.views.some((view) => view.id === `${folderTreeId}::${node.encodedSource}`)
// skip hidden files if the setting is disabled
if (!showHiddenFiles && node.basename.startsWith('.')) {
if (isRegistered) {
// and also remove any existing views for hidden files if the setting was toggled
Navigation.remove(`${folderTreeId}::${node.encodedSource}`)
}
continue
}
// skip already registered views to avoid duplicates when loading multiple levels
if (isRegistered) {
continue
}
views.push(generateNodeView(
node,
path === node.path || path?.startsWith(node.path + '/') ? true : undefined,
))
}
if (!showHiddenFiles && node.basename.startsWith('.')) {
return
}
Navigation.register(new View({
id: node.encodedSource,
Navigation.register(...views)
}
/**
* Generates a navigation view for a given folder tree node or folder.
*
* @param node - The folder tree node or folder for which to generate the view.
* @param expanded - Whether the view should be expanded by default.
*/
function generateNodeView(node: TreeNode | IFolder, expanded?: boolean): IView {
return {
id: `${folderTreeId}::${node.encodedSource}`,
parent: getSourceParent(node.source),
expanded,
loaded: expanded,
// @ts-expect-error Casing differences
name: node.displayName ?? node.displayname ?? node.basename,
@ -98,60 +137,109 @@ function registerNodeView(node: TreeNode | Folder) {
fileid: String(node.fileid), // Needed for matching exact routes
dir: node.path,
},
}))
}
}
/**
* Generates a function to load child views for a given folder tree node or folder.
* This function is used as the `loadChildViews` callback in the navigation view.
*
* @param folder
* @param node - The folder tree node or folder for which to generate the child view loader function.
*/
function removeFolderView(folder: Folder) {
function getLoadChildViews(node: TreeNode | IFolder) {
return async (view: IView): Promise<void> => {
const treeView = view as IFolderTreeView
if (treeView.loading || treeView.loaded) {
return
}
treeView.loading = true
try {
await updateTreeChildren(node.path)
treeView.loaded = true
} finally {
treeView.loading = false
}
}
}
/**
* Registers child views for the given path. If no path is provided, it registers the root nodes.
*
* @param path - The path for which to register child views. Defaults to '/' for root nodes.
*/
async function updateTreeChildren(path: string = '/') {
await queue.add(async () => {
const filesStore = useFilesStore(getPinia())
const cachedNodes = filesStore.getNodesByPath(Navigation.active!.id, path)
if (cachedNodes.length > 0) {
// if there are nodes loaded in the path we dont need to fetch from API
const folders = cachedNodes.filter((node) => node.type === FileType.Folder) as IFolder[]
registerNodeViews(folders, path)
} else {
// otherwise we need to fetch the tree nodes for the path
const nodes = await getFolderTreeNodes(path, 2)
registerNodeViews(nodes)
}
})
}
/**
* Remove a folder view from the navigation.
*
* @param folder - The folder for which to remove the view
*/
function removeFolderView(folder: IFolder) {
const viewId = folder.encodedSource
Navigation.remove(viewId)
}
/**
* Remove a folder view from the navigation by its source URL.
*
* @param source
* @param source - The source URL of the folder for which to remove the view
*/
function removeFolderViewSource(source: string) {
Navigation.remove(source)
}
/**
* Handle node creation events to add new folder tree views to the navigation.
*
* @param node
* @param node - The node that was created
*/
function onCreateNode(node: Node) {
function onCreateNode(node: INode) {
if (node.type !== FileType.Folder) {
return
}
registerNodeView(node)
registerNodeViews([node as IFolder])
}
/**
* Handle node deletion events to remove the corresponding folder tree views from the navigation.
*
* @param node
* @param node - The node that was deleted
*/
function onDeleteNode(node: Node) {
function onDeleteNode(node: INode) {
if (node.type !== FileType.Folder) {
return
}
removeFolderView(node)
removeFolderView(node as IFolder)
}
/**
* Handle node move events to update the folder tree views accordingly.
*
* @param root0
* @param root0.node
* @param root0.oldSource
* @param context - the event context
* @param context.node - The node that was moved
* @param context.oldSource - the old source URL of the moved node
*/
function onMoveNode({ node, oldSource }) {
if (node.type !== FileType.Folder) {
return
}
removeFolderViewSource(oldSource)
registerNodeView(node)
registerNodeViews([node as IFolder])
const newPath = node.source.replace(sourceRoot, '')
const oldPath = oldSource.replace(sourceRoot, '')
@ -165,58 +253,22 @@ function onMoveNode({ node, oldSource }) {
return view.params.dir.startsWith(oldPath)
})
for (const view of childViews) {
// @ts-expect-error FIXME Allow setting parent
view.parent = getSourceParent(node.source)
// @ts-expect-error dir param is defined
view.params.dir = view.params.dir.replace(oldPath, newPath)
view.params!.dir = view.params!.dir!.replace(oldPath, newPath)
}
}
/**
* Handle user config updates, specifically for the "show hidden files" setting,
* to show hidden folders in the folder tree when enabled and hide them when disabled.
*
* @param root0
* @param root0.key
* @param root0.value
* @param context - the event context
* @param context.key - the key of the updated config
* @param context.value - the new value of the updated config
*/
async function onUserConfigUpdated({ key, value }) {
if (key === 'show_hidden') {
showHiddenFiles = value
await registerTreeChildren()
// @ts-expect-error No payload
emit('files:folder-tree:initialized')
await updateTreeChildren()
}
}
/**
*
*/
function registerTreeRoot() {
Navigation.register(new View({
id: folderTreeId,
name: t('files', 'Folder tree'),
caption: t('files', 'List of your files and folders.'),
icon: FolderMultipleSvg,
order: 50, // Below all other views
getContents,
}))
}
/**
*
*/
export async function registerFolderTreeView() {
if (!isFolderTreeEnabled) {
return
}
registerTreeRoot()
await registerTreeChildren()
subscribe('files:node:created', onCreateNode)
subscribe('files:node:deleted', onDeleteNode)
subscribe('files:node:moved', onMoveNode)
subscribe('files:config:updated', onUserConfigUpdated)
// @ts-expect-error No payload
emit('files:folder-tree:initialized')
}