admin/fs: expand/collapse button

Massimo Melina committed Oct 31, 2024 at 11:03 UTC af88b88c89eade3d625d683c5d3e0015041fa363
4 files changed +88 -75
admin/src/VfsMenuBar.ts
+3 -2
@@ -1,6 +1,6 @@
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 { createElement as h } from 'react'
3 +import { createElement as h, ReactNode } from 'react'
4 import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5 import { Storage } from '@mui/icons-material'
6 import { osIcon } from './LogsPage'
@@ -13,7 +13,7 @@ import { alertDialog, promptDialog } from './dialog'
13 import { formatDiskSpace } from './FilePicker'
14 import { getDiskSpaces } from '../../src/util-os'
15
16 -export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
16 +export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
17 return h(Flex, {
18 zIndex: 2,
19 backgroundColor: 'background.paper',
@@ -36,6 +36,7 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
36 .then(() => false), // no success-animation for IconBtn
37 alertDialog)
38 }),
39 + add,
40 h(SystemIntegrationButton, statusApi.data)
41 )
42 }
admin/src/VfsPage.ts
+1 -6
@@ -4,7 +4,6 @@ import { createElement as h, Fragment, useEffect, useMemo, useRef, useState } fr
4 import { apiCall, useApiEx } from './api'
5 import { Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
6 import { state, useSnapState } from './state'
7 -import VfsMenuBar from './VfsMenuBar'
7 import VfsTree, { vfsNodeIcon } from './VfsTree'
8 import { newDialog, onlyTruthy, prefix, VfsNodeAdminSend } from './misc'
9 import { Flex, useBreakpoint } from './mui'
@@ -145,11 +144,7 @@ export default function VfsPage() {
144 ),
145 h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
146 h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5, ...scrollProps },
148 - h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
149 - h(Typography, { variant: 'h6' }, "Virtual File System"),
150 - h(VfsMenuBar, { statusApi }),
151 - ),
152 - vfs && h(VfsTree, { id2node, statusApi }) ),
147 + h(VfsTree, { id2node, statusApi }) ),
148 isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
149 h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
150 h(CardContent, {}, sideContent)) )
admin/src/VfsTree.ts
+75 -67
@@ -1,14 +1,17 @@
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, useEffect, useRef, useState } from 'react'
4 +import { createElement as h, ReactElement, useCallback, useEffect, useRef, useState } from 'react'
5 import { TreeItem, TreeView } from '@mui/x-tree-view'
6 -import { ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
7 - RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff } from '@mui/icons-material'
8 -import { Box } from '@mui/material'
6 +import {
7 + ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
8 + RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff, UnfoldMore, UnfoldLess
9 +} from '@mui/icons-material'
10 +import { Box, Typography } from '@mui/material'
11 import { reloadVfs, VfsNode } from './VfsPage'
10 -import { onlyTruthy, Who, with_ } from './misc'
11 -import { iconTooltip } from './mui'
12 +import { onlyTruthy, useEffectOnce, Who, with_ } from './misc'
13 +import { Flex, iconTooltip, useToggleButton } from './mui'
14 +import VfsMenuBar from './VfsMenuBar'
15 import { apiCall, ApiObject } from './api'
16 import { alertDialog, confirmDialog } from './dialog'
17 import _ from 'lodash'
@@ -18,39 +21,9 @@ export const FileIcon = InsertDriveFileOutlined
21
22 export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject }) {
23 const { vfs, selectedFiles } = useSnapState()
21 - const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
24 + const [expanded, setExpanded] = useState<string[]>([])
25 const dragging = useRef<string>()
23 - const ref = useRef<HTMLUListElement>()
24 - if (!vfs)
25 - return null
26 - // be sure selected element is visible
27 - const treeId = 'vfs'
28 - const first = selectedFiles[0]
29 - useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }),
30 - [first])
31 - return h(TreeView, {
32 - // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
33 - ref,
34 - expanded,
35 - selected: selectedFiles.map(x => x.id),
36 - multiSelect: true,
37 - id: treeId,
38 - sx: {
39 - overflowX: 'auto',
40 - maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
41 - '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
42 - },
43 - onNodeSelect(ev, ids) {
44 - if (typeof ids === 'string') return // shut up ts
45 - state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
46 - }
47 - }, recur(vfs as Readonly<VfsNode>))
48 -
49 - function isRestricted(who: Who | undefined) {
50 - return who !== undefined && who !== true
51 - }
52 -
53 - function recur(node: Readonly<VfsNode>): ReactElement {
26 + const Branch = useCallback(function({ node }: { node: Readonly<VfsNode> }): ReactElement {
27 let { id, name, isRoot } = node
28 const folder = node.type === 'folder'
29 const ref = useRef<HTMLLIElement | null>()
@@ -61,36 +34,37 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
34 el?.addEventListener('focusin', (e: any) => e.stopImmediatePropagation())
35 ref.current = el
36 },
64 - label: h(Box, {
65 - draggable: !isRoot,
66 - onDragStart() {
67 - dragging.current = id
68 - },
69 - onDragOver(ev) {
70 - if (!folder) return
71 - const src = dragging.current
72 - if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
73 - ev.preventDefault()
37 + label:
38 + h(Box, {
39 + draggable: !isRoot,
40 + onDragStart() {
41 + dragging.current = id
42 + },
43 + onDragOver(ev) {
44 + if (!folder) return
45 + const src = dragging.current
46 + if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
47 + ev.preventDefault()
48 + },
49 + async onDrop() {
50 + const from = dragging.current
51 + if (!from) return
52 + if (await confirmDialog(`Moving ${from} under ${id}`))
53 + moveVfs(from, id)
54 + },
55 + sx: {
56 + display: 'flex',
57 + gap: '.5em',
58 + minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
59 + }
60 },
75 - async onDrop() {
76 - const from = dragging.current
77 - if (!from) return
78 - if (await confirmDialog(`Moving ${from} under ${id}`))
79 - moveVfs(from, id)
80 - },
81 - sx: {
82 - display: 'flex',
83 - gap: '.5em',
84 - minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
85 - }
86 - },
61 h(Box, { display: 'flex', flex: 0, },
62 vfsNodeIcon(node),
63 // attributes
64 h(Box, { sx: {
91 - flex: 0, ml: '2px', my: '2px', '&>*': { fontSize: '87%', opacity: .6, mt: '-2px' },
92 - display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
93 - } },
65 + flex: 0, ml: '2px', my: '2px', '&>*': { fontSize: '87%', opacity: .6, mt: '-2px' },
66 + display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
67 + } },
68 node.can_delete !== undefined && iconTooltip(Delete, "Delete permission"),
69 node.can_upload !== undefined && iconTooltip(Upload, "Upload permission"),
70 !isRoot && !node.source && !node.url && iconTooltip(Cloud, "Virtual (no source)"),
@@ -117,23 +91,57 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
91 key: name,
92 collapseIcon: h(ExpandMore, {
93 onClick(ev) {
120 - setExpanded( expanded.filter(x => x !== id) )
94 + setExpanded(was => was?.filter(x => x !== id) )
95 ev.preventDefault()
96 ev.stopPropagation()
97 }
98 }),
99 expandIcon: h(ChevronRight, {
100 onClick(ev) {
127 - setExpanded( [...expanded, id] )
101 + setExpanded(was => [...was!, id] )
102 ev.preventDefault()
103 ev.stopPropagation()
104 }
105 }),
106 nodeId: id
107 }, isRoot && !node.children?.length ? h(TreeItem, { nodeId: '?', label: h('i', {}, "nothing here") })
134 - : node.children?.map(recur))
135 - }
108 + : node.children?.map(x => h(Branch, { node: x})) )
109
110 + function isRestricted(who: Who | undefined) {
111 + return who !== undefined && who !== true
112 + }
113 + }, [setExpanded])
114 + const ref = useRef<HTMLUListElement>()
115 + const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({ icon: exp ? UnfoldLess : UnfoldMore, sx: {} }))
116 + useEffectOnce(() => setExpanded(expandAll ? Array.from(id2node.keys()) : ['/']), [expandAll])
117 + // be sure selected element is visible
118 + const treeId = 'vfs'
119 + const first = selectedFiles[0]
120 + useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }),
121 + [first])
122 + return h(Flex, { flexDirection: 'column', alignItems: 'stretch' },
123 + h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
124 + h(Typography, { variant: 'h6' }, "Virtual File System"),
125 + h(VfsMenuBar, { statusApi, add: toggleBtn }),
126 + ),
127 + vfs && h(TreeView, {
128 + // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
129 + ref,
130 + expanded,
131 + selected: selectedFiles.map(x => x.id),
132 + multiSelect: true,
133 + id: treeId,
134 + sx: {
135 + overflowX: 'auto',
136 + maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
137 + '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
138 + },
139 + onNodeSelect(ev, ids) {
140 + if (typeof ids === 'string') return // shut up ts
141 + state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
142 + }
143 + }, h(Branch, { node: vfs as Readonly<VfsNode> }))
144 + )
145 }
146
147 export function moveVfs(from: string, to: string) {
shared/react.ts
+9
@@ -137,6 +137,15 @@ export function useGetSize() {
137 }), [size, ref, propsRef])
138 }
139
140 +export function useEffectOnce(cb: Callback, deps: any[]) {
141 + const state = useRef<any>()
142 + useEffect(() => {
143 + if (_.isEqual(deps, state.current)) return
144 + state.current = deps
145 + cb(...deps)
146 + }, deps)
147 +}
148 +
149 type FunctionRef<T=HTMLElement> = (instance: (T | null)) => void
150 export function passRef<T=any>(el: T, ...refs: (MutableRefObject<T> | FunctionRef<T>)[]) {
151 for (const ref of refs)