From d55d2583a71ae35fa8d0d1bfbec975e67e66aafa Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: feat: git-like project versioning (v1) Linear per-project history per notes/project-versioning.md: named checkpoints (thumbnail via renderer.captureFrame) plus auto-checkpoints on save (60s throttle, ring of 20, named never rotate). Restore writes a safety checkpoint first, then swaps the document outside undo/redo. Media assets referenced by any version are pinned against deletion. Migration v31-v32 stamps the schema; the version store lives in its own IDB database with an in-memory fallback for tests. Versions popover in the editor header for jump/rename/delete. 12 new tests. --- .../versions/components/version-name-dialog.tsx | 77 +++++ .../src/versions/components/versions-popover.tsx | 320 +++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 apps/web/src/versions/components/version-name-dialog.tsx create mode 100644 apps/web/src/versions/components/versions-popover.tsx (limited to 'apps/web/src/versions/components') 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 ( + + + + {title} + + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="e.g. Final cut" + /> + + + + + + + + + ); +} 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( + null, + ); + const [renameTarget, setRenameTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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 ( + <> + + + + + +
+ Versions + +
+ {versions.length === 0 ? ( +

+ No versions yet. Create a checkpoint to save a known-good state. +

+ ) : ( + +
+ {versions.map((version) => ( + setRestoreTarget(version)} + onRename={ + version.kind === "named" + ? () => setRenameTarget(version) + : undefined + } + onDelete={ + version.kind === "named" + ? () => setDeleteTarget(version) + : undefined + } + /> + ))} +
+
+ )} +
+
+ + { + if (!open) closeCheckpointDialog(); + }} + onConfirm={handleCreateCheckpoint} + mode="create" + /> + + { + 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 ?? ""} + /> + + { + if (!open) setRestoreTarget(null); + }} + > + + + + Restore {restoreTarget ? getVersionTitle({ version: restoreTarget }) : ""}? + + + The editor will jump to this version. Your current state is saved + as an auto-checkpoint first, so nothing is lost. + + + + Cancel + { + const target = restoreTarget; + setRestoreTarget(null); + if (target) { + editor.versions + .restoreVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to restore version:", error); + }); + } + }} + > + Restore + + + + + + { + if (!open) setDeleteTarget(null); + }} + > + + + + Delete {deleteTarget ? getVersionTitle({ version: deleteTarget }) : ""}? + + + This checkpoint is removed permanently. The current state of the + project is not affected. + + + + Cancel + { + const target = deleteTarget; + setDeleteTarget(null); + if (target) { + editor.versions + .deleteVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to delete checkpoint:", error); + }); + } + }} + > + Delete + + + + + + ); +} + +function VersionRow({ + version, + onRestore, + onRename, + onDelete, +}: { + version: ProjectVersion; + onRestore: () => void; + onRename?: () => void; + onDelete?: () => void; +}) { + return ( +
+ + {onRename && ( + + )} + {onDelete && ( + + )} +
+ ); +} -- cgit v1.3.1