main
ts 227 lines 10.6 KB
Raw
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 { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
4 import { createElement as h, ReactElement, useCallback, useEffect, useRef, MouseEvent } from 'react'
5 import { TreeItem, SimpleTreeView } from '@mui/x-tree-view'
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 { deleteVfs, id2vfsNode, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
12 import { onlyTruthy, pathDecode, pathEncode, prefix, toMutable, wantArray, WhoVfs, with_ } from './misc'
13 import { Flex, iconTooltip, useToggleButton } from './mui'
14 import VfsMenuBar from './VfsMenuBar'
15 import { ApiObject } from './api'
16 import { alertDialog, toast } from './dialog'
17 import _ from 'lodash'
18
19 export const FolderIcon = Folder
20 export const FileIcon = InsertDriveFileOutlined
21
22 let once = true
23
24 const SPECIAL_TREE_ITEM = '?'
25
26 export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
27 const { vfs, selectedFiles, expanded } = useSnapState()
28 const dragging = useRef<string>()
29 const Branch = useCallback(function({ node }: { node: Readonly<VfsNodeAdmin> }): ReactElement {
30 let { id, name, isRoot } = node
31 const isFolder = node.type === 'folder'
32 const ref = useRef<HTMLLIElement | null>()
33 if (isRoot && ref.current)
34 ref.current.firstElementChild?.classList.toggle('Mui-selected', !(selectedFiles.length && !_.find(selectedFiles, { id: '/' })))
35 const rootValue = pathDecode(id.slice(1))
36 const rootFor = _.findKey(statusApi.data?.roots, v => v === rootValue)
37 return h(TreeItem, {
38 ref(el) {
39 ref.current = el
40 },
41 onKeyUp(ev) {
42 if (ev.key === 'Delete') {
43 deleteVfs([id])
44 ev.stopPropagation()
45 }
46 },
47 onDoubleClick: toggle,
48 label: h(Box, {
49 draggable: !isRoot,
50 onDragStart() {
51 dragging.current = id
52 },
53 onDragOver(ev) {
54 if (!isFolder) return
55 const src = dragging.current
56 if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
57 ev.preventDefault()
58 },
59 async onDrop() {
60 const from = dragging.current
61 if (!from) return
62 const fromName = id2vfsNode.get(from)?.name // won't work after moving
63 if (moveVfs(from, id))
64 toast(`Moved "${fromName}" under "${id2vfsNode.get(id)?.name}"`, 'success')
65 },
66 sx: {
67 display: 'flex',
68 gap: '.5em',
69 minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
70 }
71 },
72 h(Box, { sx: { display: 'flex', flex: 0 } },
73 vfsNodeIcon(node),
74 // attributes, as icons
75 h(Box, {
76 sx: {
77 flex: 0, ml: '2px', my: '2px', '&>*': { fontSize: '87%', opacity: .6, mt: '-2px' },
78 display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
79 }
80 },
81 node.can_delete != null && iconTooltip(Delete, "Delete permission"),
82 node.can_upload != null && iconTooltip(Upload, "Upload permission"),
83 !isRoot && !node.source && !node.url && iconTooltip(Cloud, "Virtual (no source)"),
84 isRestricted(node.can_see) && iconTooltip(RemoveRedEye, "Restrictions on who can see"),
85 isRestricted(node.can_read) && iconTooltip(Lock, "Restrictions on who can download"),
86 node.default && iconTooltip(Web, "Show as web-page"),
87 node.masks && iconTooltip(TheaterComedy, "Masks"),
88 node.size === -1 && iconTooltip(HighlightOff, "Source not found"),
89 rootFor && iconTooltip(Home, `home for ${rootFor}`)
90 ),
91 ),
92 isRoot ? "Home folder" : name
93 ),
94 itemId: id
95 }, with_(node.source && isFolder ? "files from " + node.source : !node.children?.length && isRoot && "nothing here", x =>
96 x && h(TreeItem, { itemId: SPECIAL_TREE_ITEM + id, label: h('i', {}, x) })),
97 ...node.children?.map(x => h(Branch, { key: x.id, node: x })) || []
98 )
99
100 function isRestricted(who: WhoVfs | undefined) {
101 return who != null && who !== true
102 }
103
104 function toggle(ev: MouseEvent<any>){
105 const was = state.expanded
106 state.expanded = was.includes(id) ? was.filter(x => x !== id) : [...was, id]
107 ev.preventDefault()
108 ev.stopPropagation()
109 }
110 }, [statusApi.data])
111 const ref = useRef<HTMLUListElement>(null)
112 const allExpanded = id2vfsNode.size > 0 && expanded.length === id2vfsNode.size
113 const initialExpansion = ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []] // in case there's only one child, expand that too
114 if (once) {
115 once = false
116 state.expanded = initialExpansion
117 }
118 const [_expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({
119 icon: exp ? UnfoldLess : UnfoldMore,
120 sx: { rotate: exp ? 0 : '180deg' },
121 onClick() {
122 state.expanded = allExpanded ? initialExpansion : Array.from(id2vfsNode.keys())
123 }
124 }), allExpanded)
125 useEffect(() => {
126 state.expanded = _.uniq(state.expanded.concat(state.selectedFiles.map(x => x.parent?.id || '')))
127 }, [state.vfs])
128 // be sure the selected element is visible
129 const treeId = 'vfs'
130 const first = selectedFiles[0]
131 useEffect(() => { // scrollIntoView in modern browsers is returning a Promise
132 document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
133 }, [first])
134 return h(Flex, { flexDirection: 'column', alignItems: 'stretch', flex: 1 },
135 h(Flex, { mb: 1, flexWrap: 'wrap', gap: [1, 2], mt: '2px' /*account for the save button's outline*/ },
136 h(Typography, { variant: 'h6' }, "Virtual File System"),
137 h(VfsMenuBar, { statusApi, add: toggleBtn }),
138 ),
139 vfs && h(SimpleTreeView, {
140 ref,
141 expandedItems: toMutable(expanded),
142 expansionTrigger: 'iconContainer',
143 onExpandedItemsChange(_ev, ids) {
144 // keep placeholder helper rows out of expansion state to avoid persisting fake ids
145 state.expanded = wantArray(ids).filter((x): x is string => typeof x === 'string' && !x.startsWith(SPECIAL_TREE_ITEM))
146 },
147 selectedItems: selectedFiles.map(x => x.id),
148 multiSelect: true,
149 id: treeId,
150 sx: {
151 height: 0, flex: '1 1 auto',
152 overflowX: 'auto',
153 maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
154 '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
155 },
156 slots: {
157 collapseIcon: ExpandMore,
158 expandIcon: ChevronRight,
159 },
160 onSelectedItemsChange(_ev, ids) {
161 const selectedIds = wantArray(ids) as string[]
162 state.selectedFiles = onlyTruthy(selectedIds.map(id => id2vfsNode.get(id)))
163 // this is the only point where we have special node ids that don't fit selectedFiles
164 state.vfsShowDiskContentFor = selectedIds.length === 1
165 && selectedIds[0][0] === SPECIAL_TREE_ITEM
166 && id2vfsNode.get(selectedIds[0].slice(1))?.source || ''
167 }
168 }, h(Branch, { node: vfs as Readonly<VfsNodeAdmin> }))
169 )
170 }
171
172 export function moveVfs(from: string, to: string) {
173 const fromNode = id2vfsNode.get(from)
174 if (!fromNode)
175 return !alertDialog("Item to move not found", 'error')
176 if (fromNode.isRoot)
177 return !alertDialog("Cannot move root", 'error')
178 const toNode = id2vfsNode.get(to)
179 if (!toNode || toNode.type !== 'folder')
180 return !alertDialog("Destination folder not found", 'error')
181 if (isDescendantUri(to, from))
182 return !alertDialog("Cannot move inside itself", 'error')
183 if (toNode.children?.find(x => x.name === fromNode.name))
184 return !alertDialog("Item with same name already present in destination", 'error')
185 const oldSiblings = fromNode.parent?.children
186 if (!oldSiblings)
187 return !alertDialog("Source parent not found", 'error')
188 const fromParent = fromNode.parent
189 const movedName = fromNode.name
190 const movedIsFolder = fromNode.type === 'folder'
191 const destinationAncestors = getAncestorIds(toNode)
192 prepareVfsUndo()
193 _.remove(oldSiblings, { id: fromNode.id })
194 if (!oldSiblings.length && fromParent)
195 fromParent.children = undefined
196 addToChildrenOf(toNode, [fromNode])
197 const movedId = prefix(to, pathEncode(movedName), movedIsFolder ? '/' : '')
198 reindexVfs({ select: [movedId] })
199 state.expanded = _.uniq([...state.expanded, ...destinationAncestors])
200 return true
201
202 function getAncestorIds(node: VfsNodeAdmin) {
203 const ret: string[] = []
204 let cur: typeof node | undefined = node
205 while (cur) {
206 ret.push(cur.id)
207 cur = cur.parent
208 }
209 return ret
210 }
211 }
212
213 export function vfsNodeIcon(node: VfsNodeAdmin) {
214 return node.isRoot ? iconTooltip(Home, "home, or root if you like")
215 : node.type === 'folder' ? iconTooltip(FolderIcon, "Folder")
216 : node.url ? iconTooltip(Link, "Web-link")
217 : iconTooltip(FileIcon, "File")
218 }
219
220 export function addToChildrenOf(parent: VfsNodeAdmin, moreChildren: VfsNodeAdmin[]) {
221 if (!parent.children)
222 parent.children = []
223 // keep the assignment above and push separated: on proxied nodes, combining them will push to a stale array reference.
224 parent.children.push(...moreChildren)
225
226 markVfsModified()
227 }