main
ts 198 lines 9.34 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, useState } from 'react'
4 import { apiCall, useApi, useApiList } from './api'
5 import _ from 'lodash'
6 import { Alert, Box, Checkbox, ListItemButton, ListItemIcon, ListItemText, TextField, Typography } from '@mui/material'
7 import { enforceFinal, formatBytes, isWindowsDrive, err2msg, basename, formatPerc } from './misc'
8 import { spinner, Center, IconBtn, Flex, IconProgress, useBreakpoint, Btn } from './mui'
9 import { ArrowUpward, CreateNewFolder, Storage, VerticalAlignTop } from '@mui/icons-material'
10 import { StringField } from '@hfs/mui-grid-form'
11 import { FileIcon, FolderIcon } from './VfsTree'
12 import { FixedSizeList } from 'react-window'
13 import { promptDialog } from './dialog'
14 import { LsEntry } from '../../src/api.vfs'
15
16 interface FilePickerProps {
17 onSelect:(v:string[])=>void
18 multiple?: boolean
19 from?: string
20 folders?: boolean
21 files?: boolean
22 fileMask?: string
23 }
24 let lastPath = '.'
25 export default function FilePicker({ onSelect, multiple=true, files=true, folders=true, fileMask, from=lastPath }: FilePickerProps) {
26 const [cwd, setCwd] = useState(from)
27 lastPath = cwd
28 const [ready, setReady] = useState(false)
29 const isWindows = useRef(false)
30 useApi(!ready && 'resolve_path', { path: from, closestFolder: true }, { onResponse: async (_res, body) => {
31 try {
32 const {path} = body
33 if (typeof path !== 'string') return
34 setCwd(path)
35 isWindows.current = path[1] === ':' || path.startsWith('\\\\') // drive or unc
36 }
37 finally {
38 setReady(true)
39 }
40 } })
41 const { list, props, error, connecting, reload } = useApiList<LsEntry>(ready && 'get_ls', { path: cwd, files, fileMask })
42 useEffect(() => {
43 setSel([])
44 setFilter('')
45 }, [cwd])
46
47 const [sel, setSel] = useState<string[]>([])
48 const [filter, setFilter] = useState('')
49 const setFilterBounced = useMemo(() => _.debounce((x:string) => setFilter(x)), [])
50 const filterMatch = useMemo(() => {
51 const re = new RegExp(_.escapeRegExp(filter), 'i')
52 return (v:string) => re.test(v)
53 }, [filter])
54
55 const sm = useBreakpoint('sm')
56 const [listHeight, setListHeight] = useState(0)
57 const filteredList = useMemo(() => _.sortBy(list.filter(it => filterMatch(it.n)), ['k', 'n']), [list, filterMatch])
58 const root = isWindows.current ? '' : '/'
59 const pathDelimiter = isWindows.current ? '\\' : '/' // should we use the new getHFS().pathSeparator instead?
60 const cwdDelimiter = enforceFinal(pathDelimiter, cwd)
61 const isRoot = cwd.length < 2
62 return h(Fragment, {},
63 h(StringField, {
64 label: "Current folder",
65 value: cwd,
66 InputLabelProps: { shrink: true },
67 helperText: "UNC paths are supported",
68 async onChange(v) {
69 if (!v)
70 return setCwd(root)
71 const res = await apiCall('resolve_path', { path: v })
72 if (res.isFolder === false) // the user entered a path to a file
73 return files ? onSelect([v]) // select it, if files are allowed
74 : setCwd(v.slice(0, -basename(v).length-1)) // otherwise consider its folder
75 setCwd(res.path)
76 },
77 end: h(Fragment, {},
78 h(IconBtn, {
79 title: "root",
80 disabled: isRoot,
81 icon: VerticalAlignTop,
82 onClick() {
83 setCwd(root)
84 }
85 }),
86 h(IconBtn, {
87 title: "parent folder",
88 disabled: isRoot,
89 icon: ArrowUpward,
90 onClick() {
91 const cwdND = /[\\/]$/.test(cwd) ? cwd.slice(0,-1) : cwd // exclude final delimiter, if any
92 const last = cwdND.lastIndexOf(pathDelimiter)
93 const isUNCroot = last === 1 // whe cwd is '\\host'
94 const parent = isWindowsDrive(cwdND) || isUNCroot ? root : cwdND.slice(0, last || 1)
95 setCwd(parent)
96 }
97 }),
98 )
99 }),
100 error ? h(Alert, { severity: 'error', sx: { flex: 1 } }, err2msg(error))
101 : h(Fragment, {},
102 h(Box, {
103 ref(x?: HTMLElement){
104 if (!x) return
105 const h = x?.clientHeight - 1
106 if (h - listHeight > 1)
107 setListHeight(h)
108 },
109 sx: { flex: 1, display: 'flex', flexDirection: 'column' }
110 },
111 !list.length ? h(Center as any, { sx: { flex: 1, mt: '4em' } }, connecting ? spinner() : "No elements in this folder")
112 : h(FixedSizeList, {
113 width: '100%', height: listHeight,
114 itemSize: 46, itemCount: filteredList.length, overscanCount: 5,
115 children({ index, style }) {
116 const it = filteredList[index]
117 const isFolder = it.k === 'd'
118 const selectionId = it.n + (isFolder ? pathDelimiter : '')
119 // mui v9 requires MenuItem under MenuList, while these virtualized rows are plain list buttons
120 return h(ListItemButton, {
121 style: { ...style, padding: 0 },
122 key: it.n,
123 onClick() {
124 if (isFolder)
125 setCwd(cwdDelimiter + it.n)
126 else
127 onSelect([cwdDelimiter + it.n])
128 }
129 },
130 multiple && h(Checkbox, {
131 checked: sel.includes(selectionId),
132 disabled: !folders && isFolder,
133 onClick(ev) {
134 const removed = sel.filter(x => x !== selectionId)
135 setSel(removed.length < sel.length ? removed : [...sel, selectionId])
136 ev.stopPropagation()
137 },
138 }),
139 h(ListLsItem, { it }),
140 )
141 }
142 })
143 ),
144 h(Flex, { alignItems: 'center' },
145 (multiple || folders || !files) && h(Btn, {
146 disabled: !sel.length && (!cwd || !folders && files), // !cwd is the drive selection on Windows, which is not a path
147 sx: { minWidth: 'max-content' },
148 onClick() {
149 onSelect(sel.length ? sel.map(x => cwdDelimiter + x) : [cwdDelimiter])
150 }
151 }, files && (sel.length || !folders) ? `Select (${sel.length})` : sm ? "Select this folder" : "This folder"),
152 folders && h(Btn, {
153 icon: CreateNewFolder,
154 variant: 'outlined',
155 doneMessage: true,
156 labelIf: 'sm',
157 async onClick() {
158 const s = await promptDialog("New folder name")
159 if (!s) return false
160 await apiCall('mkdir', { path: `${cwd}/${s}` })
161 reload()
162 }
163 }, "New folder"),
164 h(TextField, {
165 size: 'small',
166 value: filter,
167 label: `Filter results (${filteredList.length}${filteredList.length < list.length ? '/'+list.length : ''})`,
168 onChange(ev) {
169 setFilterBounced(ev.target.value)
170 },
171 sx: { flex: 1 },
172 }),
173 props?.total > 0 && h(IconProgress, {
174 icon: Storage,
175 progress: 1,
176 offset: (props.total - props.free) / props.total,
177 title: formatDiskSpace(props),
178 }),
179 ),
180 )
181 )
182 }
183
184 export function formatDiskSpace({ free, total }: { free: number, total: number }) {
185 return `${formatBytes(free)} available (${formatPerc(free / total)}) of ${formatBytes(total)}`
186 }
187
188 export function ListLsItem({ it }: { it: LsEntry }) {
189 return h(Fragment, {},
190 h(ListItemIcon, {}, h(it.k ? FolderIcon : FileIcon)),
191 h(ListItemText, { sx: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, it.n),
192 !it.k && it.s !== undefined && h(Typography, {
193 variant: 'body2',
194 color: 'text.secondary',
195 sx: { ml: 4, mr: 1 },
196 }, formatBytes(it.s))
197 )
198 }