summaryrefslogtreecommitdiff
path: root/apps/web/src/versions
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/versions')
-rw-r--r--apps/web/src/versions/__tests__/media-pin.test.ts137
-rw-r--r--apps/web/src/versions/__tests__/versions-manager.test.ts242
-rw-r--r--apps/web/src/versions/components/version-name-dialog.tsx77
-rw-r--r--apps/web/src/versions/components/versions-popover.tsx320
-rw-r--r--apps/web/src/versions/index.ts13
-rw-r--r--apps/web/src/versions/media-references.ts52
-rw-r--r--apps/web/src/versions/store.ts86
-rw-r--r--apps/web/src/versions/types.ts21
-rw-r--r--apps/web/src/versions/versions-manager.ts503
-rw-r--r--apps/web/src/versions/versions-store.ts23
10 files changed, 1474 insertions, 0 deletions
diff --git a/apps/web/src/versions/__tests__/media-pin.test.ts b/apps/web/src/versions/__tests__/media-pin.test.ts
new file mode 100644
index 00000000..17fae4b2
--- /dev/null
+++ b/apps/web/src/versions/__tests__/media-pin.test.ts
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import { buildDefaultScene } from "@/timeline/scenes";
+import type { ImageElement } from "@/timeline";
+import type { MediaAsset } from "@/media/types";
+import { mediaTimeFromSeconds, ZERO_MEDIA_TIME } from "@/wasm";
+import { CURRENT_PROJECT_VERSION } from "@/services/storage/migrations";
+import { InMemoryVersionStore } from "@/versions";
+
+const PROJECT_ID = "project-1";
+const IMAGE_DURATION = mediaTimeFromSeconds({ seconds: 5 });
+
+function buildImageElement({ mediaId }: { mediaId: string }): ImageElement {
+ return {
+ id: `image-${mediaId}`,
+ type: "image",
+ name: `image-${mediaId}`,
+ startTime: ZERO_MEDIA_TIME,
+ duration: IMAGE_DURATION,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function buildImageAsset({ id }: { id: string }): MediaAsset {
+ return {
+ id,
+ name: id,
+ type: "image",
+ file: new File([new Uint8Array([1, 2, 3])], `${id}.png`, {
+ type: "image/png",
+ }),
+ };
+}
+
+function seedEditor(): { editor: EditorCore } {
+ const editor = EditorCore.getInstance();
+ editor.versions.setStore({ store: new InMemoryVersionStore() });
+ editor.versions.setNow({ now: () => 1_000_000_000 });
+
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ // Only media-pinned is referenced by the document (and thus snapshots);
+ // media-free exists as an asset but is used nowhere.
+ scene.tracks.main.elements = [buildImageElement({ mediaId: "media-pinned" })];
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: PROJECT_ID,
+ name: "Test project",
+ duration: IMAGE_DURATION,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: CURRENT_PROJECT_VERSION,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ editor.media.setAssets({
+ assets: [
+ buildImageAsset({ id: "media-pinned" }),
+ buildImageAsset({ id: "media-free" }),
+ ],
+ });
+ return { editor };
+}
+
+describe("Media pinning", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ });
+
+ test("deleting a media asset referenced by a stored version is blocked", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-pinned"),
+ ).toBe(true);
+
+ // An unreferenced asset in the same project deletes normally.
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-free" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-free"),
+ ).toBe(false);
+ });
+
+ test("an asset is unpinned once the referencing version is deleted", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ expect(
+ editor.versions.isMediaIdPinned({
+ projectId: PROJECT_ID,
+ mediaId: "media-pinned",
+ }),
+ ).toBe(true);
+
+ await editor.versions.deleteVersion({ versionId: version.id });
+ expect(
+ editor.versions.isMediaIdPinned({
+ projectId: PROJECT_ID,
+ mediaId: "media-pinned",
+ }),
+ ).toBe(false);
+
+ editor.media.removeMediaAsset({ projectId: PROJECT_ID, id: "media-pinned" });
+ expect(
+ editor.media.getAssets().some((asset) => asset.id === "media-pinned"),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/versions/__tests__/versions-manager.test.ts b/apps/web/src/versions/__tests__/versions-manager.test.ts
new file mode 100644
index 00000000..1322c71a
--- /dev/null
+++ b/apps/web/src/versions/__tests__/versions-manager.test.ts
@@ -0,0 +1,242 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import { buildDefaultScene } from "@/timeline/scenes";
+import type { VideoElement } from "@/timeline";
+import { mediaTimeFromSeconds, ZERO_MEDIA_TIME } from "@/wasm";
+import { serializeProject } from "@/services/storage/service";
+import { CURRENT_PROJECT_VERSION } from "@/services/storage/migrations";
+import {
+ AUTO_CHECKPOINT_MIN_INTERVAL_MS,
+ AUTO_CHECKPOINT_RETENTION,
+ InMemoryVersionStore,
+} from "@/versions";
+
+const PROJECT_ID = "project-1";
+const VIDEO_DURATION = mediaTimeFromSeconds({ seconds: 10 });
+const SPLIT_TIME = mediaTimeFromSeconds({ seconds: 4 });
+
+let nowMs = 1_000_000_000;
+
+function buildVideoElement(): VideoElement {
+ return {
+ id: "video-1",
+ type: "video",
+ name: "video-1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: VIDEO_DURATION,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-video-1",
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function seedEditor(): { editor: EditorCore; store: InMemoryVersionStore } {
+ const editor = EditorCore.getInstance();
+ const store = new InMemoryVersionStore();
+ editor.versions.setStore({ store });
+ editor.versions.setNow({ now: () => nowMs });
+
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ scene.tracks.main.elements = [buildVideoElement()];
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: PROJECT_ID,
+ name: "Test project",
+ duration: VIDEO_DURATION,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: CURRENT_PROJECT_VERSION,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ return { editor, store };
+}
+
+function splitSeededVideo({ editor }: { editor: EditorCore }): void {
+ const scene = editor.scenes.getActiveScene();
+ editor.timeline.splitElements({
+ elements: [{ trackId: scene.tracks.main.id, elementId: "video-1" }],
+ splitTime: SPLIT_TIME,
+ });
+}
+
+describe("VersionsManager", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ });
+
+ test("restore round-trips the document through a named checkpoint", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ splitSeededVideo({ editor });
+ expect(
+ editor.scenes.getActiveScene().tracks.main.elements,
+ ).toHaveLength(2);
+
+ const didRestore = await editor.versions.restoreVersion({
+ versionId: version.id,
+ });
+ expect(didRestore).toBe(true);
+
+ const restored = editor.project.getActive();
+ expect(serializeProject({ project: restored })).toEqual(version.project);
+ });
+
+ test("restore writes a safety auto-checkpoint of the abandoned head", async () => {
+ const { editor, store } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ splitSeededVideo({ editor });
+
+ const didRestore = await editor.versions.restoreVersion({
+ versionId: version.id,
+ });
+ expect(didRestore).toBe(true);
+
+ const stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ const autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(1);
+
+ // The safety checkpoint captures the mutated (split) document…
+ const safetyScene = autos[0].project.scenes[0];
+ expect(safetyScene.tracks.main.elements).toHaveLength(2);
+ // …while the live document is back at the checkpointed state.
+ expect(
+ editor.scenes.getActiveScene().tracks.main.elements,
+ ).toHaveLength(1);
+ });
+
+ test("auto-checkpoints rotate as a ring buffer and never evict named versions", async () => {
+ const { editor, store } = seedEditor();
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ const [firstAuto] = await store.getAllForProject({
+ projectId: PROJECT_ID,
+ });
+
+ for (let i = 0; i < AUTO_CHECKPOINT_RETENTION; i++) {
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ }
+
+ let stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ let autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(AUTO_CHECKPOINT_RETENTION);
+ // The 21st write evicted the oldest auto-checkpoint.
+ expect(autos.some((candidate) => candidate.id === firstAuto.id)).toBe(
+ false,
+ );
+
+ const named = await editor.versions.createNamedCheckpoint({
+ name: "keep-me",
+ });
+ expect(named).not.toBeNull();
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+
+ stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(AUTO_CHECKPOINT_RETENTION);
+ expect(
+ stored.some(
+ (candidate) => candidate.kind === "named" && candidate.name === "keep-me",
+ ),
+ ).toBe(true);
+ });
+
+ test("auto-checkpoints are throttled to one per interval", async () => {
+ const { editor, store } = seedEditor();
+
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(false);
+
+ nowMs += AUTO_CHECKPOINT_MIN_INTERVAL_MS - 1;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(false);
+
+ nowMs += 1;
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+
+ const stored = await store.getAllForProject({ projectId: PROJECT_ID });
+ const autos = stored.filter((candidate) => candidate.kind === "auto");
+ expect(autos).toHaveLength(2);
+ });
+
+ test("auto-checkpoints cannot be renamed or deleted", async () => {
+ const { editor } = seedEditor();
+
+ expect(await editor.versions.writeAutoCheckpoint()).toBe(true);
+ const [auto] = editor.versions.getVersions({ projectId: PROJECT_ID });
+ expect(auto.kind).toBe("auto");
+
+ expect(
+ await editor.versions.renameVersion({
+ versionId: auto.id,
+ name: "nope",
+ }),
+ ).toBe(false);
+ expect(await editor.versions.deleteVersion({ versionId: auto.id })).toBe(
+ false,
+ );
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID }),
+ ).toHaveLength(1);
+ });
+
+ test("named checkpoints can be renamed and deleted", async () => {
+ const { editor } = seedEditor();
+
+ const version = await editor.versions.createNamedCheckpoint({
+ name: "v1",
+ });
+ expect(version).not.toBeNull();
+ if (!version) return;
+
+ expect(
+ await editor.versions.renameVersion({
+ versionId: version.id,
+ name: "v1-final",
+ }),
+ ).toBe(true);
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID })[0].name,
+ ).toBe("v1-final");
+
+ expect(
+ await editor.versions.deleteVersion({ versionId: version.id }),
+ ).toBe(true);
+ expect(
+ editor.versions.getVersions({ projectId: PROJECT_ID }),
+ ).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/versions/components/version-name-dialog.tsx b/apps/web/src/versions/components/version-name-dialog.tsx
new file mode 100644
index 00000000..f0514f5a
--- /dev/null
+++ b/apps/web/src/versions/components/version-name-dialog.tsx
@@ -0,0 +1,77 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+import { Label } from "@/components/ui/label";
+
+export function VersionNameDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ mode,
+ initialName = "",
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (name: string) => void;
+ mode: "create" | "rename";
+ initialName?: string;
+}) {
+ const [name, setName] = useState(initialName);
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(initialName);
+ }
+ onOpenChange(open);
+ };
+
+ const title = mode === "create" ? "Create checkpoint" : "Rename checkpoint";
+ const confirmLabel = mode === "create" ? "Create" : "Rename";
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="gap-3">
+ <Label>Name</Label>
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="e.g. Final cut"
+ />
+ </DialogBody>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={() => onConfirm(name)}>{confirmLabel}</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/versions/components/versions-popover.tsx b/apps/web/src/versions/components/versions-popover.tsx
new file mode 100644
index 00000000..b97e62da
--- /dev/null
+++ b/apps/web/src/versions/components/versions-popover.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import {
+ Clock01Icon,
+ Delete02Icon,
+ Edit02Icon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useEditor } from "@/editor/use-editor";
+import { useVersionsStore } from "@/versions/versions-store";
+import { VersionNameDialog } from "@/versions/components/version-name-dialog";
+import type { ProjectVersion } from "@/versions/types";
+
+function formatVersionDate({ date }: { date: Date }): string {
+ return new Date(date).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+function getVersionTitle({ version }: { version: ProjectVersion }): string {
+ return version.kind === "named" && version.name
+ ? version.name
+ : "Auto-checkpoint";
+}
+
+export function VersionsButton() {
+ const editor = useEditor();
+ const isPanelOpen = useVersionsStore((s) => s.isPanelOpen);
+ const setPanelOpen = useVersionsStore((s) => s.setPanelOpen);
+ const isCheckpointDialogOpen = useVersionsStore(
+ (s) => s.isCheckpointDialogOpen,
+ );
+ const closeCheckpointDialog = useVersionsStore(
+ (s) => s.closeCheckpointDialog,
+ );
+ const openCheckpointDialog = useVersionsStore((s) => s.openCheckpointDialog);
+ const projectId = useEditor((e) => e.project.getActiveOrNull()?.metadata.id);
+ const versions = useEditor((e) =>
+ projectId ? e.versions.getVersions({ projectId }) : [],
+ );
+
+ const [restoreTarget, setRestoreTarget] = useState<ProjectVersion | null>(
+ null,
+ );
+ const [renameTarget, setRenameTarget] = useState<ProjectVersion | null>(null);
+ const [deleteTarget, setDeleteTarget] = useState<ProjectVersion | null>(null);
+
+ const handlePanelOpenChange = (open: boolean) => {
+ setPanelOpen({ open });
+ if (open && projectId) {
+ editor.versions.listVersions({ projectId }).catch((error) => {
+ console.error("Failed to refresh versions:", error);
+ });
+ }
+ };
+
+ const handleCreateCheckpoint = (name: string) => {
+ closeCheckpointDialog();
+ editor.versions.createNamedCheckpoint({ name }).catch((error) => {
+ console.error("Failed to create checkpoint:", error);
+ });
+ };
+
+ return (
+ <>
+ <Popover open={isPanelOpen} onOpenChange={handlePanelOpenChange}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ title="Versions"
+ >
+ <HugeiconsIcon icon={Clock01Icon} className="size-4" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent align="end" className="w-80 p-2">
+ <div className="flex items-center justify-between px-2 pb-2">
+ <span className="text-sm font-medium">Versions</span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openCheckpointDialog}
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5" />
+ Checkpoint
+ </Button>
+ </div>
+ {versions.length === 0 ? (
+ <p className="text-muted-foreground px-2 py-6 text-center text-sm">
+ No versions yet. Create a checkpoint to save a known-good state.
+ </p>
+ ) : (
+ <ScrollArea className="max-h-80">
+ <div className="flex flex-col gap-1">
+ {versions.map((version) => (
+ <VersionRow
+ key={version.id}
+ version={version}
+ onRestore={() => setRestoreTarget(version)}
+ onRename={
+ version.kind === "named"
+ ? () => setRenameTarget(version)
+ : undefined
+ }
+ onDelete={
+ version.kind === "named"
+ ? () => setDeleteTarget(version)
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ )}
+ </PopoverContent>
+ </Popover>
+
+ <VersionNameDialog
+ isOpen={isCheckpointDialogOpen}
+ onOpenChange={(open) => {
+ if (!open) closeCheckpointDialog();
+ }}
+ onConfirm={handleCreateCheckpoint}
+ mode="create"
+ />
+
+ <VersionNameDialog
+ isOpen={renameTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setRenameTarget(null);
+ }}
+ onConfirm={(name) => {
+ const target = renameTarget;
+ setRenameTarget(null);
+ if (target) {
+ editor.versions
+ .renameVersion({ versionId: target.id, name })
+ .catch((error) => {
+ console.error("Failed to rename checkpoint:", error);
+ });
+ }
+ }}
+ mode="rename"
+ initialName={renameTarget?.name ?? ""}
+ />
+
+ <AlertDialog
+ open={restoreTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setRestoreTarget(null);
+ }}
+ >
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ Restore {restoreTarget ? getVersionTitle({ version: restoreTarget }) : ""}?
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ The editor will jump to this version. Your current state is saved
+ as an auto-checkpoint first, so nothing is lost.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ const target = restoreTarget;
+ setRestoreTarget(null);
+ if (target) {
+ editor.versions
+ .restoreVersion({ versionId: target.id })
+ .catch((error) => {
+ console.error("Failed to restore version:", error);
+ });
+ }
+ }}
+ >
+ Restore
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ <AlertDialog
+ open={deleteTarget !== null}
+ onOpenChange={(open) => {
+ if (!open) setDeleteTarget(null);
+ }}
+ >
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ Delete {deleteTarget ? getVersionTitle({ version: deleteTarget }) : ""}?
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ This checkpoint is removed permanently. The current state of the
+ project is not affected.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => {
+ const target = deleteTarget;
+ setDeleteTarget(null);
+ if (target) {
+ editor.versions
+ .deleteVersion({ versionId: target.id })
+ .catch((error) => {
+ console.error("Failed to delete checkpoint:", error);
+ });
+ }
+ }}
+ >
+ Delete
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ );
+}
+
+function VersionRow({
+ version,
+ onRestore,
+ onRename,
+ onDelete,
+}: {
+ version: ProjectVersion;
+ onRestore: () => void;
+ onRename?: () => void;
+ onDelete?: () => void;
+}) {
+ return (
+ <div className="hover:bg-accent flex items-center gap-1 rounded-sm p-1">
+ <Button
+ variant="ghost"
+ className="h-auto flex-1 justify-start gap-2 px-1 py-1 text-left font-normal"
+ onClick={onRestore}
+ >
+ <div className="bg-muted relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-sm">
+ {version.thumbnail ? (
+ <Image
+ src={version.thumbnail}
+ alt=""
+ fill
+ sizes="48px"
+ className="object-cover"
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={Clock01Icon}
+ className="text-muted-foreground size-4"
+ />
+ )}
+ </div>
+ <div className="flex min-w-0 flex-col gap-0.5">
+ <span className="truncate text-sm">
+ {getVersionTitle({ version })}
+ </span>
+ <span className="text-muted-foreground text-xs">
+ {formatVersionDate({ date: version.createdAt })}
+ </span>
+ </div>
+ <Badge
+ variant={version.kind === "named" ? "default" : "secondary"}
+ className="ml-auto shrink-0"
+ >
+ {version.kind === "named" ? "Named" : "Auto"}
+ </Badge>
+ </Button>
+ {onRename && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-7 shrink-0"
+ title="Rename checkpoint"
+ onClick={onRename}
+ >
+ <HugeiconsIcon icon={Edit02Icon} className="size-3.5" />
+ </Button>
+ )}
+ {onDelete && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-7 shrink-0"
+ title="Delete checkpoint"
+ onClick={onDelete}
+ >
+ <HugeiconsIcon icon={Delete02Icon} className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/versions/index.ts b/apps/web/src/versions/index.ts
new file mode 100644
index 00000000..e9f69841
--- /dev/null
+++ b/apps/web/src/versions/index.ts
@@ -0,0 +1,13 @@
+export type { ProjectVersion, ProjectVersionKind } from "./types";
+export type { VersionStore } from "./store";
+export {
+ InMemoryVersionStore,
+ IndexedDBVersionStore,
+} from "./store";
+export { getReferencedMediaIds } from "./media-references";
+export {
+ AUTO_CHECKPOINT_MIN_INTERVAL_MS,
+ AUTO_CHECKPOINT_RETENTION,
+ VersionsManager,
+} from "./versions-manager";
+export { useVersionsStore } from "./versions-store";
diff --git a/apps/web/src/versions/media-references.ts b/apps/web/src/versions/media-references.ts
new file mode 100644
index 00000000..86264320
--- /dev/null
+++ b/apps/web/src/versions/media-references.ts
@@ -0,0 +1,52 @@
+import type { SerializedProject } from "@/services/storage/types";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function collectFromElements({
+ elements,
+ mediaIds,
+}: {
+ elements: unknown;
+ mediaIds: Set<string>;
+}): void {
+ if (!Array.isArray(elements)) return;
+
+ for (const element of elements) {
+ if (!isRecord(element)) continue;
+ if (typeof element.mediaId === "string" && element.mediaId.length > 0) {
+ mediaIds.add(element.mediaId);
+ }
+ }
+}
+
+/**
+ * Extracts every media asset id referenced by a serialized project's scenes.
+ * Duck-typed (like migrations) so snapshots of any project version scan safely.
+ */
+export function getReferencedMediaIds({
+ project,
+}: {
+ project: SerializedProject;
+}): Set<string> {
+ const mediaIds = new Set<string>();
+
+ for (const scene of project.scenes ?? []) {
+ const tracks: unknown = scene.tracks;
+ if (!isRecord(tracks)) continue;
+
+ if (isRecord(tracks.main)) {
+ collectFromElements({ elements: tracks.main.elements, mediaIds });
+ }
+ for (const list of [tracks.overlay, tracks.audio]) {
+ if (!Array.isArray(list)) continue;
+ for (const track of list) {
+ if (!isRecord(track)) continue;
+ collectFromElements({ elements: track.elements, mediaIds });
+ }
+ }
+ }
+
+ return mediaIds;
+}
diff --git a/apps/web/src/versions/store.ts b/apps/web/src/versions/store.ts
new file mode 100644
index 00000000..ae3f5d11
--- /dev/null
+++ b/apps/web/src/versions/store.ts
@@ -0,0 +1,86 @@
+import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
+import type { ProjectVersion } from "./types";
+
+/**
+ * Persistence backend for project versions. The IndexedDB implementation is
+ * the production store (its own database, created lazily — the v31→v32
+ * migration stamps project records); the in-memory implementation backs
+ * unit tests.
+ */
+export interface VersionStore {
+ put({ version }: { version: ProjectVersion }): Promise<void>;
+ getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]>;
+ delete({ id }: { id: string }): Promise<void>;
+ deleteForProject({ projectId }: { projectId: string }): Promise<void>;
+}
+
+export class IndexedDBVersionStore implements VersionStore {
+ private adapter = new IndexedDBAdapter<ProjectVersion>({
+ dbName: "video-editor-project-versions",
+ storeName: "project-versions",
+ version: 1,
+ });
+
+ async put({ version }: { version: ProjectVersion }): Promise<void> {
+ await this.adapter.set({ key: version.id, value: version });
+ }
+
+ async getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ const all = await this.adapter.getAll();
+ return all.filter(
+ (version) =>
+ typeof version === "object" &&
+ version !== null &&
+ version.projectId === projectId,
+ );
+ }
+
+ async delete({ id }: { id: string }): Promise<void> {
+ await this.adapter.remove(id);
+ }
+
+ async deleteForProject({ projectId }: { projectId: string }): Promise<void> {
+ const versions = await this.getAllForProject({ projectId });
+ await Promise.all(
+ versions.map((version) => this.adapter.remove(version.id)),
+ );
+ }
+}
+
+export class InMemoryVersionStore implements VersionStore {
+ private versions = new Map<string, ProjectVersion>();
+
+ async put({ version }: { version: ProjectVersion }): Promise<void> {
+ this.versions.set(version.id, version);
+ }
+
+ async getAllForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ return [...this.versions.values()].filter(
+ (version) => version.projectId === projectId,
+ );
+ }
+
+ async delete({ id }: { id: string }): Promise<void> {
+ this.versions.delete(id);
+ }
+
+ async deleteForProject({ projectId }: { projectId: string }): Promise<void> {
+ for (const version of this.versions.values()) {
+ if (version.projectId === projectId) {
+ this.versions.delete(version.id);
+ }
+ }
+ }
+}
diff --git a/apps/web/src/versions/types.ts b/apps/web/src/versions/types.ts
new file mode 100644
index 00000000..d2852e8d
--- /dev/null
+++ b/apps/web/src/versions/types.ts
@@ -0,0 +1,21 @@
+import type { SerializedProject } from "@/services/storage/types";
+
+export type ProjectVersionKind = "named" | "auto";
+
+/**
+ * A point-in-time snapshot of a project (see notes/project-versioning.md).
+ *
+ * Media is referenced by asset id inside `project` (v1 keeps the per-project
+ * media store); use `getReferencedMediaIds` to extract the referenced set.
+ */
+export interface ProjectVersion {
+ id: string;
+ projectId: string;
+ kind: ProjectVersionKind;
+ /** Required for kind "named". */
+ name?: string;
+ createdAt: Date;
+ project: SerializedProject;
+ /** Data URL captured via renderer.captureFrame(); named versions only. */
+ thumbnail?: string;
+}
diff --git a/apps/web/src/versions/versions-manager.ts b/apps/web/src/versions/versions-manager.ts
new file mode 100644
index 00000000..6dba0c8c
--- /dev/null
+++ b/apps/web/src/versions/versions-manager.ts
@@ -0,0 +1,503 @@
+import type { EditorCore } from "@/core";
+import { toast } from "sonner";
+import { generateUUID } from "@/utils/id";
+import type { TProject } from "@/project/types";
+import {
+ deserializeProject,
+ serializeProject,
+} from "@/services/storage/service";
+import { isStorageQuotaExceededError } from "@/services/storage/quota";
+import { getProjectDurationFromScenes } from "@/timeline/scenes";
+import { ZERO_MEDIA_TIME } from "@/wasm";
+import { getReferencedMediaIds } from "./media-references";
+import {
+ IndexedDBVersionStore,
+ InMemoryVersionStore,
+ type VersionStore,
+} from "./store";
+import type { ProjectVersion } from "./types";
+
+export const AUTO_CHECKPOINT_RETENTION = 20;
+export const AUTO_CHECKPOINT_MIN_INTERVAL_MS = 60_000;
+
+const VERSION_THUMBNAIL_MAX_WIDTH = 320;
+
+async function captureVersionThumbnail({
+ editor,
+}: {
+ editor: EditorCore;
+}): Promise<string | undefined> {
+ try {
+ const blob = await editor.renderer.captureFrame();
+ if (!blob) return undefined;
+
+ const bitmap = await createImageBitmap(blob);
+ const scale = Math.min(1, VERSION_THUMBNAIL_MAX_WIDTH / bitmap.width);
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
+ const context = canvas.getContext("2d");
+ if (!context) {
+ bitmap.close();
+ return undefined;
+ }
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
+ bitmap.close();
+ return canvas.toDataURL("image/jpeg", 0.75);
+ } catch (error) {
+ console.error("Failed to capture version thumbnail:", error);
+ return undefined;
+ }
+}
+
+/**
+ * Named checkpoints + throttled auto-checkpoints (ring buffer) per project.
+ * Restore is a wholesale document swap that deliberately bypasses undo/redo:
+ * undoing a restore = the safety auto-checkpoint written before the jump.
+ * See notes/project-versioning.md.
+ */
+export class VersionsManager {
+ private versionsByProject = new Map<string, ProjectVersion[]>();
+ private pinnedMediaByProject = new Map<string, Set<string>>();
+ private lastAutoCheckpointAt = new Map<string, number>();
+ private hydratedProjectId: string | null = null;
+ private listeners = new Set<() => void>();
+ private store: VersionStore;
+ private now: () => number;
+
+ constructor({
+ editor,
+ store,
+ now,
+ }: {
+ editor: EditorCore;
+ store?: VersionStore;
+ now?: () => number;
+ }) {
+ this.editor = editor;
+ // Without IndexedDB (unit tests, unsupported browsers) persistence is
+ // impossible; fall back to the in-memory store instead of failing.
+ this.store =
+ store ??
+ (typeof indexedDB === "undefined"
+ ? new InMemoryVersionStore()
+ : new IndexedDBVersionStore());
+ this.now = now ?? (() => Date.now());
+
+ this.editor.project.subscribe(() => {
+ const activeProjectId =
+ this.editor.project.getActiveOrNull()?.metadata.id ?? null;
+ if (activeProjectId === this.hydratedProjectId) return;
+ this.hydratedProjectId = activeProjectId;
+ if (activeProjectId) {
+ this.listVersions({ projectId: activeProjectId }).catch((error) => {
+ console.error("Failed to load project versions:", error);
+ });
+ }
+ });
+ }
+
+ private editor: EditorCore;
+
+ /** Test seam: swap the persistence backend / clock. */
+ setStore({ store }: { store: VersionStore }): void {
+ this.store = store;
+ }
+
+ setNow({ now }: { now: () => number }): void {
+ this.now = now;
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.listeners.forEach((listener) => {
+ listener();
+ });
+ }
+
+ /** Cached versions for a project, newest first. */
+ getVersions({ projectId }: { projectId: string }): ProjectVersion[] {
+ return this.versionsByProject.get(projectId) ?? [];
+ }
+
+ /** Reloads versions for a project from the store into the cache. */
+ async listVersions({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<ProjectVersion[]> {
+ try {
+ const versions = sortNewestFirst({
+ versions: await this.store.getAllForProject({ projectId }),
+ });
+ this.setProjectVersions({ projectId, versions });
+ return versions;
+ } catch (error) {
+ console.error("Failed to list project versions:", error);
+ return this.getVersions({ projectId });
+ }
+ }
+
+ async createNamedCheckpoint({
+ name,
+ }: {
+ name: string;
+ }): Promise<ProjectVersion | null> {
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Checkpoint needs a name");
+ return null;
+ }
+
+ const thumbnail = await captureVersionThumbnail({ editor: this.editor });
+ const version = this.buildSnapshot({
+ kind: "named",
+ name: trimmedName,
+ thumbnail,
+ });
+ if (!version) return null;
+
+ const didWrite = await this.putVersion({ version });
+ if (!didWrite) return null;
+
+ toast.success(`Checkpoint "${trimmedName}" created`);
+ return version;
+ }
+
+ /**
+ * Writes an auto-checkpoint of the current head, throttled to at most one
+ * per AUTO_CHECKPOINT_MIN_INTERVAL_MS and project. `force` skips the
+ * throttle (used for the safety checkpoint before a restore jump).
+ * Returns false when throttled, when there is nothing to snapshot, or when
+ * the write failed.
+ */
+ async writeAutoCheckpoint({
+ force = false,
+ }: { force?: boolean } = {}): Promise<boolean> {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return false;
+
+ const projectId = activeProject.metadata.id;
+ const nowMs = this.now();
+ const lastAt = this.lastAutoCheckpointAt.get(projectId);
+ if (
+ !force &&
+ lastAt !== undefined &&
+ nowMs - lastAt < AUTO_CHECKPOINT_MIN_INTERVAL_MS
+ ) {
+ return false;
+ }
+
+ const version = this.buildSnapshot({ kind: "auto" });
+ if (!version) return false;
+
+ const didWrite = await this.putVersion({ version });
+ if (!didWrite) return false;
+
+ this.lastAutoCheckpointAt.set(projectId, nowMs);
+ await this.rotateAutoCheckpoints({ projectId });
+ return true;
+ }
+
+ /**
+ * Jumps head to a stored version: writes a safety auto-checkpoint of the
+ * current head first, then swaps the document wholesale (bypassing
+ * undo/redo — undo of a restore = that safety checkpoint).
+ */
+ async restoreVersion({ versionId }: { versionId: string }): Promise<boolean> {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return false;
+
+ const version = this.getVersions({
+ projectId: activeProject.metadata.id,
+ }).find((candidate) => candidate.id === versionId);
+ if (!version) {
+ toast.error("Version not found");
+ return false;
+ }
+
+ const didWriteSafetyCheckpoint = await this.writeAutoCheckpoint({
+ force: true,
+ });
+ if (!didWriteSafetyCheckpoint) {
+ toast.error("Couldn't save a safety checkpoint of the current state", {
+ description: "Restore aborted so no work is lost.",
+ });
+ return false;
+ }
+
+ const project = deserializeProject({ serialized: version.project });
+ this.editor.project.setActiveProject({ project });
+ this.editor.scenes.initializeScenes({
+ scenes: project.scenes,
+ currentSceneId: project.currentSceneId,
+ });
+ this.editor.playback.seek({ time: ZERO_MEDIA_TIME });
+ this.editor.command.clear();
+ this.editor.selection.clearSelection();
+ this.editor.save.markDirty({ force: true });
+ this.notify();
+
+ toast.success(`Restored ${getVersionLabel({ version })}`, {
+ description: "Previous state saved as an auto-checkpoint.",
+ });
+ return true;
+ }
+
+ async renameVersion({
+ versionId,
+ name,
+ }: {
+ versionId: string;
+ name: string;
+ }): Promise<boolean> {
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Checkpoint needs a name");
+ return false;
+ }
+
+ const found = this.findVersion({ versionId });
+ if (!found) {
+ toast.error("Version not found");
+ return false;
+ }
+ if (found.version.kind !== "named") {
+ toast.error("Auto-checkpoints can't be renamed");
+ return false;
+ }
+
+ const renamed: ProjectVersion = { ...found.version, name: trimmedName };
+ try {
+ await this.store.put({ version: renamed });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to rename checkpoint" });
+ return false;
+ }
+
+ this.replaceCachedVersion({ version: renamed });
+ return true;
+ }
+
+ /** Deletes a named checkpoint. Auto-checkpoints only leave via rotation. */
+ async deleteVersion({ versionId }: { versionId: string }): Promise<boolean> {
+ const found = this.findVersion({ versionId });
+ if (!found) {
+ toast.error("Version not found");
+ return false;
+ }
+ if (found.version.kind !== "named") {
+ toast.error("Auto-checkpoints can't be deleted", {
+ description: "They rotate out automatically.",
+ });
+ return false;
+ }
+
+ try {
+ await this.store.delete({ id: versionId });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to delete checkpoint" });
+ return false;
+ }
+
+ const remaining = this.getVersions({ projectId: found.projectId }).filter(
+ (version) => version.id !== versionId,
+ );
+ this.setProjectVersions({ projectId: found.projectId, versions: remaining });
+ return true;
+ }
+
+ async deleteVersionsForProject({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ try {
+ await this.store.deleteForProject({ projectId });
+ } catch (error) {
+ console.error("Failed to delete project versions:", error);
+ }
+ this.versionsByProject.delete(projectId);
+ this.pinnedMediaByProject.delete(projectId);
+ this.lastAutoCheckpointAt.delete(projectId);
+ this.notify();
+ }
+
+ /**
+ * Sync pin check backed by the cached version list (hydrated on project
+ * load). Deleting a pinned media asset is blocked so a restore can never
+ * hit a missing blob.
+ */
+ isMediaIdPinned({
+ projectId,
+ mediaId,
+ }: {
+ projectId: string;
+ mediaId: string;
+ }): boolean {
+ return this.pinnedMediaByProject.get(projectId)?.has(mediaId) ?? false;
+ }
+
+ private buildSnapshot({
+ kind,
+ name,
+ thumbnail,
+ }: {
+ kind: ProjectVersion["kind"];
+ name?: string;
+ thumbnail?: string;
+ }): ProjectVersion | null {
+ const activeProject = this.editor.project.getActiveOrNull();
+ if (!activeProject) return null;
+
+ const scenes = this.editor.scenes.getScenes();
+ const project: TProject = {
+ ...activeProject,
+ scenes,
+ metadata: {
+ ...activeProject.metadata,
+ duration: getProjectDurationFromScenes({ scenes }),
+ updatedAt: new Date(this.now()),
+ },
+ };
+
+ return {
+ id: generateUUID(),
+ projectId: activeProject.metadata.id,
+ kind,
+ ...(name !== undefined && { name }),
+ createdAt: new Date(this.now()),
+ project: serializeProject({ project }),
+ ...(thumbnail !== undefined && { thumbnail }),
+ };
+ }
+
+ /** Persists a version and reflects it in the cache. Returns false on failure. */
+ private async putVersion({
+ version,
+ }: {
+ version: ProjectVersion;
+ }): Promise<boolean> {
+ try {
+ await this.store.put({ version });
+ } catch (error) {
+ this.toastStoreError({ error, fallback: "Failed to save checkpoint" });
+ return false;
+ }
+
+ const versions = [
+ version,
+ ...this.getVersions({ projectId: version.projectId }),
+ ];
+ this.setProjectVersions({
+ projectId: version.projectId,
+ versions: sortNewestFirst({ versions }),
+ });
+ return true;
+ }
+
+ /** Ring buffer: keep the newest AUTO_CHECKPOINT_RETENTION autos per project. */
+ private async rotateAutoCheckpoints({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ const versions = this.getVersions({ projectId });
+ const autoVersions = versions.filter((version) => version.kind === "auto");
+ const evicted = autoVersions.slice(AUTO_CHECKPOINT_RETENTION);
+ if (evicted.length === 0) return;
+
+ await Promise.all(
+ evicted.map(async (version) => {
+ try {
+ await this.store.delete({ id: version.id });
+ } catch (error) {
+ console.error("Failed to rotate auto-checkpoint:", error);
+ }
+ }),
+ );
+
+ const evictedIds = new Set(evicted.map((version) => version.id));
+ this.setProjectVersions({
+ projectId,
+ versions: versions.filter((version) => !evictedIds.has(version.id)),
+ });
+ }
+
+ private findVersion({
+ versionId,
+ }: {
+ versionId: string;
+ }): { projectId: string; version: ProjectVersion } | null {
+ for (const [projectId, versions] of this.versionsByProject) {
+ const version = versions.find((candidate) => candidate.id === versionId);
+ if (version) return { projectId, version };
+ }
+ return null;
+ }
+
+ private replaceCachedVersion({ version }: { version: ProjectVersion }): void {
+ const versions = this.getVersions({ projectId: version.projectId }).map(
+ (candidate) => (candidate.id === version.id ? version : candidate),
+ );
+ this.setProjectVersions({ projectId: version.projectId, versions });
+ }
+
+ private setProjectVersions({
+ projectId,
+ versions,
+ }: {
+ projectId: string;
+ versions: ProjectVersion[];
+ }): void {
+ this.versionsByProject.set(projectId, versions);
+ const pinned = new Set<string>();
+ for (const version of versions) {
+ for (const mediaId of getReferencedMediaIds({
+ project: version.project,
+ })) {
+ pinned.add(mediaId);
+ }
+ }
+ this.pinnedMediaByProject.set(projectId, pinned);
+ this.notify();
+ }
+
+ private toastStoreError({
+ error,
+ fallback,
+ }: {
+ error: unknown;
+ fallback: string;
+ }): void {
+ if (isStorageQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ return;
+ }
+ console.error(`${fallback}:`, error);
+ toast.error(fallback);
+ }
+}
+
+function sortNewestFirst({
+ versions,
+}: {
+ versions: ProjectVersion[];
+}): ProjectVersion[] {
+ return [...versions].sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ );
+}
+
+function getVersionLabel({ version }: { version: ProjectVersion }): string {
+ return version.kind === "named" && version.name
+ ? `"${version.name}"`
+ : "auto-checkpoint";
+}
diff --git a/apps/web/src/versions/versions-store.ts b/apps/web/src/versions/versions-store.ts
new file mode 100644
index 00000000..7ae957fb
--- /dev/null
+++ b/apps/web/src/versions/versions-store.ts
@@ -0,0 +1,23 @@
+import { create } from "zustand";
+
+/**
+ * UI-only state for the versions surfaces (popover + checkpoint dialog).
+ * Document state lives in EditorCore's VersionsManager — never here.
+ */
+interface VersionsState {
+ isPanelOpen: boolean;
+ isCheckpointDialogOpen: boolean;
+ setPanelOpen: (args: { open: boolean }) => void;
+ togglePanel: () => void;
+ openCheckpointDialog: () => void;
+ closeCheckpointDialog: () => void;
+}
+
+export const useVersionsStore = create<VersionsState>()((set) => ({
+ isPanelOpen: false,
+ isCheckpointDialogOpen: false,
+ setPanelOpen: ({ open }) => set({ isPanelOpen: open }),
+ togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
+ openCheckpointDialog: () => set({ isCheckpointDialogOpen: true }),
+ closeCheckpointDialog: () => set({ isCheckpointDialogOpen: false }),
+}));