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) {