| 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 | } |