@samitouri / QOSami-HFS / commits / 778e71f3

admin/shared: show content of folders with source

Massimo Melina committed Feb 20, 2026 at 12:41 UTC 778e71f38e578c9551aebadf322b17a2c9086f0a
7 files changed +84 -41
admin/src/FileForm.ts
+2 -2
@@ -15,7 +15,7 @@ import {
15 } from './misc'
16 import { isModifiedConfig } from './AccountForm'
17 import { Btn, Flex, IconBtn, LinkBtn, propsForModifiedValues, useBreakpoint, wikiLink } from './mui'
18 -import { deleteVfs, id2node, reindexVfs, VfsNodeAdmin } from './VfsPage'
18 +import { deleteVfs, id2vfsNode, reindexVfs, VfsNodeAdmin } from './VfsPage'
19 import _ from 'lodash'
20 import FileField from './FileField'
21 import { alertDialog, toast, useDialogBarColors } from './dialog'
@@ -136,7 +136,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
136 children: "Apply",
137 startIcon: h(Check),
138 async onClick() {
139 - const node = state.selectedFiles[0] || id2node.get(values.id)
139 + const node = state.selectedFiles[0] || id2vfsNode.get(values.id)
140 if (!node)
141 throw Error("Selected node not found")
142 const props = _.omit(values, ['birthtime','mtime','size','id'])
admin/src/FilePicker.ts
+14 -8
@@ -54,7 +54,7 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
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])
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 ? '\\' : '/'
60 const cwdDelimiter = enforceFinal(pathDelimiter, cwd)
@@ -135,13 +135,7 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
135 ev.stopPropagation()
136 },
137 }),
138 - h(ListItemIcon, {}, h(it.k ? FolderIcon : FileIcon)),
139 - h(ListItemText, { sx: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, it.n),
140 - !isFolder && it.s !== undefined && h(Typography, {
141 - variant: 'body2',
142 - color: 'text.secondary',
143 - ml: 4, mr: 1,
144 - }, formatBytes(it.s))
138 + h(ListLsItem, { it }),
139 )
140 }
141 })
@@ -188,4 +182,16 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
182
183 export function formatDiskSpace({ free, total }: { free: number, total: number }) {
184 return `${formatBytes(free)} available (${formatPerc(free / total)}) of ${formatBytes(total)}`
185 +}
186 +
187 +export function ListLsItem({ it }: { it: LsEntry }) {
188 + return h(Fragment, {},
189 + h(ListItemIcon, {}, h(it.k ? FolderIcon : FileIcon)),
190 + h(ListItemText, { sx: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, it.n),
191 + !it.k && it.s !== undefined && h(Typography, {
192 + variant: 'body2',
193 + color: 'text.secondary',
194 + ml: 4, mr: 1,
195 + }, formatBytes(it.s))
196 + )
197 }
\ No newline at end of file
admin/src/VfsPage.ts
+34 -17
@@ -1,12 +1,16 @@
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 } from './api'
5 -import { Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
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 {
9 - CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait
13 + CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait,
14 } from './misc'
15 import { Flex, useBreakpoint } from './mui'
16 import { reactJoin } from '@hfs/shared'
@@ -19,10 +23,10 @@ import { PageProps } from './App'
23
24 let selectOnReload: string[] | undefined
25 let exposeVfsLoading: Promise<unknown> | undefined
22 -export const id2node = new Map<string, VfsNodeAdmin>()
26 +export const id2vfsNode = new Map<string, VfsNodeAdmin>()
27
28 export default function VfsPage({ setTitleSide }: PageProps) {
25 - const { vfs, selectedFiles, movingFile } = useSnapState()
29 + const { vfs, selectedFiles, movingFile, vfsShowDiskContentFor } = useSnapState()
30 const { data, reload, element, loading } = useApiEx('get_vfs')
31 exposeVfsLoading = loading
32 useEffect(() => {
@@ -48,6 +52,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
52 }, [status, config])
53 const accountsApi = useApiEx<typeof apiAccounts.get_accounts>('get_accounts') // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
54 const accounts = useMemo(() => _.sortBy(accountsApi?.data?.list, 'username'), [accountsApi.data])
55 + const diskContent = useApiList<LsEntry>(vfsShowDiskContentFor && 'get_ls', { path: vfsShowDiskContentFor })
56
57 // this will take care of closing the dialog, for the user's convenience, after "cut" button is pressed
58 const closeDialogRef = useRef(_.noop)
@@ -75,7 +80,14 @@ export default function VfsPage({ setTitleSide }: PageProps) {
80 ), [hintElement]))
81
82 const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNodeAdmin
78 - const sideContent = accountsApi.element || !vfs || !selectedFiles.length ? null
83 + const sideContent = useMemo(() => accountsApi.element || !vfs ? null
84 + : diskContent.enabled ? diskContent.element || h(Box, {},
85 + h(Box, { fontSize: 'xx-large', sx: { wordBreak: 'break-all' } }, "From ", vfsShowDiskContentFor),
86 + h(List, { dense: true },
87 + diskContent.list.map(it =>
88 + h(ListItem, { key: it.n, sx: { borderTop: '1px solid #8888' } }, h(ListLsItem, { it })))
89 + )
90 + )
91 : single ? h(FileForm, {
92 key: single.id,
93 isSideBreakpoint,
@@ -85,6 +97,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
97 accounts: accounts ?? [],
98 file: single
99 })
100 + : !selectedFiles.length ? null
101 : h(Fragment, {},
102 h(Flex, {},
103 h(Typography, {variant: 'h6'}, selectedFiles.length + ' selected'),
@@ -95,9 +108,10 @@ export default function VfsPage({ setTitleSide }: PageProps) {
108 h(ListItemText, { primary: f.name, secondary: f.source }) ))
109 )
110 )
111 + , [accountsApi.element, vfs, diskContent.list, single, selectedFiles])
112
113 useEffect(() => {
100 - if (isSideBreakpoint || !sideContent || !selectedFiles.length) return
114 + if (isSideBreakpoint || !sideContent) return
115 const ancestors = ['']
116 {
117 let r = single && single.parent
@@ -107,7 +121,8 @@ export default function VfsPage({ setTitleSide }: PageProps) {
121 }
122 }
123 const { close } = newDialog({
110 - title: selectedFiles.length > 1 ? "Multiple selection" :
124 + title: vfsShowDiskContentFor ? "Disk content"
125 + : selectedFiles.length > 1 ? "Multiple selection" :
126 h(Flex, {},
127 vfsNodeIcon(selectedFiles[0] as VfsNodeAdmin),
128 h(Flex, { flexWrap: 'wrap', gap: '0 0.5em' },
@@ -117,13 +132,15 @@ export default function VfsPage({ setTitleSide }: PageProps) {
132 ),
133 dialogProps: { sx: { justifyContent: 'flex-end' } },
134 Content: () => sideContent,
120 - onClose() {
135 + onClose(auto) {
136 + if (auto) return
137 state.selectedFiles = []
138 + state.vfsShowDiskContentFor = ''
139 },
140 })
141 closeDialogRef.current = close
125 - return () => void close() // auto-close dialog if we are switching to side-panel
126 - }, [isSideBreakpoint, _.last(selectedFiles)?.id])
142 + return () => void close(true) // true = auto-closing
143 + }, [isSideBreakpoint, _.last(selectedFiles)?.id, sideContent])
144
145 useEffect(() => {
146 if (state.vfs || !data) return
@@ -145,7 +162,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
162
163 }, [data])
164 if (element && !state.vfs) {
148 - id2node.clear()
165 + id2vfsNode.clear()
166 return element
167 }
168 const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
@@ -171,20 +188,20 @@ export function reindexVfs({
188 } = {}) {
189 if (!node) return
190 if (clearMap)
174 - id2node.clear()
191 + id2vfsNode.clear()
192 recur(node, node.parent?.id || '/', node.parent)
193 // Reindex can update ids/references; remap caller-provided selections to canonical nodes from id2node.
194 if (select)
178 - state.selectedFiles = onlyTruthy(select.map(x => id2node.get(typeof x === 'string' ? x : x.id)))
195 + state.selectedFiles = onlyTruthy(select.map(x => id2vfsNode.get(typeof x === 'string' ? x : x.id)))
196
197 function recur(node: VfsNodeAdmin, pre: string, parent: VfsNodeAdmin | undefined) {
198 const oldId = node.id
199 node.parent = parent
200 const newId = node.isRoot ? '/' : prefix(pre, pathEncode(node.name), node.type === 'folder' ? '/' : '')
201 if (oldId && oldId !== newId)
185 - id2node.delete(oldId)
202 + id2vfsNode.delete(oldId)
203 node.id = newId
187 - id2node.set(newId, node)
204 + id2vfsNode.set(newId, node)
205 if (!node.children) return
206 if (sortChildren)
207 node.children = _.sortBy(node.children, ['type', x => x.name?.toLocaleLowerCase()])
@@ -213,7 +230,7 @@ export function deleteVfs(uris: string[]) {
230 if (!topLevelUris.length) return
231 prepareVfsUndo()
232 for (const uri of topLevelUris) {
216 - const node = id2node.get(uri)!
233 + const node = id2vfsNode.get(uri)!
234 const siblings = node.parent!.children!
235 _.remove(siblings, { id: node.id })
236 if (!siblings.length)
admin/src/VfsTree.ts
+16 -12
@@ -8,7 +8,7 @@ import {
8 RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff, UnfoldMore, UnfoldLess
9 } from '@mui/icons-material'
10 import { Box, Typography } from '@mui/material'
11 -import { id2node, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
11 +import { id2vfsNode, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
12 import { onlyTruthy, pathEncode, prefix, toMutable, wantArray, Who, with_ } from './misc'
13 import { Flex, iconTooltip, useToggleButton } from './mui'
14 import VfsMenuBar from './VfsMenuBar'
@@ -21,12 +21,14 @@ 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
29 - const folder = node.type === 'folder'
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: '/' })))
@@ -43,7 +45,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
45 dragging.current = id
46 },
47 onDragOver(ev) {
46 - if (!folder) return
48 + if (!isFolder) return
49 const src = dragging.current
50 if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
51 ev.preventDefault()
@@ -51,9 +53,9 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
53 async onDrop() {
54 const from = dragging.current
55 if (!from) return
54 - const fromName = id2node.get(from)?.name // won't work after moving
56 + const fromName = id2vfsNode.get(from)?.name // won't work after moving
57 if (moveVfs(from, id))
56 - toast(`Moved "${fromName}" under "${id2node.get(id)?.name}"`, 'success')
58 + toast(`Moved "${fromName}" under "${id2vfsNode.get(id)?.name}"`, 'success')
59 },
60 sx: {
61 display: 'flex',
@@ -87,8 +89,9 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
89 collapseIcon: h(ExpandMore, { onClick: toggle }),
90 expandIcon: h(ChevronRight, { onClick: toggle }),
91 nodeId: id
90 - }, isRoot && !node.children?.length ? h(TreeItem, { nodeId: '?', label: h('i', {}, "nothing here") })
91 - : node.children?.map(x => h(Branch, { key: x.id, node: x })) )
92 + }, with_(node.source && isFolder ? "files from " + node.source : !node.children?.length && isRoot && "nothing here", x => x && h(TreeItem, { nodeId: SPECIAL_TREE_ITEM + id, label: h('i', {}, x) })),
93 + ...node.children?.map(x => h(Branch, { key: x.id, node: x })) || []
94 + )
95
96 function isRestricted(who: Who | undefined) {
97 return who != null && who !== true
@@ -102,7 +105,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
105 }
106 }, [statusApi.data])
107 const ref = useRef<HTMLUListElement>(null)
105 - const allExpanded = id2node.size > 0 && expanded.length === id2node.size
108 + const allExpanded = id2vfsNode.size > 0 && expanded.length === id2vfsNode.size
109 const initialExpansion = ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []] // in case there's only one child, expand that too
110 if (once) {
111 once = false
@@ -112,7 +115,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
115 icon: exp ? UnfoldLess : UnfoldMore,
116 sx: { rotate: exp ? 0 : '180deg' },
117 onClick() {
115 - state.expanded = allExpanded ? initialExpansion : Array.from(id2node.keys())
118 + state.expanded = allExpanded ? initialExpansion : Array.from(id2vfsNode.keys())
119 }
120 }), allExpanded)
121 useEffect(() => {
@@ -141,19 +144,20 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
144 '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
145 },
146 onNodeSelect(_ev, ids) {
144 - state.selectedFiles = onlyTruthy(wantArray(ids).map(id => id2node.get(id)))
147 + state.selectedFiles = onlyTruthy(wantArray(ids).map(id => id2vfsNode.get(id)))
148 + state.vfsShowDiskContentFor = ids.length === 1 && ids[0]?.[0] === SPECIAL_TREE_ITEM && id2vfsNode.get(ids[0].slice(1))?.source || ''
149 }
150 }, h(Branch, { node: vfs as Readonly<VfsNodeAdmin> }))
151 )
152 }
153
154 export function moveVfs(from: string, to: string) {
151 - const fromNode = id2node.get(from)
155 + const fromNode = id2vfsNode.get(from)
156 if (!fromNode)
157 return !alertDialog("Item to move not found", 'error')
158 if (fromNode.isRoot)
159 return !alertDialog("Cannot move root", 'error')
156 - const toNode = id2node.get(to)
160 + const toNode = id2vfsNode.get(to)
161 if (!toNode || toNode.type !== 'folder')
162 return !alertDialog("Destination folder not found", 'error')
163 if (isDescendantUri(to, from))
admin/src/api.ts
+16 -1
@@ -171,7 +171,22 @@ export function useApiList<T=any, S=T>(cmd:string|Falsy, params: Dict={}, { map,
171 Object.assign(res, change)
172 })
173 }, [updateList])
174 - return { list: pausedList ?? list, props, loading, error, initializing, connecting, setList, updateList, updateEntry, reload }
174 + return {
175 + list: pausedList ?? list,
176 + props,
177 + loading,
178 + error,
179 + initializing,
180 + connecting,
181 + setList,
182 + updateList,
183 + updateEntry,
184 + reload,
185 + enabled: Boolean(cmd),
186 + element: connecting || initializing || loading ? spinner()
187 + : error ? h(Alert, { severity: 'error', sx: { flex: 1 } }, err2msg(error))
188 + : null
189 + }
190
191 function reload() {
192 setReloader(x => x + 1)
admin/src/state.ts
+1
@@ -12,6 +12,7 @@ const INIT = {
12 title: '',
13 config: {} as Dict,
14 selectedFiles: [] as VfsNodeAdmin[],
15 + vfsShowDiskContentFor: '',
16 accountsAsTree: false,
17 movingFile: '',
18 vfs: undefined as VfsNodeAdmin | undefined,
src/api.vfs.ts
+1 -1
@@ -190,7 +190,7 @@ export default {
190
191 get_disk_spaces: getDiskSpaces,
192
193 - get_ls({ path, files, fileMask }, ctx) {
193 + get_ls({ path, files=true, fileMask }, ctx) {
194 return new SendListReadable<LsEntry>({
195 async doAtStart(list) {
196 if (!path && IS_WINDOWS) {