@samitouri / QOSami-HFS / commits / 1008e2ae

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