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",