Skip to content

Commit

Permalink
[WIP] feat: Add folder tree to sidebar
Browse files Browse the repository at this point in the history
Signed-off-by: Christopher Ng <[email protected]>
  • Loading branch information
Pytal committed Jul 18, 2024
1 parent 42d0a59 commit 8301f7e
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 101 deletions.
158 changes: 158 additions & 0 deletions apps/files/src/components/FilesNavigationItemList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<!--
- 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"
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)">
<!-- Sanitized icon as svg if provided -->
<template v-if="view.icon" #icon>
<NcIconSvgWrapper :svg="view.icon" />
</template>

<!-- Child views if any -->
<component :is="'FilesNavigationItemList'"
v-if="hasChildViews(view)"
:parent="view.id"
:level="level + 1"
:views="views.filter(view => view.parent !== parent)" />
</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 { useViewConfigStore } from '../store/viewConfig.js'
export default defineComponent({
name: 'FilesNavigationItemList',
components: {
Fragment,
NcAppNavigationItem,
NcIconSvgWrapper,
},
props: {
parent: {
type: String,
default: undefined, // Root level views have `undefined` parent ids
},
level: {
type: Number,
default: 0,
},
views: {
type: Array as PropType<View[]>,
default: () => [],
},
},
setup() {
const viewConfigStore = useViewConfigStore()
return {
viewConfigStore,
}
},
computed: {
currentViews(): View[] {
return this.views
.filter(view => view.parent === this.parent)
.toSorted((a, b) => a.order - b.order)
},
childViews(): Record<string, View[]> {
return this.views
.filter(view => view.parent !== this.parent)
// create a map of parents and their children
.reduce((list, view) => {
list[view.parent!] = [...(list[view.parent!] || []), view]
list[view.parent!].sort((a, b) => a.order - b.order)
return list
}, {} as Record<string, View[]>)
},
style() {
if (this.level === 0) {
return null
}
return {
'padding-left': '16px',
}
},
},
methods: {
hasChildViews(view: View): boolean {
return this.childViews[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)
},
},
})
</script>
5 changes: 3 additions & 2 deletions apps/files/src/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { Ref, ShallowRef, UnwrapRef } from 'vue'

import { getNavigation } from '@nextcloud/files'
import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue'
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'

/**
* Composable to get the currently active files view from the files navigation
*/
export function useNavigation() {
const navigation = getNavigation()
const views: ShallowRef<View[]> = shallowRef(navigation.views)
const views: Ref<UnwrapRef<View[]>> = ref(navigation.views)
const currentView: ShallowRef<View | null> = shallowRef(navigation.active)

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/files/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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'
Expand All @@ -48,6 +49,7 @@ registerFavoritesView()
registerFilesView()
registerRecentView()
registerPersonalFilesView()
registerFolderTreeView()

// Register preview service worker
registerPreviewServiceWorker()
Expand Down
82 changes: 82 additions & 0 deletions apps/files/src/services/FolderTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { ResponseDataDetailed, SearchResult } from 'webdav'
import type { ContentsWithRoot, Folder, Node } from '@nextcloud/files'

import { CancelablePromise } from 'cancelable-promise'
import { getCurrentUser } from '@nextcloud/auth'
import { getDavNameSpaces, getDavProperties } from '@nextcloud/files'
import { joinPaths } from '@nextcloud/paths'

import { client } from './WebdavClient.ts'
import { getContents, resultToNode } from './Files.ts'

export const folderTreeId = 'folders'

export const getFolders = (): CancelablePromise<Node[]> => {
const userId = getCurrentUser()?.uid
const searchPayload = `
<?xml version="1.0"?>
<d:searchrequest ${getDavNameSpaces()}>
<d:basicsearch>
<d:select>
<d:prop>
${getDavProperties()}
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/files/${userId}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:is-collection/>
</d:where>
</d:basicsearch>
</d:searchrequest>
`.trim()

const controller = new AbortController()
return new CancelablePromise(async (resolve, reject, onCancel) => {
onCancel(() => controller.abort())

try {
const { data } = await client.search('/', {
signal: controller.signal,
details: true,
data: searchPayload,
}) as ResponseDataDetailed<SearchResult>
const nodes = data.results.map(resultToNode)
resolve(nodes)
} catch (error) {
reject(error)
}
})
}

/**
* Get contents at path relative to folder
*
* @param folder The folder
* @param path The path
*/
export const getFolderContents = (folder: Folder, path = '/'): CancelablePromise<ContentsWithRoot> => {
return getContents(joinPaths(folder.path, path))
}

export const generateFolderTreeId = (folder: Folder): string => {
return `${folderTreeId}-${folder.fileid}` // FIXME fileid collision
}

export const generateFolderTreeParentId = (folder: Folder): string => {
if (folder.dirname === '/') {
return folderTreeId
}
return `${folderTreeId}-${folder.attributes.parentid}` // FIXME parentid collision
}
Loading

0 comments on commit 8301f7e

Please sign in to comment.