main
ts 289 lines 11.9 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 { createElement as h, Fragment, useEffect, useMemo, useRef } from 'react'
4 import { useApiEx, useApiList } from './api'
5 import {
6 Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography
7 } from '@mui/material'
8 import { LsEntry } from '../../src/api.vfs'
9 import { ListLsItem } from './FilePicker'
10 import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
11 import VfsTree, { vfsNodeIcon } from './VfsTree'
12 import {
13 CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait,
14 isWhoObject, PERM_KEYS, VfsPerms, WhoVfs,
15 } from './misc'
16 import { Flex, useBreakpoint } from './mui'
17 import { reactJoin } from '@hfs/shared'
18 import _ from 'lodash'
19 import FileForm, { useAccountsApi } from './FileForm'
20 import { Add, Delete } from '@mui/icons-material'
21 import { toast } from './dialog'
22 import { PageProps } from './App'
23
24 let selectOnReload: string[] | undefined
25 let exposeVfsLoading: Promise<unknown> | undefined
26 export const id2vfsNode = new Map<string, VfsNodeAdmin>()
27
28 export default function VfsPage({ setTitleSide }: PageProps) {
29 const { vfs, selectedFiles, movingFile, vfsShowDiskContentFor } = useSnapState()
30 const { data, reload, element, loading } = useApiEx('get_vfs')
31 exposeVfsLoading = loading
32 useEffect(() => {
33 if (!vfs)
34 reload()
35 }, [vfs, reload])
36 const { data: config } = useApiEx('get_config', { only: [CFG.force_address, CFG.base_url] })
37 const sideBreakpoint = 'md'
38 const isSideBreakpoint = useBreakpoint(sideBreakpoint)
39 const statusApi = useApiEx('get_status')
40 const { data: status } = statusApi
41 const urls = useMemo<string[]>(() => {
42 const force = config?.[CFG.force_address] // when force_address, we'll only suggest urls that will be accepted
43 const ret = (status?.urls.https || status?.urls.http)?.filter((url: string) => {
44 if (!force) return true
45 const host = normalizeHost(new URL(url).host)
46 return Object.keys(status.roots).some(mask => matches(host, mask))
47 })
48 const b = force ? config?.[CFG.base_url] : status?.baseUrl // when force_address, we should only consider user-inputted urls, otherwise 'automatic' is also good
49 if (b && ret && !ret.includes(b))
50 ret.unshift(b)
51 return ret
52 }, [status, config])
53 const accountsApi = useAccountsApi() // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
54 const diskContent = useApiList<LsEntry>(vfsShowDiskContentFor && 'get_ls', { path: vfsShowDiskContentFor })
55
56 // this will take care of closing the dialog, for the user's convenience, after "cut" button is pressed
57 const closeDialogRef = useRef(_.noop)
58 useEffect(() => {
59 if (movingFile === selectedFiles[0]?.id)
60 closeDialogRef.current()
61 }, [movingFile])
62
63 const nothingShared = data && !data.root?.children?.length && !data.root?.source
64 const hintElement = useMemo(() => nothingShared ? h(Alert, {
65 severity: 'warning',
66 children: h(Fragment, {}, "Add something to your virtual file system — click the ", h(Add), "button, or set a source for the Home folder"),
67 }) : urls?.length > 0 && h(Alert, {
68 severity: 'info',
69 children: [
70 "Your shared files can be browsed from ",
71 h('span', { className: HIDE_IN_TESTS, key: 0 },
72 reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href))) )
73 ]
74 }), [nothingShared, urls])
75
76 setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
77 h(Alert, { severity: 'info' }, "This is what your users will see. Edit it freely – files on disk won’t be changed."),
78 hintElement,
79 ), [hintElement]))
80
81 const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNodeAdmin
82 const sideContent = useMemo(() => !vfs ? null
83 : diskContent.enabled ? diskContent.element || h(Box, {},
84 h(Box, { sx: { fontSize: 'xx-large', wordBreak: 'break-all' } }, "From ", vfsShowDiskContentFor),
85 h(List, { dense: true },
86 diskContent.list.map(it =>
87 h(ListItem, { key: it.n, sx: { borderTop: '1px solid #8888' } }, h(ListLsItem, { it })))
88 )
89 )
90 : single ? h(FileForm, {
91 key: single.id,
92 isSideBreakpoint,
93 addToBar: isSideBreakpoint && h(Box, { sx: { flex: 1, textAlign: 'right', mr: 1, color: '#8883' } }, vfsNodeIcon(single)),
94 statusApi,
95 saved: () => closeDialogRef.current(),
96 accountsApi,
97 file: single
98 })
99 : !selectedFiles.length ? null
100 : h(Fragment, {},
101 h(Flex, {},
102 h(Typography, {variant: 'h6'}, selectedFiles.length + ' selected'),
103 h(Button, { onClick: deleteFiles, startIcon: h(Delete) }, "Remove"),
104 ),
105 h(List, { dense: true, disablePadding: true },
106 selectedFiles.map(f => h(ListItem, { key: f.id },
107 h(ListItemText, { primary: f.name, secondary: f.source }) ))
108 )
109 )
110 , [accountsApi.element, vfs, diskContent.list, single, selectedFiles])
111
112 useEffect(() => {
113 if (isSideBreakpoint || !sideContent) return
114 const ancestors = ['']
115 {
116 let r = single && single.parent
117 while (r && !r.isRoot) {
118 ancestors.push(r.name)
119 r = r.parent
120 }
121 }
122 const { close } = newDialog({
123 title: vfsShowDiskContentFor ? "Disk content"
124 : selectedFiles.length > 1 ? "Multiple selection" :
125 h(Flex, {},
126 vfsNodeIcon(selectedFiles[0] as VfsNodeAdmin),
127 h(Flex, { flexWrap: 'wrap', gap: '0 0.5em' },
128 selectedFiles[0].name || "Home",
129 h(Box, { component: 'span', sx: { color: 'text.secondary' } } as any, ancestors.join(' /'))
130 )
131 ),
132 dialogProps: { sx: { justifyContent: 'flex-end' } },
133 Content: () => sideContent,
134 onClose(auto) {
135 if (auto) return
136 state.selectedFiles = []
137 state.vfsShowDiskContentFor = ''
138 },
139 })
140 closeDialogRef.current = close
141 return () => void close(true) // true = auto-closing
142 }, [isSideBreakpoint, _.last(selectedFiles)?.id, sideContent])
143
144 useEffect(() => {
145 if (state.vfs || !data) return
146 const { root } = data
147 if (!root) return
148 root.isRoot = true
149 state.vfs = root
150 state.vfsUndo = undefined
151 reindexVfs({ sortChildren: true, select: consumeSelectOnReload() })
152 state.vfsModified = false
153
154 function consumeSelectOnReload() {
155 if (!selectOnReload) return
156 closeDialogRef.current() // this is noop when side-paneling
157 const ret = selectOnReload
158 selectOnReload = undefined
159 return ret
160 }
161
162 }, [data])
163 if (element && !state.vfs) {
164 id2vfsNode.clear()
165 return element
166 }
167 const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
168 return h(Grid, {
169 container: true,
170 rowSpacing: 1,
171 columnSpacing: 2,
172 sx: { top: 0, flex: '1 1 auto', height: 0 },
173 },
174 h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5 } as any, sx: scrollProps },
175 h(VfsTree, { statusApi }) ),
176 isSideBreakpoint && sideContent && h(Grid, { size: 'grow', sx: { ...scrollProps, maxWidth: '100%' } },
177 h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
178 h(CardContent, {}, sideContent)) )
179 )
180 }
181
182 export function reindexVfs({
183 node=state.vfs,
184 clearMap=true,
185 sortChildren=false,
186 select=state.selectedFiles,
187 }: {
188 node?: VfsNodeAdmin
189 clearMap?: boolean
190 sortChildren?: boolean
191 select?: VfsNodeAdmin[] | string[]
192 } = {}) {
193 if (!node) return
194 if (clearMap)
195 id2vfsNode.clear()
196 recur(node, node.parent?.id || '/', node.parent)
197 state.vfsShowDiskContentFor = ''
198 // Reindex can update ids/references; remap caller-provided selections to canonical nodes from id2node.
199 if (select)
200 state.selectedFiles = onlyTruthy(select.map(x => id2vfsNode.get(typeof x === 'string' ? x : x.id)))
201
202 function recur(node: VfsNodeAdmin, pre: string, parent: VfsNodeAdmin | undefined) {
203 const oldId = node.id
204 node.parent = parent
205 node.inherited = getInheritedPerms(node) // refresh cached inheritance while reindexing, because local edits do not get a server roundtrip
206 const newId = node.isRoot ? '/' : prefix(pre, pathEncode(node.name), node.type === 'folder' ? '/' : '')
207 if (oldId && oldId !== newId)
208 id2vfsNode.delete(oldId)
209 node.id = newId
210 node.originalId ||= newId // set only first value (all are truthy)
211 id2vfsNode.set(newId, node)
212 if (!node.children) return
213 if (sortChildren)
214 node.children = _.sortBy(node.children, ['type', x => x.name?.toLocaleLowerCase()])
215 for (const child of node.children)
216 recur(child, node.id, node)
217 }
218 }
219
220 export function getInheritedPerms(child: VfsNodeAdmin | undefined) {
221 const parent = child?.parent
222 if (!parent) return
223 const ret: VfsPerms = {}
224 for (const k of PERM_KEYS) {
225 const inheritedPerm = getInheritedPerm(parent, k)
226 // null is the form's local representation of an unset permission
227 if (inheritedPerm !== undefined && child[k] == null)
228 ret[k] = inheritedPerm
229 }
230 return _.isEmpty(ret) ? undefined : ret
231
232 function getInheritedPerm(cursor: VfsNodeAdmin | undefined, perm: keyof VfsPerms): WhoVfs | undefined {
233 while (cursor) {
234 let inheritedPerm = cursor[perm]
235 if (inheritedPerm != null) {
236 if (!isWhoObject(inheritedPerm))
237 return inheritedPerm
238 inheritedPerm = inheritedPerm.children
239 if (inheritedPerm !== undefined)
240 return inheritedPerm
241 }
242 cursor = cursor.parent
243 }
244 }
245 }
246
247 export function reloadVfs(pleaseSelect?: string[]) {
248 selectOnReload = pleaseSelect
249 state.vfs = undefined
250 return wait(100).then(() => exposeVfsLoading) // ensure the loading started and finished. Not great, but does the job. Consider a cleaner solution.
251 }
252
253 async function deleteFiles() {
254 const f = state.selectedFiles
255 if (!f.length) return
256 deleteVfs(f.map(x => x.id))
257 toast(`${f.length} item(s) deleted`, 'success')
258 }
259
260 export function deleteVfs(uris: string[]) {
261 const sorted = _.uniq(uris).sort()
262 const topLevelUris = sorted.filter((uri, idx) => uri !== '/'
263 && (idx === 0 || _.findLastIndex(sorted, parentUri => isDescendantUri(uri, parentUri), idx - 1) < 0))
264 if (!topLevelUris.length) return
265 prepareVfsUndo()
266 for (const uri of topLevelUris) {
267 const node = id2vfsNode.get(uri)!
268 const siblings = node.parent!.children!
269 _.remove(siblings, { id: node.id })
270 }
271 if (state.movingFile && topLevelUris.some(uri => state.movingFile === uri || isDescendantUri(state.movingFile, uri)))
272 state.movingFile = ''
273 markVfsModified()
274 }
275
276 export function isDescendantUri(childUri: string, parentUri: string) {
277 return parentUri.endsWith('/') && childUri.startsWith(parentUri)
278 }
279
280 export interface VfsNodeAdmin extends Omit<VfsNodeAdminSend, 'birthtime' | 'mtime' | 'children'> {
281 id: string
282 birthtime?: string
283 mtime?: string
284 default?: string
285 children?: VfsNodeAdmin[]
286 parent?: VfsNodeAdmin
287 isRoot?: true
288 originalId: string
289 }