fix(files): reuse available date for folder tree

Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Co-authored-by: Louis <louis@chmn.me>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-02-05 17:30:35 +01:00
parent 9def7a8ba7
commit 26b42e9f7c
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
4 changed files with 182 additions and 100 deletions

View file

@ -12,6 +12,8 @@ declare module '@nextcloud/event-bus' {
'files:config:updated': { key: string, value: UserConfig[string] }
'files:view-config:updated': { key: string, value: string | number | boolean, IView: string }
'files:list:initialized': undefined
'files:favorites:added': INode
'files:favorites:removed': INode

View file

@ -285,6 +285,8 @@ export default defineComponent({
data() {
return {
initialized: false,
loading: true,
loadingAction: null as string | null,
error: null as string | null,
@ -497,6 +499,15 @@ export default defineComponent({
currentFolder() {
this.activeStore.activeFolder = this.currentFolder
// if not already initialized and we have a valid folder, we can consider the list as initialized
if (!this.initialized
&& this.currentFolder.fileid
&& this.currentFolder.fileid > 0
) {
this.initialized = true
this.$nextTick(async () => emit('files:list:initialized'))
}
},
currentView(newView, oldView) {

View file

@ -43,12 +43,12 @@
</template>
<script lang="ts">
import type { View } from '@nextcloud/files'
import type { ViewConfig } from '../types.ts'
import type { IView, View } from '@nextcloud/files'
import { emit, subscribe } from '@nextcloud/event-bus'
import { emit } from '@nextcloud/event-bus'
import { getNavigation } from '@nextcloud/files'
import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
import { watchDebounced } from '@vueuse/core'
import { defineComponent } from 'vue'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
@ -94,6 +94,19 @@ export default defineComponent({
const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const views = useViews()
watchDebounced(views, () => {
const expandedViews = Object.entries(viewConfigStore.viewConfigs)
.filter(([, config]) => config.expanded)
.map(([id]) => id)
const expandedViewsWithChildView = views.value
.filter((view) => 'loadChildViews' in view && view.loadChildViews)
.filter((view) => expandedViews.includes(view.id)) as (View & Pick<Required<IView>, 'loadChildViews'>)[]
for (const view of expandedViewsWithChildView) {
view.loadChildViews(view)
}
}, { debounce: 100 })
return {
t,
@ -102,7 +115,7 @@ export default defineComponent({
filtersStore,
viewConfigStore,
views: useViews(),
views,
}
},
@ -123,18 +136,18 @@ export default defineComponent({
/**
* Map of parent ids to views
*/
viewMap(): Record<string, View[]> {
viewMap(): Record<string, IView[]> {
return this.views
.reduce((map, view) => {
map[view.parent!] = [...(map[view.parent!] || []), view]
map[view.parent!].sort((a, b) => {
map[view.parent!]!.sort((a, b) => {
if (typeof a.order === 'number' || typeof b.order === 'number') {
return (a.order ?? 0) - (b.order ?? 0)
}
return collator.compare(a.name, b.name)
})
return map
}, {} as Record<string, View[]>)
}, {} as Record<string, IView[]>)
},
},
@ -150,11 +163,6 @@ 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)!
@ -163,23 +171,12 @@ export default defineComponent({
},
methods: {
async loadExpandedViews() {
const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>)
.filter(([, config]) => config.expanded === true)
.map(([viewId]) => this.views.find((view) => view.id === viewId))
.filter(Boolean as unknown as ((u: unknown) => u is View))
.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
*/
showView(view: View) {
showView(view: IView) {
this.sidebar.close()
getNavigation().setActive(view.id)
emit('files:navigation:changed', view)

View file

@ -1,17 +1,18 @@
/**
/*!
* 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 { FileType, getNavigation, View } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe } from '@nextcloud/event-bus'
import { FileType, Folder, 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,67 +22,128 @@ import {
getSourceParent,
sourceRoot,
} from '../services/FolderTree.ts'
import { useActiveStore } from '../store/active.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 registerQueue = 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
/**
*
* @param path
* Register the folder tree feature
*/
async function registerTreeChildren(path: string = '/') {
export async function registerFolderTreeView() {
if (!isFolderTreeEnabled) {
return
}
registerTreeRoot()
subscribe('files:list:initialized', () => registerTreeChildren())
}
/**
* Postponed registration of tree children to ensure the root view is registered and rendered first.
*/
async function registerTreeChildren() {
// because the files app is initialized we now have access to the stores
const activeStore = useActiveStore(getPinia())
const filesStore = useFilesStore(getPinia())
const currentPath = activeStore.activeFolder?.path ?? '/'
const views: IFolderTreeView[] = []
// if we are in a subfolder, register all parent folders first
const segments = currentPath.slice(1).split('/')
if (segments[0] !== '') {
const sourceSegments = activeStore.activeFolder!.source.split('/')
for (let i = 1; i <= segments.length; i++) {
const source = sourceSegments.slice(0, -i).join('/')
const node = filesStore.getNode(source)
if (node) {
// we have the node already loaded
views.push(generateNodeView(node as IFolder))
} else {
// fake this parent folder until we have it loaded
views.push(generateNodeView(new Folder({
owner: getCurrentUser()!.uid,
root: activeStore.activeFolder!.root,
source,
})))
}
}
}
// finally also add all views for folders in current view
const folders = filesStore.getNodesByPath(activeStore.activeView!.id, currentPath)
.filter((node) => node.type === FileType.Folder) as IFolder[]
// mark the current folder as loaded to avoid loading it again when navigating to it
const activeFolderView = views.find((view) => view.id === activeStore.activeFolder!.encodedSource)
if (activeFolderView) {
activeFolderView.loaded = true
}
if (folders.length > 0) {
views.push(...folders.map(generateNodeView))
}
if (views.length > 0) {
Navigation.register(...views)
}
subscribe('files:node:created', onCreateNode)
subscribe('files:node:deleted', onDeleteNode)
subscribe('files:node:moved', onMoveNode)
subscribe('files:config:updated', onUserConfigUpdated)
}
/**
* 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 () => {
// preload up to 2 depth levels for faster navigation
// preload 2 depth levels by default for faster navigation
const nodes = await getFolderTreeNodes(path, 2)
const promises = nodes.map((node) => registerQueue.add(() => registerNodeView(node)))
await Promise.allSettled(promises)
registerNodeViews(nodes)
})
}
/**
* 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
* @param node - The folder tree node or folder for which to generate the child view loader function.
*/
function getLoadChildViews(node: TreeNode | Folder) {
return async (view: View): Promise<void> => {
// @ts-expect-error Custom property on View instance
if (view.loading || view.loaded) {
function getLoadChildViews(node: TreeNode | IFolder) {
return async (view: IView): Promise<void> => {
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 {
await updateTreeChildren(node.path)
treeView.loaded = true
} finally {
treeView.loading = false
}
}
}
/**
* 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.
*/
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({
function generateNodeView(node: TreeNode | IFolder): IView {
return {
id: node.encodedSource,
parent: getSourceParent(node.source),
@ -98,21 +160,51 @@ function registerNodeView(node: TreeNode | Folder) {
fileid: String(node.fileid), // Needed for matching exact routes
dir: node.path,
},
}))
}
}
/**
* Helper to register node views in the navigation.
*
* @param folder
* @param nodes - The nodes to register
*/
function removeFolderView(folder: Folder) {
async function registerNodeViews(nodes: (TreeNode | IFolder)[]) {
const views: IView[] = []
for (const node of nodes) {
const isRegistered = Navigation.views.some((view) => view.id === 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(node.encodedSource)
}
continue
}
// skip already registered views to avoid duplicates when loading multiple levels
if (isRegistered) {
continue
}
views.push(generateNodeView(node))
}
Navigation.register(...views)
}
/**
* 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)
@ -122,22 +214,22 @@ function removeFolderViewSource(source: string) {
*
* @param node
*/
function onCreateNode(node: Node) {
function onCreateNode(node: INode) {
if (node.type !== FileType.Folder) {
return
}
registerNodeView(node)
registerNodeViews([node as IFolder])
}
/**
*
* @param node
*/
function onDeleteNode(node: Node) {
function onDeleteNode(node: INode) {
if (node.type !== FileType.Folder) {
return
}
removeFolderView(node)
removeFolderView(node as IFolder)
}
/**
@ -151,7 +243,7 @@ function onMoveNode({ node, oldSource }) {
return
}
removeFolderViewSource(oldSource)
registerNodeView(node)
registerNodeViews([node as IFolder])
const newPath = node.source.replace(sourceRoot, '')
const oldPath = oldSource.replace(sourceRoot, '')
@ -165,10 +257,8 @@ 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)
}
}
@ -181,14 +271,13 @@ function onMoveNode({ node, oldSource }) {
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()
}
}
/**
*
* Register the root view of the folder tree in the navigation.
* This is the entry point for the folder tree and will allow users to access their files and folders through the navigation menu.
*/
function registerTreeRoot() {
Navigation.register(new View({
@ -203,20 +292,3 @@ function registerTreeRoot() {
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')
}