From 2faba0388f93d8e4fb86219eba7883b331d501ff Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 24 Dec 2025 05:45:22 +0000 Subject: Add versioning to files --- .../frontend/src/components/files/FileDetail.tsx | 35 ++- .../src/components/files/UpdateNotification.tsx | 118 ++++++++- .../components/files/VersionHistoryDropdown.tsx | 263 +++++++++++++++++++++ 3 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 makima/frontend/src/components/files/VersionHistoryDropdown.tsx (limited to 'makima/frontend/src/components') diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 29311b8..c7b716a 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; -import type { FileDetail as FileDetailType } from "../../lib/api"; +import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } from "../../lib/api"; import { BodyRenderer } from "./BodyRenderer"; +import { VersionHistoryDropdown } from "./VersionHistoryDropdown"; interface FileDetailProps { file: FileDetailType; @@ -13,6 +14,15 @@ interface FileDetailProps { onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + // Version history props + versions?: FileVersionSummary[]; + versionsLoading?: boolean; + selectedVersion?: FileVersion | null; + loadingVersion?: boolean; + restoring?: boolean; + onSelectVersion?: (version: number) => void; + onRestoreVersion?: (version: number) => void; + onClearVersionSelection?: () => void; } export function FileDetail({ @@ -26,6 +36,14 @@ export function FileDetail({ onEditingChange, hasPendingRemoteUpdate, onOverwrite, + versions = [], + versionsLoading = false, + selectedVersion = null, + loadingVersion = false, + restoring = false, + onSelectVersion, + onRestoreVersion, + onClearVersionSelection, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -68,9 +86,22 @@ export function FileDetail({ > ← Back to list -
+
{isEditing ? ( <> + {onSelectVersion && onRestoreVersion && onClearVersionSelection && ( + + )}
-
-
-
Your version:
-
- {truncateText(getElementText(diff.localElement!), 120)} -
+ {diff.wordDiff ? ( +
+ {diff.wordDiff.map((word, idx) => { + if (word.type === "same") { + return ( + + {word.text} + + ); + } else if (word.type === "removed") { + return ( + + {word.text} + + ); + } else { + return ( + + {word.text} + + ); + } + })}
-
-
Remote version:
-
- {truncateText(getElementText(diff.remoteElement!), 120)} + ) : ( +
+
+
Your version:
+
+ {getElementText(diff.localElement!)} +
+
+
+
Remote version:
+
+ {getElementText(diff.remoteElement!)} +
-
+ )}
); } diff --git a/makima/frontend/src/components/files/VersionHistoryDropdown.tsx b/makima/frontend/src/components/files/VersionHistoryDropdown.tsx new file mode 100644 index 0000000..50e6f0a --- /dev/null +++ b/makima/frontend/src/components/files/VersionHistoryDropdown.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect, useRef } from "react"; +import type { FileVersionSummary, FileVersion, VersionSource } from "../../lib/api"; + +interface VersionHistoryDropdownProps { + currentVersion: number; + versions: FileVersionSummary[]; + loading: boolean; + selectedVersion: FileVersion | null; + loadingVersion: boolean; + onSelectVersion: (version: number) => void; + onRestoreVersion: (version: number) => void; + onClearSelection: () => void; + restoring: boolean; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getSourceLabel(source: VersionSource): string { + switch (source) { + case "user": + return "User"; + case "llm": + return "AI"; + case "system": + return "System"; + } +} + +function getSourceColor(source: VersionSource): string { + switch (source) { + case "user": + return "text-blue-400"; + case "llm": + return "text-purple-400"; + case "system": + return "text-gray-400"; + } +} + +export function VersionHistoryDropdown({ + currentVersion, + versions, + loading, + selectedVersion, + loadingVersion, + onSelectVersion, + onRestoreVersion, + onClearSelection, + restoring, +}: VersionHistoryDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [versionToRestore, setVersionToRestore] = useState(null); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleVersionClick = (version: number) => { + if (version === currentVersion) return; + onSelectVersion(version); + setIsOpen(false); + }; + + const handleRestoreClick = (version: number) => { + setVersionToRestore(version); + setShowConfirm(true); + }; + + const handleConfirmRestore = () => { + if (versionToRestore !== null) { + onRestoreVersion(versionToRestore); + } + setShowConfirm(false); + setVersionToRestore(null); + }; + + const handleCancelRestore = () => { + setShowConfirm(false); + setVersionToRestore(null); + onClearSelection(); + }; + + // If showing the selected version preview + if (selectedVersion) { + return ( +
+
+
+ Viewing Version + + v{selectedVersion.version} + + + ({getSourceLabel(selectedVersion.source)}) + +
+ +
+ +
+ {formatDate(selectedVersion.createdAt)} + {selectedVersion.changeDescription && ( + - {selectedVersion.changeDescription} + )} +
+ + {/* Preview of version content */} +
+ {selectedVersion.body.length === 0 ? ( +
No content
+ ) : ( +
+ {selectedVersion.body.map((element, index) => ( +
+ {element.type === "heading" && ( +
+ {"#".repeat(element.level)} {element.text} +
+ )} + {element.type === "paragraph" && ( +
{element.text}
+ )} + {element.type === "chart" && ( +
[Chart: {element.title || element.chartType}]
+ )} + {element.type === "image" && ( +
[Image: {element.alt || element.src}]
+ )} +
+ ))} +
+ )} +
+ + {/* Restore button */} + {!showConfirm ? ( + + ) : ( +
+
+ Are you sure you want to restore to version {versionToRestore}? +
+
+ This will create a new version with the content from v{versionToRestore}. + Your current changes will be preserved as a separate version. +
+
+ + +
+
+ )} +
+ ); + } + + return ( +
+ + + {isOpen && ( +
+
+ Version History +
+ + {loading ? ( +
Loading...
+ ) : versions.length === 0 ? ( +
No versions found
+ ) : ( +
+ {versions.map((version) => ( + + ))} +
+ )} +
+ )} +
+ ); +} -- cgit v1.2.3