admin/shared: keep roots updated in case of vfs changes
Massimo Melina committed
May 22, 2026 at 16:45 UTC
1008e2ae75c3d94f6787814aeeb51431719873cc
6 files changed
+82
-13
admin/src/VfsMenuBar.ts
+13
-2
@@ -7,7 +7,7 @@ import addFiles, { addLink, addVirtual } from './addFiles'
7
import MenuButton from './MenuButton'
8
import { osIcon } from './LogsPage'
9
import { reloadVfs } from './VfsPage'
10
-import { prefix, VFS_STORED_KEYS } from './misc'
10
+import { Dict, prefix, VFS_STORED_KEYS } from './misc'
11
import { state, undoVfs, useSnapState } from './state'
12
import _ from 'lodash'
13
import { Btn, Flex, reloadBtn, useBreakpoint, useCtrlShortcutButton } from './mui'
@@ -34,7 +34,7 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
34
disabled: !vfsModified && "No changes to save",
35
modified: vfsModified,
36
doneAnimation: true,
37
- onClick: saveVfs
37
+ onClick: () => saveVfs().finally(statusApi.reload)
38
}),
39
h(Btn, {
40
icon: Undo,
@@ -109,13 +109,24 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined })
109
}
110
111
async function saveVfs() {
112
+ const uriRemaps: Dict<string> = {}
113
+ recurVfs(n => n.originalId !== n.id && (uriRemaps[n.originalId] = n.id))
114
await apiCall('set_vfs', {
115
uri: '/',
116
+ uriRemaps,
117
props: (function recur(n=state.vfs) {
118
const ret = _.pick(n, VFS_STORED_KEYS)
119
ret.children = n?.children?.map(recur) as any
120
return ret
121
})()
122
})
123
+ recurVfs(n => n.originalId = n.id) // reset
124
state.vfsModified = false
125
}
126
+
127
+function recurVfs(cb: (n: NonNullable<typeof state.vfs>) => any, node=state.vfs) {
128
+ if (!node) return
129
+ cb(node)
130
+ node?.children?.forEach(child => recurVfs(cb, child))
131
+ return node
132
+}
admin/src/VfsPage.ts
+2
@@ -207,6 +207,7 @@ export function reindexVfs({
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)
@@ -284,4 +285,5 @@ export interface VfsNodeAdmin extends Omit<VfsNodeAdminSend, 'birthtime' | 'mtim
285
children?: VfsNodeAdmin[]
286
parent?: VfsNodeAdmin
287
isRoot?: true
288
+ originalId: string
289
}
admin/src/addFiles.ts
+9
-7
@@ -7,7 +7,7 @@ import { reindexVfs, VfsNodeAdmin } from './VfsPage'
7
import { addToChildrenOf } from './VfsTree'
8
import { prepareVfsUndo, state } from './state'
9
import FilePicker from './FilePicker'
10
-import { basename, extname, focusSelector, getHFS } from '@hfs/shared'
10
+import { basename, extname, focusSelector, getHFS, Optional } from '@hfs/shared'
11
12
let lastFolder: undefined | string
13
export default function addFiles() {
@@ -24,7 +24,7 @@ export default function addFiles() {
24
h(FilePicker, {
25
from: lastFolder ?? parent.source,
26
async onSelect(sel) {
27
- addNodes(parent, sel.map(source => ({ source, name: basename(source), id: '' })))
27
+ addNodes(parent, sel.map(source => ({ source, name: basename(source) })))
28
lastFolder = sel[0].slice(0, sel[0].lastIndexOf('/'))
29
close()
30
}
@@ -34,16 +34,18 @@ export default function addFiles() {
34
})
35
}
36
37
-function addNodes(parent: VfsNodeAdmin, nodes: VfsNodeAdmin[]) {
37
+function addNodes(parent: VfsNodeAdmin, nodes: Optional<VfsNodeAdmin, 'id' | 'originalId'>[]) {
38
for (const n of nodes) {
39
if (n.source?.endsWith(getHFS().pathSeparator) || !n.source && !n.url)
40
n.type = 'folder'
41
n.id ||= parent.id + n.name + (n.type === 'folder' ? '/' : '')
42
+ n.originalId ||= n.id
43
n.parent = parent
44
}
45
prepareVfsUndo()
45
- addToChildrenOf(parent, nodes)
46
- reindexVfs({ select: nodes, sortChildren: true })
46
+ const select = nodes as VfsNodeAdmin[]
47
+ addToChildrenOf(parent, select)
48
+ reindexVfs({ select, sortChildren: true })
49
}
50
51
function getFreeName(parent: VfsNodeAdmin, name: string) {
@@ -72,7 +74,7 @@ export async function addVirtual() {
74
const parent = getFolderFromSelected()
75
name = getFreeName(parent, name)
76
if (!name) return
75
- addNodes(parent, [{ name, id: '', type: 'folder' }])
77
+ addNodes(parent, [{ name, type: 'folder' }])
78
}
79
catch(e) {
80
await alertDialog(e as Error)
@@ -84,7 +86,7 @@ export async function addLink() {
86
const parent = getFolderFromSelected()
87
const name = getFreeName(parent, 'new link')
88
if (!name) return
87
- addNodes(parent, [{ name, url: 'https://example.com', id: '' }])
89
+ addNodes(parent, [{ name, url: 'https://example.com' }])
90
toast("Link created", 'success', {
91
onClose: () => focusSelector('input[name=url]')
92
})
src/api.vfs.ts
+50
-2
@@ -9,7 +9,7 @@ import { mkdir } from 'fs/promises'
9
import { ApiError, ApiHandlers } from './apiMiddleware'
10
import { dirname, extname, join, resolve } from 'path'
11
import {
12
- enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, PERM_KEYS,
12
+ enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, pathDecode, pathEncode, PERM_KEYS,
13
VFS_STORED_KEYS, statWithTimeout, VfsNodeAdminSend
14
} from './misc'
15
import {
@@ -20,6 +20,7 @@ import { getDiskSpace, getDiskSpaces, getDrives, reg } from './util-os'
20
import { getBaseUrlOrDefault, getServerStatus } from './listen'
21
import { SendListReadable } from './SendList'
22
import { walkDir } from './walkDir'
23
+import { roots } from './roots'
24
25
// to manipulate the tree we need the original node
26
async function urlToNodeOriginal(uri: string) {
@@ -63,7 +64,7 @@ export default {
64
}
65
},
66
66
- async set_vfs({ uri, props }) {
67
+ async set_vfs({ uri, props, uriRemaps={} }) {
68
const n = uri && await urlToNodeOriginal(uri)
69
if (!n)
70
return new ApiError(HTTP_NOT_FOUND, 'path not found')
@@ -79,7 +80,14 @@ export default {
80
simplifyName(n)
81
n.isFolder = undefined // reset field, it will be set by saveVfs
82
await saveVfs()
83
+ if (!isRoot(n)) // not actually used by admin-panel but still
84
+ uriRemaps[uri] = uriForNode(uri, n) // just in case the current node was modified
85
+ await updateRootsForVfsUriRemaps(uriRemaps)
86
return n
87
+
88
+ function uriForNode(uri: string, n: VfsNode) {
89
+ return enforceFinal('/', dirname(uri).replace(/\\/g, '/')) + pathEncode(getNodeName(n)) + (nodeIsFolder(n) ? '/' : '')
90
+ }
91
},
92
93
// legacy – not currently used by the UI
@@ -106,6 +114,9 @@ export default {
114
}
115
;(parentNode.children ||= []).push(fromNode)
116
await saveVfs()
117
+ await updateRootsForVfsUriRemaps({
118
+ [from]: enforceFinal('/', parent) + pathEncode(name) + (nodeIsFolder(fromNode) ? '/' : '')
119
+ })
120
return {}
121
},
122
@@ -294,6 +305,43 @@ function sanitizeVfsProps(props: any) {
305
return ret
306
}
307
308
+function updateRootsForVfsUriRemaps(uriRemaps={}) {
309
+ const remaps = Object.entries(uriRemaps)
310
+ .map(([from, to]) => [normalizeVfsId(from), normalizeVfsId(to)] as const)
311
+ .filter(x => x[0] !== x[1])
312
+ .sort(([a], [b]) => b.length - a.length) // longest first because, in case of both parent and child, it's more specific
313
+ let changed = false
314
+ const updatedRoots = _.mapValues(roots.get(), root => {
315
+ if (typeof root !== 'string' || !root) return root
316
+ const normalizedRoot = normalize(root)
317
+ for (const [from, to] of remaps) {
318
+ // roots are stored outside the VFS tree, so rename/move edits need an explicit path remap
319
+ const remappedRoot = replaceUriPrefix(normalizedRoot, from, to)
320
+ if (!remappedRoot) continue
321
+ if (remappedRoot !== root)
322
+ changed = true
323
+ return remappedRoot
324
+ }
325
+ return root
326
+ })
327
+ if (changed)
328
+ roots.set(updatedRoots)
329
+
330
+ function normalize(uri: unknown) {
331
+ return String(uri).replace(/^\/+|^(?!\/)|\/{2,}|\/+$|(?<!\/)$/g, '/')
332
+ }
333
+
334
+ function normalizeVfsId(uri: unknown) {
335
+ return normalize(pathDecode(String(uri))) // admin remaps come from tree ids, while roots are stored as readable VFS paths
336
+ }
337
+
338
+ function replaceUriPrefix(uri: string, oldPrefix: string, newPrefix: string) {
339
+ return uri === oldPrefix ? newPrefix
340
+ : uri.startsWith(oldPrefix) ? newPrefix + uri.slice(oldPrefix.length)
341
+ : ''
342
+ }
343
+}
344
+
345
export function simplifyName(node: VfsNode) {
346
const { name, ...noName } = node
347
if (getNodeName(noName) === name)
src/roots.ts
+1
-1
@@ -43,7 +43,7 @@ export const rootsMiddleware: Koa.Middleware = (ctx, next) =>
43
if (!params) return
44
for (const [k, v] of Object.entries(params))
45
if (k.startsWith('uri'))
46
- params[k] = Array.isArray(v) ? v.map(cb) : cb(v)
46
+ params[k] = Array.isArray(v) ? v.map(cb) : _.isString(v) ? cb(v) : v
47
}
48
})() || next()
49
tests/test.ts
+7
-1
@@ -1167,12 +1167,15 @@ describe('admin', () => {
1167
await reqApi('del_vfs', { uris: ['/' + name] }, data => [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {})
1168
})
1169
test('set_vfs.rename and props', async () => {
1170
- const name = `set-vfs-${randomId(6)}`
1170
+ const name = `set vfs ${randomId(6)}`
1171
const renamed = `${name}-renamed`
1172
const uri = '/' + name
1173
const renamedUri = '/' + renamed
1174
+ const rootsHost = `set-vfs-${randomId(6)}.example.com`
1175
+ const oldRoots = await reqApi('get_config', { only: ['roots'] }, 200, { auth })().then(res => res.roots)
1176
try {
1177
await reqApi('add_vfs', { source: '.', name }, 200, { auth })()
1178
+ await reqApi('set_config', { values: { roots: { ...oldRoots, [rootsHost]: uri } } }, 200, { auth })()
1179
await reqApi('set_vfs', { uri, props: { name: renamed, comment: 'test note', can_list: false } }, 200, { auth })()
1180
await reqApi('get_vfs', {}, res => {
1181
const children = res?.root?.children || []
@@ -1183,8 +1186,11 @@ describe('admin', () => {
1186
: renamedNode.comment !== 'test note' ? 'comment not updated'
1187
: renamedNode.can_list !== false ? 'can_list not updated' : '')
1188
}, { auth })()
1189
+ await reqApi('get_config', { only: ['roots'] }, res =>
1190
+ throwIf(res?.roots?.[rootsHost] === renamedUri + '/' ? '' : 'root not updated'), { auth })()
1191
}
1192
finally {
1193
+ await reqApi('set_config', { values: { roots: oldRoots } }, 200, { auth })().catch(() => {})
1194
await reqApi('del_vfs', { uris: [renamedUri] }, data =>
1195
[0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {})
1196
await reqApi('del_vfs', { uris: [uri] }, data =>