Skip to content

Commit 2fd00fd

Browse files
committed
feat: Navigate via folder tree
Signed-off-by: Christopher Ng <chrng8@gmail.com>
1 parent e73ded9 commit 2fd00fd

12 files changed

Lines changed: 564 additions & 103 deletions

File tree

apps/files/src/actions/openFolderAction.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5-
import { Permission, Node, FileType, View, FileAction, DefaultType } from '@nextcloud/files'
5+
import { Permission, Node, FileType, View, FileAction, DefaultType, Folder } from '@nextcloud/files'
66
import { translate as t } from '@nextcloud/l10n'
77
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
88

9+
import { folderTreeId, getFolderTreeViewId } from '../services/FolderTree.ts'
10+
911
export const action = new FileAction({
1012
id: 'open-folder',
1113
displayName(files: Node[]) {
@@ -36,8 +38,21 @@ export const action = new FileAction({
3638
return false
3739
}
3840

41+
if (view.id === folderTreeId || view.params?.isFolderTreeChild === 'true') { // Navigate into folder tree views directly
42+
if (!(node instanceof Folder)) {
43+
return null
44+
}
45+
const viewId = getFolderTreeViewId(node)
46+
window.OCP.Files.Router.goToRoute(
47+
'filelist',
48+
{ view: viewId, fileid: String(node.fileid) },
49+
{ dir: node.path },
50+
)
51+
return null
52+
}
53+
3954
window.OCP.Files.Router.goToRoute(
40-
null,
55+
'filelist',
4156
{ view: view.id, fileid: String(node.fileid) },
4257
{ dir: node.path },
4358
)

apps/files/src/components/BreadCrumbs.vue

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,19 @@ import { defineComponent } from 'vue'
4242
import { Permission } from '@nextcloud/files'
4343
import { translate as t } from '@nextcloud/l10n'
4444
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
45+
import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
4546
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
4647
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
4748
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
4849
4950
import { useNavigation } from '../composables/useNavigation'
5051
import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService'
52+
import { folderTreeId, getFolderTreeViewId } from '../services/FolderTree.js'
5153
import { showError } from '@nextcloud/dialogs'
5254
import { useDragAndDropStore } from '../store/dragging.ts'
5355
import { useFilesStore } from '../store/files.ts'
5456
import { usePathsStore } from '../store/paths.ts'
57+
import { useFolderTreeStore } from '../store/folderTree.ts'
5558
import { useSelectionStore } from '../store/selection.ts'
5659
import { useUploaderStore } from '../store/uploader.ts'
5760
import filesListWidthMixin from '../mixins/filesListWidth.ts'
@@ -81,6 +84,7 @@ export default defineComponent({
8184
const draggingStore = useDragAndDropStore()
8285
const filesStore = useFilesStore()
8386
const pathsStore = usePathsStore()
87+
const folderTreeStore = useFolderTreeStore()
8488
const selectionStore = useSelectionStore()
8589
const uploaderStore = useUploaderStore()
8690
const { currentView } = useNavigation()
@@ -89,6 +93,7 @@ export default defineComponent({
8993
draggingStore,
9094
filesStore,
9195
pathsStore,
96+
folderTreeStore,
9297
selectionStore,
9398
uploaderStore,
9499
@@ -109,18 +114,21 @@ export default defineComponent({
109114
return this.dirs.map((dir: string, index: number) => {
110115
const source = this.getFileSourceFromPath(dir)
111116
const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined
112-
const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } }
113117
return {
114118
dir,
115119
exact: true,
116120
name: this.getDirDisplayName(dir),
117-
to,
121+
to: this.getTo(dir, node),
118122
// disable drop on current directory
119123
disableDrop: index === this.dirs.length - 1,
120124
}
121125
})
122126
},
123127
128+
isInFolderTree() {
129+
return this.currentView?.id === folderTreeId || this.currentView?.params?.isFolderTreeChild === 'true'
130+
},
131+
124132
isUploadInProgress(): boolean {
125133
return this.uploaderStore.queue.length !== 0
126134
},
@@ -134,6 +142,9 @@ export default defineComponent({
134142
135143
// used to show the views icon for the first breadcrumb
136144
viewIcon(): string {
145+
if (this.isInFolderTree) {
146+
return FolderMultipleSvg
147+
}
137148
return this.currentView?.icon ?? HomeSvg
138149
},
139150
@@ -151,10 +162,13 @@ export default defineComponent({
151162
return this.filesStore.getNode(source)
152163
},
153164
getFileSourceFromPath(path: string): FileSource | null {
154-
return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
165+
return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? this.folderTreeStore.getPath(path)
155166
},
156167
getDirDisplayName(path: string): string {
157168
if (path === '/') {
169+
if (this.isInFolderTree) {
170+
return t('files', 'All folders')
171+
}
158172
return this.$navigation?.active?.name || t('files', 'Home')
159173
}
160174
@@ -163,6 +177,33 @@ export default defineComponent({
163177
return node?.displayname || basename(path)
164178
},
165179
180+
getTo(dir: string, node?: Node): Record<string, unknown> {
181+
if (this.isInFolderTree && dir === '/') {
182+
return {
183+
name: 'filelist',
184+
params: { view: folderTreeId },
185+
}
186+
}
187+
if (node === undefined) {
188+
return {
189+
...this.$route,
190+
query: { dir },
191+
}
192+
}
193+
if (this.isInFolderTree) {
194+
return {
195+
name: 'filelist',
196+
params: { view: getFolderTreeViewId(node), fileid: String(node.fileid) },
197+
query: { dir: node.path },
198+
}
199+
}
200+
return {
201+
...this.$route,
202+
params: { fileid: node.fileid },
203+
query: { dir: node.path },
204+
}
205+
},
206+
166207
onClick(to) {
167208
if (to?.query?.dir === this.$route.query.dir) {
168209
this.$emit('reload')

apps/files/src/components/FileEntry/FileEntryName.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,10 @@ export default defineComponent({
305305
})
306306
307307
// Success 🎉
308-
emit('files:node:updated', this.source)
309-
emit('files:node:renamed', this.source)
308+
const node = this.source
309+
node.attributes['old-name'] = oldName
310+
emit('files:node:updated', node)
311+
emit('files:node:renamed', node)
310312
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
311313
312314
// Reset the renaming store
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<Fragment>
8+
<NcAppNavigationItem v-for="view in currentViews"
9+
:key="view.id"
10+
class="files-navigation__item"
11+
allow-collapse
12+
:data-cy-files-navigation-item="view.id"
13+
:exact="useExactRouteMatching(view)"
14+
:icon="view.iconClass"
15+
:name="view.name"
16+
:open="isExpanded(view)"
17+
:pinned="view.sticky"
18+
:to="generateToNavigation(view)"
19+
:style="style"
20+
@update:open="onToggleExpand(view)">
21+
<template v-if="view.icon" #icon>
22+
<NcIconSvgWrapper :svg="view.icon" />
23+
</template>
24+
25+
<!-- Recursively nest child views -->
26+
<FilesNavigationItem v-if="hasChildViews(view)"
27+
:parent="view"
28+
:level="level + 1"
29+
:views="filterView(views, parent.id)" />
30+
</NcAppNavigationItem>
31+
</Fragment>
32+
</template>
33+
34+
<script lang="ts">
35+
import type { PropType } from 'vue'
36+
import type { View } from '@nextcloud/files'
37+
38+
import { defineComponent } from 'vue'
39+
import { Fragment } from 'vue-frag'
40+
41+
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
42+
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
43+
44+
import { useNavigation } from '../composables/useNavigation.js'
45+
import { useViewConfigStore } from '../store/viewConfig.js'
46+
47+
const maxLevel = 7 // Limit nesting to not exceed max call stack size
48+
49+
export default defineComponent({
50+
name: 'FilesNavigationItem',
51+
52+
components: {
53+
Fragment,
54+
NcAppNavigationItem,
55+
NcIconSvgWrapper,
56+
},
57+
58+
props: {
59+
parent: {
60+
type: Object as PropType<View>,
61+
default: () => ({}),
62+
},
63+
level: {
64+
type: Number,
65+
default: 0,
66+
},
67+
views: {
68+
type: Object as PropType<Record<string, View[]>>,
69+
default: () => ({}),
70+
},
71+
},
72+
73+
setup() {
74+
const { currentView } = useNavigation()
75+
const viewConfigStore = useViewConfigStore()
76+
return {
77+
currentView,
78+
viewConfigStore,
79+
}
80+
},
81+
82+
computed: {
83+
currentViews(): View[] {
84+
if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
85+
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
86+
.filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
87+
}
88+
return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
89+
},
90+
91+
style() {
92+
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
93+
return null
94+
}
95+
return {
96+
'padding-left': '16px',
97+
}
98+
},
99+
},
100+
101+
methods: {
102+
hasChildViews(view: View): boolean {
103+
if (this.level >= maxLevel) {
104+
return false
105+
}
106+
return this.views[view.id]?.length > 0
107+
},
108+
109+
/**
110+
* Only use exact route matching on routes with child views
111+
* Because if a view does not have children (like the files view) then multiple routes might be matched for it
112+
* 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
113+
* @param view The view to check
114+
*/
115+
useExactRouteMatching(view: View): boolean {
116+
return this.hasChildViews(view)
117+
},
118+
119+
/**
120+
* Generate the route to a view
121+
* @param view View to generate "to" navigation for
122+
*/
123+
generateToNavigation(view: View) {
124+
if (view.params) {
125+
const { dir } = view.params
126+
return { name: 'filelist', params: { ...view.params }, query: { dir } }
127+
}
128+
return { name: 'filelist', params: { view: view.id } }
129+
},
130+
131+
/**
132+
* Check if a view is expanded by user config
133+
* or fallback to the default value.
134+
* @param view View to check if expanded
135+
*/
136+
isExpanded(view: View): boolean {
137+
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
138+
? this.viewConfigStore.getConfig(view.id).expanded === true
139+
: view.expanded === true
140+
},
141+
142+
/**
143+
* Expand/collapse a a view with children and permanently
144+
* save this setting in the server.
145+
* @param view View to toggle
146+
*/
147+
onToggleExpand(view: View) {
148+
// Invert state
149+
const isExpanded = this.isExpanded(view)
150+
// Update the view expanded state, might not be necessary
151+
view.expanded = !isExpanded
152+
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
153+
},
154+
155+
/**
156+
* Return the view map with the specified view id removed
157+
*
158+
* @param viewMap Map of views
159+
* @param id View id
160+
*/
161+
filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
162+
return Object.fromEntries(
163+
Object.entries(viewMap)
164+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
165+
.filter(([viewId, _views]) => viewId !== id),
166+
)
167+
},
168+
},
169+
})
170+
</script>

apps/files/src/composables/useNavigation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55
import type { View } from '@nextcloud/files'
6+
import type { ShallowRef } from 'vue'
67

78
import { getNavigation } from '@nextcloud/files'
8-
import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue'
9+
import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
910

1011
/**
1112
* Composable to get the currently active files view from the files navigation
@@ -28,6 +29,7 @@ export function useNavigation() {
2829
*/
2930
function onUpdateViews() {
3031
views.value = navigation.views
32+
triggerRef(views)
3133
}
3234

3335
onMounted(() => {

apps/files/src/eventbus.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ declare module '@nextcloud/event-bus' {
1010
'files:favorites:removed': Node
1111
'files:favorites:added': Node
1212
'files:node:renamed': Node
13+
'files:node:created': Node
14+
'files:node:deleted': Node
15+
'files:node:updated': Node
1316
}
1417
}
1518

apps/files/src/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import registerFavoritesView from './views/favorites'
2222
import registerRecentView from './views/recent'
2323
import registerPersonalFilesView from './views/personal-files'
2424
import registerFilesView from './views/files'
25+
import { registerFolderTreeView } from './views/folderTree.ts'
2526
import registerPreviewServiceWorker from './services/ServiceWorker.js'
2627

2728
import { initLivePhotos } from './services/LivePhotos'
@@ -48,6 +49,7 @@ registerFavoritesView()
4849
registerFilesView()
4950
registerRecentView()
5051
registerPersonalFilesView()
52+
registerFolderTreeView()
5153

5254
// Register preview service worker
5355
registerPreviewServiceWorker()

apps/files/src/newMenu/newFolder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const entry = {
6262
'mount-type': context.attributes?.['mount-type'],
6363
'owner-id': context.attributes?.['owner-id'],
6464
'owner-display-name': context.attributes?.['owner-display-name'],
65+
parentid: context.fileid,
6566
},
6667
})
6768

0 commit comments

Comments
 (0)