@samitouri / QOSami-HFS / commits / 885e337e

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]