From f258c66e822b74e9a4cd09b289bb3715b1501dcf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 11 Feb 2026 23:30:39 +0100 Subject: [PATCH] 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 --- apps/files/src/services/FolderTree.ts | 2 +- apps/files/src/views/folderTree.ts | 256 ++++++++++++++++---------- 2 files changed, 155 insertions(+), 103 deletions(-) diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts index 2ce937ec8af..c00aae1a98c 100644 --- a/apps/files/src/services/FolderTree.ts +++ b/apps/files/src/services/FolderTree.ts @@ -112,5 +112,5 @@ export function getSourceParent(source: string): string { if (parent === sourceRoot) { return folderTreeId } - return encodeSource(parent) + return `${folderTreeId}::${encodeSource(parent)}` } diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts index 6c1a255e1c8..3e854a6b66a 100644 --- a/apps/files/src/views/folderTree.ts +++ b/apps/files/src/views/folderTree.ts @@ -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 +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 Navigation = getNavigation() +const folderTreeView: IFolderTreeView = new View({ + id: folderTreeId, -const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + name: t('files', 'Folder tree'), + caption: t('files', 'List of your files and folders.'), -const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 }) + icon: FolderMultipleSvg, + order: 50, // Below all other views + + getContents, + + async loadChildViews(view) { + const treeView = view as IFolderTreeView + if (treeView.loading || treeView.loaded) { + return + } + + 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 + } + }, +}) /** - * - * @param path + * Register the folder tree feature */ -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) - }) +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 getLoadChildViews(node: TreeNode | Folder) { - return async (view: View): Promise => { - // @ts-expect-error Custom property on View instance - if (view.loading || view.loaded) { - return +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 } - // @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') + + views.push(generateNodeView( + node, + path === node.path || path?.startsWith(node.path + '/') ? true : undefined, + )) } + Navigation.register(...views) } /** + * Generates a navigation view for a given folder tree node or folder. * - * @param node + * @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 registerNodeView(node: TreeNode | Folder) { - const registeredView = Navigation.views.find((view) => view.id === node.encodedSource) - if (registeredView) { - Navigation.remove(registeredView.id) - } - if (!showHiddenFiles && node.basename.startsWith('.')) { - return - } - Navigation.register(new View({ - id: node.encodedSource, +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 node - The folder tree node or folder for which to generate the child view loader function. + */ +function getLoadChildViews(node: TreeNode | IFolder) { + return async (view: IView): Promise => { + 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 folder + * @param path - The path for which to register child views. Defaults to '/' for root nodes. */ -function removeFolderView(folder: Folder) { +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') - } -} - -/** - * - */ -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 + await updateTreeChildren() } - 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') }