admin/shared: undo
Massimo Melina committed
Feb 19, 2026 at 19:09 UTC
f68af0af4be1ef3b333e181902b8931adfa26c98
8 files changed
+99
-8
admin/src/FileForm.ts
+2
-1
@@ -1,6 +1,6 @@
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 { markVfsModified, state, useSnapState } from './state'
3
+import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
4
import { createElement as h, forwardRef, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5
import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, useTheme } from '@mui/material'
6
import {
@@ -141,6 +141,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
141
throw Error("Selected node not found")
142
const props = _.omit(values, ['birthtime','mtime','size','id'])
143
const wasId = node.id
144
+ prepareVfsUndo()
145
Object.assign(node, props)
146
if (props.name !== undefined)
147
reindexVfs({ node, clearMap: false, select: [node] })
admin/src/VfsMenuBar.ts
+9
-3
@@ -2,13 +2,13 @@
2
3
import { createElement as h, ReactNode } from 'react'
4
import { Alert, Box, ButtonProps, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5
-import { Add, Save, Storage } from '@mui/icons-material'
5
+import { Add, Save, Storage, Undo } from '@mui/icons-material'
6
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'
11
-import { state, useSnapState } from './state'
11
+import { state, undoVfs, useSnapState } from './state'
12
import _ from 'lodash'
13
import { Btn, Flex, reloadBtn, useBreakpoint } from './mui'
14
import { apiCall, ApiObject, useApi } from './api'
@@ -19,7 +19,7 @@ import { getDiskSpaces } from '../../src/util-os'
19
import { adminApis } from '../../src/adminApis'
20
21
export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
22
- const { vfsModified } = useSnapState()
22
+ const { vfsModified, vfsUndo } = useSnapState()
23
return h(Flex, {
24
zIndex: 2,
25
gap: 1,
@@ -34,6 +34,12 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
34
modified: vfsModified,
35
onClick: saveVfs
36
}),
37
+ h(Btn, {
38
+ icon: Undo,
39
+ title: "Undo/redo last change",
40
+ disabled: !vfsUndo && "No changes to undo",
41
+ onClick: undoVfs,
42
+ }),
43
reloadBtn(() => reloadVfs()),
44
h(Btn, {
45
icon: Storage,
admin/src/VfsPage.ts
+3
-1
@@ -3,7 +3,7 @@
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'
6
-import { markVfsModified, state, useSnapState } from './state'
6
+import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
7
import VfsTree, { vfsNodeIcon } from './VfsTree'
8
import {
9
CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait
@@ -131,6 +131,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
131
if (!root) return
132
root.isRoot = true
133
state.vfs = root
134
+ state.vfsUndo = undefined
135
reindexVfs({ sortChildren: true, select: consumeSelectOnReload() })
136
state.vfsModified = false
137
@@ -210,6 +211,7 @@ export function deleteVfs(uris: string[]) {
211
const topLevelUris = sorted.filter((uri, idx) => uri !== '/'
212
&& (idx === 0 || _.findLastIndex(sorted, parentUri => isDescendantUri(uri, parentUri), idx - 1) < 0))
213
if (!topLevelUris.length) return
214
+ prepareVfsUndo()
215
for (const uri of topLevelUris) {
216
const node = id2node.get(uri)!
217
const siblings = node.parent!.children!
admin/src/VfsTree.ts
+2
-1
@@ -1,6 +1,6 @@
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 { markVfsModified, state, useSnapState } from './state'
3
+import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
4
import { createElement as h, ReactElement, useCallback, useEffect, useRef, MouseEvent } from 'react'
5
import { TreeItem, TreeView } from '@mui/x-tree-view'
6
import {
@@ -166,6 +166,7 @@ export function moveVfs(from: string, to: string) {
166
const movedName = fromNode.name
167
const movedIsFolder = fromNode.type === 'folder'
168
const destinationAncestors = getAncestorIds(toNode)
169
+ prepareVfsUndo()
170
_.remove(oldSiblings, { id: fromNode.id })
171
if (!oldSiblings.length && fromParent)
172
fromParent.children = undefined
admin/src/addFiles.ts
+2
-1
@@ -5,7 +5,7 @@ import { createElement as h, Fragment } from 'react'
5
import { Box } from '@mui/material'
6
import { reindexVfs, VfsNodeAdmin } from './VfsPage'
7
import { addToChildrenOf } from './VfsTree'
8
-import { state } from './state'
8
+import { prepareVfsUndo, state } from './state'
9
import FilePicker from './FilePicker'
10
import { basename, extname, focusSelector } from '@hfs/shared'
11
@@ -41,6 +41,7 @@ function addNodes(parent: VfsNodeAdmin, nodes: VfsNodeAdmin[]) {
41
n.id ||= parent.id + n.name + (n.type === 'folder' ? '/' : '')
42
n.parent = parent
43
}
44
+ prepareVfsUndo()
45
addToChildrenOf(parent, nodes)
46
reindexVfs({ select: nodes })
47
}
admin/src/state.ts
+25
@@ -15,6 +15,7 @@ const INIT = {
15
accountsAsTree: false,
16
movingFile: '',
17
vfs: undefined as VfsNodeAdmin | undefined,
18
+ vfsUndo: undefined as VfsNodeAdmin | undefined,
19
vfsModified: false,
20
expanded: [] as string[],
21
loginRequired: false as boolean | number,
@@ -52,7 +53,31 @@ export function markVfsModified() {
53
reindexVfs()
54
}
55
56
+export function prepareVfsUndo() {
57
+ if (!state.vfs) return
58
+ state.vfsUndo = cloneVfs(state.vfs)
59
+}
60
+
61
+export function undoVfs() {
62
+ if (!state.vfs || !state.vfsUndo) return
63
+ // Swap current/snapshot so pressing undo again restores the state we just replaced (single-level redo behavior).
64
+ const current = cloneVfs(state.vfs)
65
+ state.vfs = state.vfsUndo
66
+ state.vfsUndo = current
67
+ state.vfsModified = true
68
+ reindexVfs()
69
+}
70
+
71
// use this to reflect a deep change in an object to its root, so that valtio is triggered
72
export function updateStateObject(obj: any, k: string, cb: (x: any) => void) {
73
obj[k] = produce(obj[k], cb)
74
}
75
+
76
+function cloneVfs(node: VfsNodeAdmin): VfsNodeAdmin {
77
+ const { parent, children, ...rest } = node
78
+ // Parent links create cycles in the live tree; omit them so snapshots can be cloned and restored safely.
79
+ const copy = _.cloneDeep(rest) as VfsNodeAdmin
80
+ if (children)
81
+ copy.children = children.map(cloneVfs)
82
+ return copy
83
+}
e2e/admin-vfs.spec.ts
+55
@@ -167,6 +167,61 @@ test('delete virtual folder updates tree and marks modified', async ({ page }) =
167
})
168
})
169
170
+test('undo toggles with single-level redo behavior', async ({ page }) => {
171
+ await page.goto(URL + '~/admin/')
172
+ await page.getByRole('textbox', { name: 'Username' }).fill(username)
173
+ await page.getByRole('textbox', { name: 'Password' }).fill(password)
174
+ await page.getByRole('textbox', { name: 'Password' }).press('Enter')
175
+ await clickAdminMenu(page, /Shared files/)
176
+ await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
177
+
178
+ const folderName = 'for-disabled'
179
+ const undoButton = page.locator('svg[data-testid="UndoIcon"]').first().locator('xpath=ancestor::button[1]')
180
+ await expect(undoButton).toBeDisabled()
181
+
182
+ await selectVfsNode(page, folderName, '/for-disabled/')
183
+ await page.getByRole('button', { name: 'Delete' }).first().click()
184
+ const confirm = page.locator('.dialog-confirm')
185
+ await expect(confirm).toBeVisible()
186
+ await confirm.locator('a').first().click()
187
+ await expect(undoButton).toBeEnabled()
188
+
189
+ await expect.poll(() => page.evaluate(name => {
190
+ const root = (window as any).state?.vfs
191
+ return {
192
+ hasFolder: (root?.children || []).some((x: any) => x.name === name),
193
+ modified: (window as any).state?.vfsModified,
194
+ }
195
+ }, folderName)).toEqual({
196
+ hasFolder: false,
197
+ modified: true,
198
+ })
199
+
200
+ await undoButton.click()
201
+ await expect.poll(() => page.evaluate(name => {
202
+ const root = (window as any).state?.vfs
203
+ return {
204
+ hasFolder: (root?.children || []).some((x: any) => x.name === name),
205
+ modified: (window as any).state?.vfsModified,
206
+ }
207
+ }, folderName)).toEqual({
208
+ hasFolder: true,
209
+ modified: true,
210
+ })
211
+
212
+ await undoButton.click()
213
+ await expect.poll(() => page.evaluate(name => {
214
+ const root = (window as any).state?.vfs
215
+ return {
216
+ hasFolder: (root?.children || []).some((x: any) => x.name === name),
217
+ modified: (window as any).state?.vfsModified,
218
+ }
219
+ }, folderName)).toEqual({
220
+ hasFolder: false,
221
+ modified: true,
222
+ })
223
+})
224
+
225
test('apply keeps unset permissions nullish in-memory', async ({ page }) => {
226
await page.goto(URL + '~/admin/')
227
await page.getByRole('textbox', { name: 'Username' }).fill(username)
package.json
+1
-1
@@ -22,7 +22,7 @@
22
"test": "node --import tsx --test tests/test.ts",
23
"test-with-server": "sh -c 'npm run port-is-free && tsc && rm -rf tests/work tests/tmp && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test \"$@\" tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit' --",
24
"port-is-free": "node -e \"const port=process.argv[1]||8081;process.exit(await fetch('http://localhost:'+port).then(() => console.log('BUSY')||1, () => 0))\" --",
25
- "test-ui": "npx playwright test frontend && npx playwright test serial",
25
+ "test-ui": "npx playwright test frontend && npx playwright test serial && npx playwright test admin-vfs",
26
"test-with-ui": "sh -c 'npm run port-is-free -- 3005 && npm run start-frontend & npm run port-is-free -- 3006 && npm run start-admin & cross-env TEST_WITH_UI=1 npx playwright test --ui'",
27
"pub": "cd dist && npm publish",
28
"dist": "STASHED=; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' && STASHED=1; fi; CI=1 FORCE_COLOR=1 npm run dist-uncommitted || (EXIT_CODE=$?; [ -n \"$STASHED\" ] && git stash pop; exit $EXIT_CODE); [ -n \"$STASHED\" ] && git stash pop",