feat: add session undo and redo
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 34s

This commit is contained in:
Lucas Winther
2026-09-30 01:33:29 +02:00
parent d38584396b
commit 804f32c5fb
9 changed files with 411 additions and 14 deletions
@@ -0,0 +1,94 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { createSnapshotStore } from "../../store/snapshot.ts";
import { store } from "../../store/index.ts";
import { HistoryControls } from "./HistoryControls.tsx";
const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo };
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
Object.assign(store, original);
});
async function setup() {
let saved = createSeedDataset("2026-09");
let fail = false;
const history = createSnapshotStore({
async read() { return saved; },
async write(next) {
if (fail) throw new Error("Disk full");
saved = next;
},
}, "2026-09");
await history.load();
Object.assign(store, {
getHistory: history.getHistory,
subscribe: history.subscribe,
undo: history.undo,
redo: history.redo,
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<HistoryControls />));
return { history, failWrites(value: boolean) { fail = value; } };
}
test("buttons follow saved history and restore data", async () => {
const { history } = await setup();
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
const redo = container!.querySelector<HTMLButtonElement>('button[aria-label="Redo"]')!;
expect(undo.disabled).toBe(true);
expect(redo.disabled).toBe(true);
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
expect(undo.disabled).toBe(false);
await act(async () => { undo.click(); });
expect(history.get().posts[0]!.name).toBe("Video Games");
expect(redo.disabled).toBe(false);
await act(async () => { redo.click(); });
expect(history.get().posts[0]!.name).toBe("Changed");
});
test("a failed undo shows an error and permits retry", async () => {
const { history, failWrites } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
failWrites(true);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(history.get().posts[0]!.name).toBe("Changed");
failWrites(false);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')).toBeNull();
});
test("keyboard shortcut reverses saved edits outside editable controls", async () => {
const { history } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
const field = document.createElement("input");
container!.append(field);
await act(async () => {
field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Video Games");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
});
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Redo2, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { store } from "../../store/index.ts";
/** Session history controls. Dataset writes remain serialized by the store. */
export function HistoryControls() {
const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const busyRef = useRef(false);
const run = useCallback(async (direction: "undo" | "redo") => {
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
try {
await store[direction]();
setError(null);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not ${direction}: ${message}. Your data has not changed.`);
} finally {
busyRef.current = false;
setBusy(false);
}
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return;
const target = event.target;
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return;
const key = event.key.toLowerCase();
const direction = key === "z"
? (event.shiftKey ? "redo" : "undo")
: key === "y" && !event.shiftKey ? "redo" : null;
if (!direction) return;
event.preventDefault();
void run(direction);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [run]);
return (
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
<Undo2 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
<Redo2 aria-hidden="true" />
</Button>
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
</div>
);
}