mirror of
https://github.com/nextcloud/server.git
synced 2026-02-19 02:38:40 -05:00
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:
parent
9def7a8ba7
commit
26b42e9f7c
4 changed files with 182 additions and 100 deletions
2
apps/files/src/eventbus.d.ts
vendored
2
apps/files/src/eventbus.d.ts
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue