]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat: Navigate via folder tree
authorChristopher Ng <chrng8@gmail.com>
Wed, 31 Jul 2024 01:19:55 +0000 (18:19 -0700)
committerChristopher Ng <chrng8@gmail.com>
Thu, 1 Aug 2024 16:17:56 +0000 (09:17 -0700)
Signed-off-by: Christopher Ng <chrng8@gmail.com>
apps/files/src/components/BreadCrumbs.vue
apps/files/src/components/FileEntry/FileEntryName.vue
apps/files/src/components/FilesNavigationItem.vue [new file with mode: 0644]
apps/files/src/composables/useNavigation.ts
apps/files/src/eventbus.d.ts
apps/files/src/init.ts
apps/files/src/services/FolderTree.ts [new file with mode: 0644]
apps/files/src/views/Navigation.vue
apps/files/src/views/folderTree.ts [new file with mode: 0644]

index d93330e1d29e3f98c605ecba17320fbf84e2b3aa..21777562d18e7bf9f4bca5c926d2a411edbd1398 100644 (file)
@@ -109,12 +109,11 @@ export default defineComponent({
                        return this.dirs.map((dir: string, index: number) => {
                                const source = this.getFileSourceFromPath(dir)
                                const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
-                               const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } }
                                return {
                                        dir,
                                        exact: true,
                                        name: this.getDirDisplayName(dir),
-                                       to,
+                                       to: this.getTo(dir, node),
                                        // disable drop on current directory
                                        disableDrop: index === this.dirs.length - 1,
                                }
@@ -163,6 +162,20 @@ export default defineComponent({
                        return node?.displayname || basename(path)
                },
 
+               getTo(dir: string, node?: Node): Record<string, unknown> {
+                       if (node === undefined) {
+                               return {
+                                       ...this.$route,
+                                       query: { dir },
+                               }
+                       }
+                       return {
+                               ...this.$route,
+                               params: { fileid: String(node.fileid) },
+                               query: { dir: node.path },
+                       }
+               },
+
                onClick(to) {
                        if (to?.query?.dir === this.$route.query.dir) {
                                this.$emit('reload')
index 1d45f7de17e60d5631754ba17cd9866f6cc858ec..439037b984ea5c5e3c247ce69123e18298360d8f 100644 (file)
@@ -45,6 +45,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
 import { emit } from '@nextcloud/event-bus'
 import { FileType, NodeStatus } from '@nextcloud/files'
 import { translate as t } from '@nextcloud/l10n'
+import { dirname } from '@nextcloud/paths'
 import { defineComponent, inject } from 'vue'
 
 import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
@@ -269,6 +270,10 @@ export default defineComponent({
                                // Success đźŽ‰
                                emit('files:node:updated', this.source)
                                emit('files:node:renamed', this.source)
+                               emit('files:node:moved', {
+                                       node: this.source,
+                                       oldSource: `${dirname(this.source.source)}/${oldName}`,
+                               })
                                showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
 
                                // Reset the renaming store
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
new file mode 100644 (file)
index 0000000..7550780
--- /dev/null
@@ -0,0 +1,170 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+       <Fragment>
+               <NcAppNavigationItem v-for="view in currentViews"
+                       :key="view.id"
+                       class="files-navigation__item"
+                       allow-collapse
+                       :data-cy-files-navigation-item="view.id"
+                       :exact="useExactRouteMatching(view)"
+                       :icon="view.iconClass"
+                       :name="view.name"
+                       :open="isExpanded(view)"
+                       :pinned="view.sticky"
+                       :to="generateToNavigation(view)"
+                       :style="style"
+                       @update:open="onToggleExpand(view)">
+                       <template v-if="view.icon" #icon>
+                               <NcIconSvgWrapper :svg="view.icon" />
+                       </template>
+
+                       <!-- Recursively nest child views -->
+                       <FilesNavigationItem v-if="hasChildViews(view)"
+                               :parent="view"
+                               :level="level + 1"
+                               :views="filterView(views, parent.id)" />
+               </NcAppNavigationItem>
+       </Fragment>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { View } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { Fragment } from 'vue-frag'
+
+import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+
+import { useNavigation } from '../composables/useNavigation.js'
+import { useViewConfigStore } from '../store/viewConfig.js'
+
+const maxLevel = 7 // Limit nesting to not exceed max call stack size
+
+export default defineComponent({
+       name: 'FilesNavigationItem',
+
+       components: {
+               Fragment,
+               NcAppNavigationItem,
+               NcIconSvgWrapper,
+       },
+
+       props: {
+               parent: {
+                       type: Object as PropType<View>,
+                       default: () => ({}),
+               },
+               level: {
+                       type: Number,
+                       default: 0,
+               },
+               views: {
+                       type: Object as PropType<Record<string, View[]>>,
+                       default: () => ({}),
+               },
+       },
+
+       setup() {
+               const { currentView } = useNavigation()
+               const viewConfigStore = useViewConfigStore()
+               return {
+                       currentView,
+                       viewConfigStore,
+               }
+       },
+
+       computed: {
+               currentViews(): View[] {
+                       if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
+                               return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
+                                       .filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
+                       }
+                       return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
+               },
+
+               style() {
+                       if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level
+                               return null
+                       }
+                       return {
+                               'padding-left': '16px',
+                       }
+               },
+       },
+
+       methods: {
+               hasChildViews(view: View): boolean {
+                       if (this.level >= maxLevel) {
+                               return false
+                       }
+                       return this.views[view.id]?.length > 0
+               },
+
+               /**
+                * Only use exact route matching on routes with child views
+                * Because if a view does not have children (like the files view) then multiple routes might be matched for it
+                * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
+                * @param view The view to check
+                */
+               useExactRouteMatching(view: View): boolean {
+                       return this.hasChildViews(view)
+               },
+
+               /**
+                * Generate the route to a view
+                * @param view View to generate "to" navigation for
+                */
+               generateToNavigation(view: View) {
+                       if (view.params) {
+                               const { dir } = view.params
+                               return { name: 'filelist', params: { ...view.params }, query: { dir } }
+                       }
+                       return { name: 'filelist', params: { view: view.id } }
+               },
+
+               /**
+                * Check if a view is expanded by user config
+                * or fallback to the default value.
+                * @param view View to check if expanded
+                */
+               isExpanded(view: View): boolean {
+                       return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
+                               ? this.viewConfigStore.getConfig(view.id).expanded === true
+                               : view.expanded === true
+               },
+
+               /**
+                * Expand/collapse a a view with children and permanently
+                * save this setting in the server.
+                * @param view View to toggle
+                */
+               onToggleExpand(view: View) {
+                       // Invert state
+                       const isExpanded = this.isExpanded(view)
+                       // Update the view expanded state, might not be necessary
+                       view.expanded = !isExpanded
+                       this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
+               },
+
+               /**
+                * Return the view map with the specified view id removed
+                *
+                * @param viewMap Map of views
+                * @param id View id
+                */
+               filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
+                       return Object.fromEntries(
+                               Object.entries(viewMap)
+                                       // eslint-disable-next-line @typescript-eslint/no-unused-vars
+                                       .filter(([viewId, _views]) => viewId !== id),
+                       )
+               },
+       },
+})
+</script>
index f410aec895fa8522edda7a2dc0168a7fa26b0024..2fff5633e230ec20208773427fd38f2a9a72e171 100644 (file)
@@ -3,9 +3,10 @@
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
 import type { View } from '@nextcloud/files'
+import type { ShallowRef } from 'vue'
 
 import { getNavigation } from '@nextcloud/files'
-import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue'
+import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
 
 /**
  * Composable to get the currently active files view from the files navigation
@@ -28,6 +29,7 @@ export function useNavigation() {
         */
        function onUpdateViews() {
                views.value = navigation.views
+               triggerRef(views)
        }
 
        onMounted(() => {
index db90c40eeaef28e16ca89e7f3cbf86d2e9dcf611..e1fd8c73b4b40bdab48a28700369533be72d6627 100644 (file)
@@ -11,7 +11,12 @@ declare module '@nextcloud/event-bus' {
 
                'files:favorites:removed': Node
                'files:favorites:added': Node
+
+               'files:node:created': Node
+               'files:node:deleted': Node
+               'files:node:updated': Node
                'files:node:renamed': Node
+               'files:node:moved': { node: Node, oldSource: string }
 
                'files:filter:added': IFileListFilter
                'files:filter:removed': string
index 4266453a4a3096f61adf8385398cc21b3647ffb7..846f1049d5ac58a24578bb9ffc949602821ca621 100644 (file)
@@ -27,6 +27,7 @@ import registerFavoritesView from './views/favorites'
 import registerRecentView from './views/recent'
 import registerPersonalFilesView from './views/personal-files'
 import registerFilesView from './views/files'
+import { registerFolderTreeView } from './views/folderTree.ts'
 import registerPreviewServiceWorker from './services/ServiceWorker.js'
 
 import { initLivePhotos } from './services/LivePhotos'
@@ -53,6 +54,7 @@ registerFavoritesView()
 registerFilesView()
 registerRecentView()
 registerPersonalFilesView()
+registerFolderTreeView()
 
 // Register file list filters
 registerHiddenFilesFilter()
diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts
new file mode 100644 (file)
index 0000000..87c3aaa
--- /dev/null
@@ -0,0 +1,90 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { CancelablePromise } from 'cancelable-promise'
+import {
+       davRemoteURL,
+       Folder,
+} from '@nextcloud/files'
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { dirname, encodePath } from '@nextcloud/paths'
+
+import { getContents as getFiles } from './Files.ts'
+
+export const folderTreeId = 'folders'
+export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}`
+
+interface TreeNodeData {
+       id: number,
+       displayName?: string,
+       // eslint-disable-next-line no-use-before-define
+       children?: Tree,
+}
+
+interface Tree {
+       [basename: string]: TreeNodeData,
+}
+
+export interface TreeNode {
+       source: string,
+       path: string,
+       fileid: number,
+       basename: string,
+       displayName?: string,
+}
+
+const getTreeNodes = (tree: Tree, nodes: TreeNode[] = [], currentPath: string = ''): TreeNode[] => {
+       for (const basename in tree) {
+               const path = `${currentPath}/${basename}`
+               const node: TreeNode = {
+                       source: `${sourceRoot}${path}`,
+                       path,
+                       fileid: tree[basename].id,
+                       basename,
+                       displayName: tree[basename].displayName,
+               }
+               nodes.push(node)
+               if (tree[basename].children) {
+                       getTreeNodes(tree[basename].children, nodes, path)
+               }
+       }
+       return nodes
+}
+
+export const getFolderTreeNodes = async (): Promise<TreeNode[]> => {
+       const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'))
+       const nodes = getTreeNodes(tree)
+       return nodes
+}
+
+export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path)
+
+export const encodeSource = (source: string): string => {
+       const { origin } = new URL(source)
+       return origin + encodePath(source.slice(origin.length))
+}
+
+export const getSourceParent = (source: string): string => {
+       const parent = dirname(source)
+       if (parent === sourceRoot) {
+               return folderTreeId
+       }
+       return encodeSource(parent)
+}
+
+export const getFolderTreeViewId = (folder: Folder): string => {
+       return folder.encodedSource
+}
+
+export const getFolderTreeParentId = (folder: Folder): string => {
+       if (folder.dirname === '/') {
+               return folderTreeId
+       }
+       return dirname(folder.encodedSource)
+}
index b0588863f5d7925e41c7a9bedf480ef7498af202..cca38824d2c81036ccbb180ec2a004909e099993 100644 (file)
@@ -4,38 +4,14 @@
 -->
 <template>
        <NcAppNavigation data-cy-files-navigation
+               class="files-navigation"
                :aria-label="t('files', 'Files')">
                <template #search>
                        <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" />
                </template>
                <template #default>
                        <NcAppNavigationList :aria-label="t('files', 'Views')">
-                               <NcAppNavigationItem v-for="view in parentViews"
-                                       :key="view.id"
-                                       :allow-collapse="true"
-                                       :data-cy-files-navigation-item="view.id"
-                                       :exact="useExactRouteMatching(view)"
-                                       :icon="view.iconClass"
-                                       :name="view.name"
-                                       :open="isExpanded(view)"
-                                       :pinned="view.sticky"
-                                       :to="generateToNavigation(view)"
-                                       @update:open="onToggleExpand(view)">
-                                       <!-- Sanitized icon as svg if provided -->
-                                       <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
-
-                                       <!-- Child views if any -->
-                                       <NcAppNavigationItem v-for="child in childViews[view.id]"
-                                               :key="child.id"
-                                               :data-cy-files-navigation-item="child.id"
-                                               :exact-path="true"
-                                               :icon="child.iconClass"
-                                               :name="child.name"
-                                               :to="generateToNavigation(child)">
-                                               <!-- Sanitized icon as svg if provided -->
-                                               <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" />
-                                       </NcAppNavigationItem>
-                               </NcAppNavigationItem>
+                               <FilesNavigationItem :views="viewMap" />
                        </NcAppNavigationList>
 
                        <!-- Settings modal-->
@@ -65,7 +41,7 @@
 import type { View } from '@nextcloud/files'
 
 import { emit } from '@nextcloud/event-bus'
-import { t } from '@nextcloud/l10n'
+import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
 import { defineComponent } from 'vue'
 
 import IconCog from 'vue-material-design-icons/Cog.vue'
@@ -73,9 +49,9 @@ import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
 import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
 import NcAppNavigationList from '@nextcloud/vue/dist/Components/NcAppNavigationList.js'
 import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
 import NavigationQuota from '../components/NavigationQuota.vue'
 import SettingsModal from './Settings.vue'
+import FilesNavigationItem from '../components/FilesNavigationItem.vue'
 
 import { useNavigation } from '../composables/useNavigation'
 import { useFilenameFilter } from '../composables/useFilenameFilter'
@@ -83,18 +59,26 @@ import { useFiltersStore } from '../store/filters.ts'
 import { useViewConfigStore } from '../store/viewConfig.ts'
 import logger from '../logger.ts'
 
+const collator = Intl.Collator(
+       [getLanguage(), getCanonicalLocale()],
+       {
+               numeric: true,
+               usage: 'sort',
+       },
+)
+
 export default defineComponent({
        name: 'Navigation',
 
        components: {
                IconCog,
+               FilesNavigationItem,
 
                NavigationQuota,
                NcAppNavigation,
                NcAppNavigationItem,
                NcAppNavigationList,
                NcAppNavigationSearch,
-               NcIconSvgWrapper,
                SettingsModal,
        },
 
@@ -129,28 +113,21 @@ export default defineComponent({
                        return this.$route?.params?.view || 'files'
                },
 
-               parentViews(): View[] {
-                       return this.views
-                               // filter child views
-                               .filter(view => !view.parent)
-                               // sort views by order
-                               .sort((a, b) => {
-                                       return a.order - b.order
-                               })
-               },
-
-               childViews(): Record<string, View[]> {
+               /**
+                * Map of parent ids to views
+                */
+               viewMap(): Record<string, View[]> {
                        return this.views
-                               // filter parent views
-                               .filter(view => !!view.parent)
-                               // create a map of parents and their children
-                               .reduce((list, view) => {
-                                       list[view.parent!] = [...(list[view.parent!] || []), view]
-                                       // Sort children by order
-                                       list[view.parent!].sort((a, b) => {
-                                               return a.order - b.order
+                               .reduce((map, view) => {
+                                       map[view.parent!] = [...(map[view.parent!] || []), view]
+                                       // TODO Allow undefined order for natural sort
+                                       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 list
+                                       return map
                                }, {} as Record<string, View[]>)
                },
        },
@@ -175,16 +152,6 @@ export default defineComponent({
        },
 
        methods: {
-               /**
-                * Only use exact route matching on routes with child views
-                * Because if a view does not have children (like the files view) then multiple routes might be matched for it
-                * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
-                * @param view The view to check
-                */
-               useExactRouteMatching(view: View): boolean {
-                       return this.childViews[view.id]?.length > 0
-               },
-
                /**
                 * Set the view as active on the navigation and handle internal state
                 * @param view View to set active
@@ -196,42 +163,6 @@ export default defineComponent({
                        emit('files:navigation:changed', view)
                },
 
-               /**
-                * Expand/collapse a a view with children and permanently
-                * save this setting in the server.
-                * @param view View to toggle
-                */
-               onToggleExpand(view: View) {
-                       // Invert state
-                       const isExpanded = this.isExpanded(view)
-                       // Update the view expanded state, might not be necessary
-                       view.expanded = !isExpanded
-                       this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
-               },
-
-               /**
-                * Check if a view is expanded by user config
-                * or fallback to the default value.
-                * @param view View to check if expanded
-                */
-               isExpanded(view: View): boolean {
-                       return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
-                               ? this.viewConfigStore.getConfig(view.id).expanded === true
-                               : view.expanded === true
-               },
-
-               /**
-                * Generate the route to a view
-                * @param view View to generate "to" navigation for
-                */
-               generateToNavigation(view: View) {
-                       if (view.params) {
-                               const { dir } = view.params
-                               return { name: 'filelist', params: view.params, query: { dir } }
-                       }
-                       return { name: 'filelist', params: { view: view.id } }
-               },
-
                /**
                 * Open the settings modal
                 */
@@ -272,4 +203,10 @@ export default defineComponent({
        // Prevent shrinking or growing
        flex: 0 0 auto;
 }
+
+.files-navigation {
+       :deep(.app-navigation__content > ul.app-navigation__list) {
+               will-change: scroll-position;
+       }
+}
 </style>
diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts
new file mode 100644 (file)
index 0000000..85c69e5
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { TreeNode } from '../services/FolderTree.ts'
+
+import { Folder, Node, View, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { subscribe } from '@nextcloud/event-bus'
+import { isSamePath } from '@nextcloud/paths'
+
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
+
+import {
+       encodeSource,
+       folderTreeId,
+       getContents,
+       getFolderTreeNodes,
+       getFolderTreeParentId,
+       getFolderTreeViewId,
+       getSourceParent,
+       sourceRoot,
+} from '../services/FolderTree.ts'
+
+const Navigation = getNavigation()
+
+const registerTreeNodeView = (node: TreeNode) => {
+       Navigation.register(new View({
+               id: encodeSource(node.source),
+               parent: getSourceParent(node.source),
+
+               name: node.displayName ?? node.basename,
+
+               icon: FolderSvg,
+               order: 0, // TODO Allow undefined order for natural sort
+
+               getContents,
+
+               params: {
+                       view: folderTreeId,
+                       fileid: String(node.fileid), // Needed for matching exact routes
+                       dir: node.path,
+               },
+       }))
+}
+
+const registerFolderView = (folder: Folder) => {
+       Navigation.register(new View({
+               id: getFolderTreeViewId(folder),
+               parent: getFolderTreeParentId(folder),
+
+               name: folder.displayname,
+
+               icon: FolderSvg,
+               order: 0, // TODO Allow undefined order for natural sort
+
+               getContents,
+
+               params: {
+                       view: folderTreeId,
+                       fileid: String(folder.fileid),
+                       dir: folder.path,
+               },
+       }))
+}
+
+const removeFolderView = (folder: Folder) => {
+       const viewId = getFolderTreeViewId(folder)
+       Navigation.remove(viewId)
+}
+
+const removeFolderViewSource = (source: string) => {
+       const Navigation = getNavigation()
+       Navigation.remove(source)
+}
+
+const onCreateNode = (node: Node) => {
+       if (!(node instanceof Folder)) {
+               return
+       }
+       registerFolderView(node)
+}
+
+const onDeleteNode = (node: Node) => {
+       if (!(node instanceof Folder)) {
+               return
+       }
+       removeFolderView(node)
+}
+
+const onMoveNode = ({ node, oldSource }) => {
+       if (!(node instanceof Folder)) {
+               return
+       }
+       removeFolderViewSource(oldSource)
+       registerFolderView(node)
+
+       const newPath = node.source.replace(sourceRoot, '')
+       const oldPath = oldSource.replace(sourceRoot, '')
+       const childViews = Navigation.views.filter(view => {
+               if (!view.params?.dir) {
+                       return false
+               }
+               if (isSamePath(view.params.dir, oldPath)) {
+                       return false
+               }
+               return view.params.dir.startsWith(oldPath)
+       })
+       for (const view of childViews) {
+               // @ts-expect-error FIXME Allow setting parent
+               view.parent = getFolderTreeParentId(node)
+               // @ts-expect-error dir param is defined
+               view.params.dir = view.params.dir.replace(oldPath, newPath)
+       }
+}
+
+const registerFolderTreeRoot = () => {
+       Navigation.register(new View({
+               id: folderTreeId,
+
+               name: t('files', 'All folders'),
+               caption: t('files', 'List of your files and folders.'),
+
+               icon: FolderMultipleSvg,
+               order: 50, // Below all other views
+
+               getContents,
+       }))
+}
+
+const registerFolderTreeChildren = async () => {
+       const nodes = await getFolderTreeNodes()
+       for (const node of nodes) {
+               registerTreeNodeView(node)
+       }
+
+       subscribe('files:node:created', onCreateNode)
+       subscribe('files:node:deleted', onDeleteNode)
+       subscribe('files:node:moved', onMoveNode)
+}
+
+export const registerFolderTreeView = async () => {
+       registerFolderTreeRoot()
+       await registerFolderTreeChildren()
+}