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') }