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 +++++++++++++++++++++ makima/frontend/src/hooks/useVersionHistory.ts | 137 +++++++++++ makima/frontend/src/lib/api.ts | 135 +++++++++++ makima/frontend/src/routes/files.tsx | 207 +++++++++++++--- 6 files changed, 845 insertions(+), 50 deletions(-) create mode 100644 makima/frontend/src/components/files/VersionHistoryDropdown.tsx create mode 100644 makima/frontend/src/hooks/useVersionHistory.ts (limited to 'makima/frontend/src') 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) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/makima/frontend/src/hooks/useVersionHistory.ts b/makima/frontend/src/hooks/useVersionHistory.ts new file mode 100644 index 0000000..f9d4122 --- /dev/null +++ b/makima/frontend/src/hooks/useVersionHistory.ts @@ -0,0 +1,137 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listFileVersions, + getFileVersion, + restoreFileVersion, + type FileVersionSummary, + type FileVersion, + type FileDetail, + VersionConflictError, +} from "../lib/api"; + +export interface UseVersionHistoryOptions { + fileId: string | null; + currentVersion: number; +} + +export interface VersionHistoryState { + versions: FileVersionSummary[]; + loading: boolean; + error: string | null; + selectedVersion: FileVersion | null; + loadingVersion: boolean; +} + +export function useVersionHistory(options: UseVersionHistoryOptions) { + const { fileId, currentVersion } = options; + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + const [loadingVersion, setLoadingVersion] = useState(false); + const [restoring, setRestoring] = useState(false); + const [conflict, setConflict] = useState<{ expected: number; actual: number } | null>(null); + + // Fetch version list + const fetchVersions = useCallback(async () => { + if (!fileId) return; + + setLoading(true); + setError(null); + try { + const response = await listFileVersions(fileId); + setVersions(response.versions); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch versions"); + } finally { + setLoading(false); + } + }, [fileId]); + + // Fetch a specific version's content + const fetchVersion = useCallback( + async (version: number): Promise => { + if (!fileId) return null; + + setLoadingVersion(true); + setError(null); + try { + const versionData = await getFileVersion(fileId, version); + setSelectedVersion(versionData); + return versionData; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch version"); + return null; + } finally { + setLoadingVersion(false); + } + }, + [fileId] + ); + + // Restore to a specific version (creates a new version with that content) + const restoreToVersion = useCallback( + async (targetVersion: number): Promise => { + if (!fileId) return null; + + setRestoring(true); + setError(null); + setConflict(null); + try { + const result = await restoreFileVersion(fileId, targetVersion, currentVersion); + // Refresh version list after restore + await fetchVersions(); + setSelectedVersion(null); + return result; + } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + expected: e.expectedVersion, + actual: e.actualVersion, + }); + return null; + } + setError(e instanceof Error ? e.message : "Failed to restore version"); + return null; + } finally { + setRestoring(false); + } + }, + [fileId, currentVersion, fetchVersions] + ); + + // Clear selected version + const clearSelectedVersion = useCallback(() => { + setSelectedVersion(null); + }, []); + + // Clear conflict + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + + // Fetch versions when fileId changes + useEffect(() => { + if (fileId) { + fetchVersions(); + } else { + setVersions([]); + setSelectedVersion(null); + } + }, [fileId, fetchVersions]); + + return { + versions, + loading, + error, + selectedVersion, + loadingVersion, + restoring, + conflict, + fetchVersions, + fetchVersion, + restoreToVersion, + clearSelectedVersion, + clearConflict, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index f1e3a9f..931981b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -236,3 +236,138 @@ export async function chatWithFile( } return res.json(); } + +// Version history types +export type VersionSource = "user" | "llm" | "system"; + +export interface FileVersion { + version: number; + name: string; + description: string | null; + summary: string | null; + body: BodyElement[]; + source: VersionSource; + createdAt: string; + changeDescription?: string; +} + +export interface FileVersionSummary { + version: number; + source: VersionSource; + createdAt: string; + changeDescription?: string; +} + +export interface FileVersionListResponse { + versions: FileVersionSummary[]; + total: number; +} + +export interface RestoreVersionRequest { + targetVersion: number; +} + +// Version history API functions +export async function listFileVersions(fileId: string): Promise { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions`); + if (!res.ok) { + throw new Error(`Failed to list versions: ${res.statusText}`); + } + return res.json(); +} + +export async function getFileVersion(fileId: string, version: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`); + if (!res.ok) { + throw new Error(`Failed to get version: ${res.statusText}`); + } + return res.json(); +} + +export async function restoreFileVersion( + fileId: string, + targetVersion: number, + currentVersion: number +): Promise { + const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetVersion, currentVersion }), + }); + + if (res.status === 409) { + const conflict = (await res.json()) as ConflictErrorResponse; + throw new VersionConflictError(conflict); + } + + if (!res.ok) { + throw new Error(`Failed to restore version: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// LLM Tool Definitions for Version History +// ============================================================================= +// These types define the tools available to the LLM for version history access. +// The backend should implement handlers for these tools. + +/** + * Tool: read_version + * Allows the LLM to read the content of a specific historical version. + * This is read-only - it does not modify the document. + */ +export interface ReadVersionToolInput { + version: number; +} + +export interface ReadVersionToolOutput { + success: boolean; + version: number; + body: BodyElement[]; + summary: string | null; + source: VersionSource; + createdAt: string; + changeDescription?: string; + message: string; +} + +/** + * Tool: list_versions + * Allows the LLM to list all available versions of the document. + */ +export interface ListVersionsToolOutput { + success: boolean; + versions: FileVersionSummary[]; + currentVersion: number; + message: string; +} + +/** + * Tool: restore_version + * Allows the LLM to restore the document to a specific historical version. + * This creates a new version with the content from the target version. + */ +export interface RestoreVersionToolInput { + targetVersion: number; + reason?: string; +} + +export interface RestoreVersionToolOutput { + success: boolean; + previousVersion: number; + newVersion: number; + restoredFromVersion: number; + message: string; +} + +// LLM Tool type definitions for the backend +export type LlmVersionTool = + | { name: "read_version"; input: ReadVersionToolInput } + | { name: "list_versions"; input: Record } + | { name: "restore_version"; input: RestoreVersionToolInput }; + +export type LlmVersionToolResult = + | { name: "read_version"; result: ReadVersionToolOutput } + | { name: "list_versions"; result: ListVersionsToolOutput } + | { name: "restore_version"; result: RestoreVersionToolOutput }; diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index f398041..0d870f7 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -7,6 +7,7 @@ import { CliInput } from "../components/files/CliInput"; import { ConflictNotification } from "../components/files/ConflictNotification"; import { UpdateNotification } from "../components/files/UpdateNotification"; import { useFiles } from "../hooks/useFiles"; +import { useVersionHistory } from "../hooks/useVersionHistory"; import { useFileSubscription, type FileUpdateEvent, @@ -22,37 +23,121 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState(null); const [remoteFileData, setRemoteFileData] = useState(null); - const [hasLocalChanges, setHasLocalChanges] = useState(false); - const [isActivelyEditing, setIsActivelyEditing] = useState(false); const pendingUpdateRef = useRef(false); + // Track the last version we sent to detect our own updates + const lastSentVersionRef = useRef(null); + // Track the version we just successfully saved (to ignore its WebSocket notification) + const lastSavedVersionRef = useRef(null); + // Use refs for values checked in WebSocket callback to avoid stale closures + const hasLocalChangesRef = useRef(false); + const isActivelyEditingRef = useRef(false); + const currentVersionRef = useRef(null); + + // Helper functions to update refs (used only in callbacks, not for rendering) + const updateHasLocalChanges = useCallback((value: boolean) => { + hasLocalChangesRef.current = value; + }, []); + + const updateIsActivelyEditing = useCallback((value: boolean) => { + isActivelyEditingRef.current = value; + }, []); + + // Version history + const { + versions, + loading: versionsLoading, + selectedVersion, + loadingVersion, + restoring, + fetchVersion, + restoreToVersion, + clearSelectedVersion, + fetchVersions, + } = useVersionHistory({ + fileId: id || null, + currentVersion: fileDetail?.version || 0, + }); + + // Handle version restore + const handleRestoreVersion = useCallback( + async (targetVersion: number) => { + const result = await restoreToVersion(targetVersion); + if (result) { + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + // Refresh version list after restore + fetchVersions(); + } + }, + [restoreToVersion, fetchVersions, updateHasLocalChanges] + ); // Load file detail when URL has an id useEffect(() => { if (id) { setDetailLoading(true); - setHasLocalChanges(false); + updateHasLocalChanges(false); + // Reset pending update tracking when switching files + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + lastSavedVersionRef.current = null; + currentVersionRef.current = null; + setRemoteUpdate(null); + setRemoteFileData(null); fetchFile(id).then((detail) => { + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); setDetailLoading(false); }); } else { setFileDetail(null); - setHasLocalChanges(false); + currentVersionRef.current = null; + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); // Handle file update events from WebSocket const handleFileUpdate = useCallback( async (event: FileUpdateEvent) => { - // Ignore our own updates + // Check if this is a version we just saved - ignore it + // This handles the case where the WebSocket arrives after the HTTP response + if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { + lastSavedVersionRef.current = null; + return; + } + + // If we have a pending update, check if this is our own update if (pendingUpdateRef.current) { - pendingUpdateRef.current = false; + if (lastSentVersionRef.current !== null) { + const expectedNewVersion = lastSentVersionRef.current + 1; + if (event.version === expectedNewVersion) { + // This is our own update - ignore it + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + return; + } + } + // We sent an update but received a different version - could be a race condition + // Still ignore since we have an update in flight + return; + } + + // Check if this version matches what we already have + // This catches cases where our save's WebSocket arrives late + if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { return; } // If no local changes and not actively editing, auto-refresh - if (!hasLocalChanges && !isActivelyEditing) { + // Use refs to get current values (avoid stale closure issues) + if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { const detail = await fetchFile(event.fileId); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); } else { // Fetch remote version for diff display @@ -62,7 +147,7 @@ export default function FilesPage() { setRemoteUpdate(event); } }, - [hasLocalChanges, isActivelyEditing, fetchFile] + [fetchFile] ); // Subscribe to file updates @@ -98,13 +183,22 @@ export default function FilesPage() { async (fileId: string, name: string, description: string) => { if (!fileDetail) return; pendingUpdateRef.current = true; - const result = await editFile(fileId, { name, description, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(fileId, { name, description, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } }, - [editFile, fileDetail] + [editFile, fileDetail, updateHasLocalChanges] ); const handleBodyUpdate = useCallback( @@ -132,18 +226,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } }, - [fileDetail, id, editFile] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleBodyReorder = useCallback( @@ -159,18 +262,27 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); - setHasLocalChanges(true); + updateHasLocalChanges(true); // Save to backend with version for optimistic locking pendingUpdateRef.current = true; - const result = await editFile(id, { body: newBody, version: fileDetail.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } }, - [fileDetail, id, editFile] + [fileDetail, id, editFile, updateHasLocalChanges] ); const handleCreate = useCallback(async () => { @@ -194,10 +306,13 @@ export default function FilesPage() { if (id) { clearConflict(); const detail = await fetchFile(id); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, clearConflict, fetchFile]); + }, [id, clearConflict, fetchFile, updateHasLocalChanges]); const handleConflictForceOverwrite = useCallback(async () => { if (id && fileDetail) { @@ -207,25 +322,37 @@ export default function FilesPage() { if (latest) { // Retry with latest version pendingUpdateRef.current = true; - const result = await editFile(id, { body: fileDetail.body, version: latest.version }); - if (result) { - setFileDetail(result); - setHasLocalChanges(false); + lastSentVersionRef.current = latest.version; + try { + const result = await editFile(id, { body: fileDetail.body, version: latest.version }); + if (result) { + // Track the saved version to ignore its WebSocket notification + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; } } } - }, [id, fileDetail, clearConflict, fetchFile, editFile]); + }, [id, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); // Remote update handlers const handleRemoteUpdateRefresh = useCallback(async () => { if (id) { const detail = await fetchFile(id); + if (detail) { + currentVersionRef.current = detail.version; + } setFileDetail(detail); setRemoteUpdate(null); setRemoteFileData(null); - setHasLocalChanges(false); + updateHasLocalChanges(false); } - }, [id, fetchFile]); + }, [id, fetchFile, updateHasLocalChanges]); const handleRemoteUpdateDismiss = useCallback(() => { setRemoteUpdate(null); @@ -254,9 +381,17 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} - onEditingChange={setIsActivelyEditing} + onEditingChange={updateIsActivelyEditing} hasPendingRemoteUpdate={!!remoteUpdate} onOverwrite={handleRemoteUpdateDismiss} + versions={versions} + versionsLoading={versionsLoading} + selectedVersion={selectedVersion} + loadingVersion={loadingVersion} + restoring={restoring} + onSelectVersion={fetchVersion} + onRestoreVersion={handleRestoreVersion} + onClearVersionSelection={clearSelectedVersion} />
-- cgit v1.2.3