admin/shared: persist tree expansion state across data-reload and page-change
Massimo Melina committed
Sep 4, 2025 at 15:40 UTC
885e337eea7606ef5149da2bbaee41fca873a8d8
3 files changed
+20
-11
admin/src/VfsTree.ts
+12
-11
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { state, useSnapState } from './state'
4
-import { createElement as h, ReactElement, useCallback, useEffect, useRef, useState, MouseEvent } from 'react'
4
+import { createElement as h, ReactElement, useCallback, useEffect, useRef, MouseEvent } from 'react'
5
import { TreeItem, TreeView } from '@mui/x-tree-view'
6
import {
7
ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
@@ -9,7 +9,7 @@ import {
9
} from '@mui/icons-material'
10
import { Box, Typography } from '@mui/material'
11
import { reloadVfs, VfsNode } from './VfsPage'
12
-import { onlyTruthy, useEffectOnce, Who, with_ } from './misc'
12
+import { onlyTruthy, toMutable, useEffectOnce, Who, with_ } from './misc'
13
import { Flex, iconTooltip, useToggleButton } from './mui'
14
import VfsMenuBar from './VfsMenuBar'
15
import { apiCall, ApiObject } from './api'
@@ -20,8 +20,7 @@ export const FolderIcon = Folder
20
export const FileIcon = InsertDriveFileOutlined
21
22
export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject }) {
23
- const { vfs, selectedFiles } = useSnapState()
24
- const [expanded, setExpanded] = useState<string[]>([])
23
+ const { vfs, selectedFiles, expanded } = useSnapState()
24
const dragging = useRef<string>()
25
const Branch = useCallback(function({ node }: { node: Readonly<VfsNode> }): ReactElement {
26
let { id, name, isRoot } = node
@@ -100,22 +99,24 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
99
}
100
101
function toggle(ev: MouseEvent<any>){
103
- setExpanded(was => was?.includes(id) ? was.filter(x => x !== id) : [...was!, id])
102
+ const was = state.expanded
103
+ state.expanded = was.includes(id) ? was.filter(x => x !== id) : [...was, id]
104
ev.preventDefault()
105
ev.stopPropagation()
106
}
107
- }, [setExpanded, statusApi.data])
107
+ }, [statusApi.data])
108
const ref = useRef<HTMLUListElement>(null)
109
const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({
110
icon: exp ? UnfoldLess : UnfoldMore,
111
sx: { rotate: exp ? 0 : '180deg' },
112
}))
113
- useEffectOnce(() => {
114
- setExpanded(expandAll ? Array.from(id2node.keys())
115
- : ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []]) // in case there's only one child, expand that too
113
+ useEffectOnce(() => { // this is also resetting the state at each mount
114
+ state.expanded = expandAll ? Array.from(id2node.keys())
115
+ : state.expanded.length ? state.expanded // keep previous state
116
+ : ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []] // in case there's only one child, expand that too
117
}, [expandAll, Boolean(vfs)]) // vfs is undefined on first render, we want to be called again as soon as it is loaded first time and not at reloads
118
useEffect(() => {
118
- setExpanded(was => _.uniq(was.concat(state.selectedFiles.map(x => x.parent?.id || ''))))
119
+ state.expanded = _.uniq(state.expanded.concat(state.selectedFiles.map(x => x.parent?.id || '')))
120
}, [state.vfs])
121
// be sure selected element is visible
122
const treeId = 'vfs'
@@ -129,7 +130,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
130
),
131
vfs && h(TreeView, {
132
ref,
132
- expanded,
133
+ expanded: toMutable(expanded),
134
selected: selectedFiles.map(x => x.id),
135
multiSelect: true,
136
id: treeId,
admin/src/state.ts
+1
@@ -15,6 +15,7 @@ const INIT = {
15
accountsAsTree: false,
16
movingFile: '',
17
vfs: undefined as VfsNode | undefined,
18
+ expanded: [] as string[],
19
loginRequired: false as boolean | number,
20
username: '',
21
monitorOnlyFiles: true,
src/cross.ts
+7
@@ -537,6 +537,13 @@ export function patchKey(o: any, k: string, replacer: (was: unknown) => unknown)
537
return o
538
}
539
540
+export type Mutable<T> = { -readonly [K in keyof T]: T[K] }
541
+export function toMutable<T>(value: readonly T[]): T[]
542
+export function toMutable<T extends object>(value: T): Mutable<T>
543
+export function toMutable(value: readonly unknown[] | object) {
544
+ return Array.isArray(value) ? value.slice() : { ...value }
545
+}
546
+
547
export function shortenAgent(agent: string) {
548
return _.findKey(BROWSERS, re => re.test(agent))
549
|| /^[^/(]+ ?/.exec(agent)?.[0]