summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-24 05:45:22 +0000
committersoryu <soryu@soryu.co>2025-12-24 05:45:22 +0000
commit2faba0388f93d8e4fb86219eba7883b331d501ff (patch)
tree92b83b8d558a652d3777627b2ac95ded250faa48
parent8f016a0e9d14badc39dffd67ed6fb862f9d08496 (diff)
downloadsoryu-2faba0388f93d8e4fb86219eba7883b331d501ff.tar.gz
soryu-2faba0388f93d8e4fb86219eba7883b331d501ff.zip
Add versioning to files
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx35
-rw-r--r--makima/frontend/src/components/files/UpdateNotification.tsx118
-rw-r--r--makima/frontend/src/components/files/VersionHistoryDropdown.tsx263
-rw-r--r--makima/frontend/src/hooks/useVersionHistory.ts137
-rw-r--r--makima/frontend/src/lib/api.ts135
-rw-r--r--makima/frontend/src/routes/files.tsx207
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20241225000000_add_file_versions.sql49
-rw-r--r--makima/src/db/models.rs96
-rw-r--r--makima/src/db/repository.rs144
-rw-r--r--makima/src/llm/mod.rs2
-rw-r--r--makima/src/llm/tools.rs168
-rw-r--r--makima/src/server/handlers/chat.rs246
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/versions.rs207
-rw-r--r--makima/src/server/mod.rs6
16 files changed, 1757 insertions, 59 deletions
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({
>
&larr; Back to list
</button>
- <div className="flex gap-2">
+ <div className="flex items-center gap-2">
{isEditing ? (
<>
+ {onSelectVersion && onRestoreVersion && onClearVersionSelection && (
+ <VersionHistoryDropdown
+ currentVersion={file.version}
+ versions={versions}
+ loading={versionsLoading}
+ selectedVersion={selectedVersion}
+ loadingVersion={loadingVersion}
+ onSelectVersion={onSelectVersion}
+ onRestoreVersion={onRestoreVersion}
+ onClearSelection={onClearVersionSelection}
+ restoring={restoring}
+ />
+ )}
<button
onClick={handleCancel}
className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
diff --git a/makima/frontend/src/components/files/UpdateNotification.tsx b/makima/frontend/src/components/files/UpdateNotification.tsx
index c87d535..863f951 100644
--- a/makima/frontend/src/components/files/UpdateNotification.tsx
+++ b/makima/frontend/src/components/files/UpdateNotification.tsx
@@ -39,12 +39,75 @@ function getElementTypeLabel(element: BodyElement): string {
}
}
+// Word-level diff for showing inline changes
+interface WordDiff {
+ type: "same" | "added" | "removed";
+ text: string;
+}
+
+function computeWordDiff(oldText: string, newText: string): WordDiff[] {
+ const oldWords = oldText.split(/(\s+)/);
+ const newWords = newText.split(/(\s+)/);
+ const result: WordDiff[] = [];
+
+ // Simple LCS-based diff
+ const m = oldWords.length;
+ const n = newWords.length;
+
+ // Build LCS table
+ const dp: number[][] = Array(m + 1)
+ .fill(null)
+ .map(() => Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (oldWords[i - 1] === newWords[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to find diff
+ let i = m,
+ j = n;
+ const diffStack: WordDiff[] = [];
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
+ diffStack.push({ type: "same", text: oldWords[i - 1] });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ diffStack.push({ type: "added", text: newWords[j - 1] });
+ j--;
+ } else {
+ diffStack.push({ type: "removed", text: oldWords[i - 1] });
+ i--;
+ }
+ }
+
+ // Reverse and merge consecutive same-type diffs
+ for (let k = diffStack.length - 1; k >= 0; k--) {
+ const item = diffStack[k];
+ if (result.length > 0 && result[result.length - 1].type === item.type) {
+ result[result.length - 1].text += item.text;
+ } else {
+ result.push({ ...item });
+ }
+ }
+
+ return result;
+}
+
interface DiffItem {
type: "added" | "removed" | "modified" | "unchanged";
localElement?: BodyElement;
remoteElement?: BodyElement;
localIndex?: number;
remoteIndex?: number;
+ wordDiff?: WordDiff[];
}
// Simple diff algorithm - compares elements by their text content
@@ -66,13 +129,15 @@ function computeDiff(localBody: BodyElement[], remoteBody: BodyElement[]): DiffI
const localText = getElementText(local);
const remoteText = getElementText(remote);
if (localText !== remoteText || local.type !== remote.type) {
- // Element modified
+ // Element modified - compute word-level diff
+ const wordDiff = computeWordDiff(localText, remoteText);
diffs.push({
type: "modified",
localElement: local,
remoteElement: remote,
localIndex: i,
remoteIndex: i,
+ wordDiff,
});
}
// Skip unchanged elements
@@ -186,20 +251,49 @@ function DiffItemView({ diff }: { diff: DiffItem }) {
[{getElementTypeLabel(diff.localElement!)}]
</span>
</div>
- <div className="space-y-2">
- <div>
- <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div>
- <div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50">
- {truncateText(getElementText(diff.localElement!), 120)}
- </div>
+ {diff.wordDiff ? (
+ <div className="font-mono text-xs break-words leading-relaxed">
+ {diff.wordDiff.map((word, idx) => {
+ if (word.type === "same") {
+ return (
+ <span key={idx} className="text-white/60">
+ {word.text}
+ </span>
+ );
+ } else if (word.type === "removed") {
+ return (
+ <span
+ key={idx}
+ className="bg-red-500/30 text-red-300 line-through"
+ >
+ {word.text}
+ </span>
+ );
+ } else {
+ return (
+ <span key={idx} className="bg-green-500/30 text-green-300">
+ {word.text}
+ </span>
+ );
+ }
+ })}
</div>
- <div>
- <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div>
- <div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50">
- {truncateText(getElementText(diff.remoteElement!), 120)}
+ ) : (
+ <div className="space-y-2">
+ <div>
+ <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div>
+ <div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50">
+ {getElementText(diff.localElement!)}
+ </div>
+ </div>
+ <div>
+ <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div>
+ <div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50">
+ {getElementText(diff.remoteElement!)}
+ </div>
</div>
</div>
- </div>
+ )}
</div>
);
}
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<number | null>(null);
+ const dropdownRef = useRef<HTMLDivElement>(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 (
+ <div className="border border-[#3f6fb3]/50 bg-[#0d1b2d] p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-xs text-[#75aafc] uppercase">Viewing Version</span>
+ <span className="font-mono text-sm text-[#dbe7ff] font-bold">
+ v{selectedVersion.version}
+ </span>
+ <span className={`font-mono text-xs ${getSourceColor(selectedVersion.source)}`}>
+ ({getSourceLabel(selectedVersion.source)})
+ </span>
+ </div>
+ <button
+ onClick={onClearSelection}
+ className="font-mono text-xs text-[#555] hover:text-white/70"
+ >
+ Close
+ </button>
+ </div>
+
+ <div className="text-[10px] font-mono text-[#555] mb-3">
+ {formatDate(selectedVersion.createdAt)}
+ {selectedVersion.changeDescription && (
+ <span className="ml-2 text-[#9bc3ff]">- {selectedVersion.changeDescription}</span>
+ )}
+ </div>
+
+ {/* Preview of version content */}
+ <div className="max-h-48 overflow-y-auto mb-4 border border-[#333] bg-black/20 p-3">
+ {selectedVersion.body.length === 0 ? (
+ <div className="text-[#555] text-xs italic">No content</div>
+ ) : (
+ <div className="space-y-2">
+ {selectedVersion.body.map((element, index) => (
+ <div key={index} className="font-mono text-xs text-white/70">
+ {element.type === "heading" && (
+ <div className="text-[#9bc3ff] font-bold">
+ {"#".repeat(element.level)} {element.text}
+ </div>
+ )}
+ {element.type === "paragraph" && (
+ <div className="text-white/60">{element.text}</div>
+ )}
+ {element.type === "chart" && (
+ <div className="text-purple-400">[Chart: {element.title || element.chartType}]</div>
+ )}
+ {element.type === "image" && (
+ <div className="text-green-400">[Image: {element.alt || element.src}]</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Restore button */}
+ {!showConfirm ? (
+ <button
+ onClick={() => handleRestoreClick(selectedVersion.version)}
+ disabled={restoring}
+ className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50"
+ >
+ {restoring ? "Restoring..." : "Restore This Version"}
+ </button>
+ ) : (
+ <div className="border border-yellow-500/30 bg-yellow-500/10 p-3">
+ <div className="font-mono text-xs text-yellow-400 mb-2">
+ Are you sure you want to restore to version {versionToRestore}?
+ </div>
+ <div className="font-mono text-[10px] text-white/50 mb-3">
+ This will create a new version with the content from v{versionToRestore}.
+ Your current changes will be preserved as a separate version.
+ </div>
+ <div className="flex gap-2">
+ <button
+ onClick={handleConfirmRestore}
+ disabled={restoring}
+ className="flex-1 px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-yellow-600/50 border border-yellow-500/50 hover:bg-yellow-600/70 transition-colors disabled:opacity-50"
+ >
+ {restoring ? "Restoring..." : "Confirm Restore"}
+ </button>
+ <button
+ onClick={handleCancelRestore}
+ disabled={restoring}
+ className="px-3 py-1.5 font-mono text-xs text-[#555] border border-[#333] hover:text-white/70"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <div ref={dropdownRef} className="relative">
+ <button
+ onClick={() => setIsOpen(!isOpen)}
+ className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ <span>v{currentVersion}</span>
+ <svg
+ width="10"
+ height="6"
+ viewBox="0 0 10 6"
+ fill="none"
+ className={`transition-transform ${isOpen ? "rotate-180" : ""}`}
+ >
+ <path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
+ </svg>
+ </button>
+
+ {isOpen && (
+ <div className="absolute top-full left-0 mt-1 w-64 max-h-72 overflow-y-auto bg-[#0d1b2d] border border-[#3f6fb3]/50 shadow-lg z-50">
+ <div className="p-2 border-b border-[#333] font-mono text-[10px] text-[#555] uppercase">
+ Version History
+ </div>
+
+ {loading ? (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">Loading...</div>
+ ) : versions.length === 0 ? (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">No versions found</div>
+ ) : (
+ <div>
+ {versions.map((version) => (
+ <button
+ key={version.version}
+ onClick={() => handleVersionClick(version.version)}
+ disabled={version.version === currentVersion || loadingVersion}
+ className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${
+ version.version === currentVersion
+ ? "bg-[#0f3c78]/50 text-[#dbe7ff]"
+ : "hover:bg-[#0f3c78]/30 text-white/70"
+ } ${loadingVersion ? "opacity-50" : ""}`}
+ >
+ <div className="flex items-center justify-between">
+ <span className="font-bold">v{version.version}</span>
+ <span className={getSourceColor(version.source)}>
+ {getSourceLabel(version.source)}
+ </span>
+ </div>
+ <div className="text-[10px] text-[#555] mt-0.5">
+ {formatDate(version.createdAt)}
+ </div>
+ {version.changeDescription && (
+ <div className="text-[10px] text-[#75aafc] mt-0.5 truncate">
+ {version.changeDescription}
+ </div>
+ )}
+ {version.version === currentVersion && (
+ <div className="text-[10px] text-green-400 mt-0.5">(current)</div>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
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<FileVersionSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [selectedVersion, setSelectedVersion] = useState<FileVersion | null>(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<FileVersion | null> => {
+ 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<FileDetail | null> => {
+ 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<FileVersionListResponse> {
+ 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<FileVersion> {
+ 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<FileDetail> {
+ 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<string, never> }
+ | { 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<FileUpdateEvent | null>(null);
const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(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<number | null>(null);
+ // Track the version we just successfully saved (to ignore its WebSocket notification)
+ const lastSavedVersionRef = useRef<number | null>(null);
+ // Use refs for values checked in WebSocket callback to avoid stale closures
+ const hasLocalChangesRef = useRef(false);
+ const isActivelyEditingRef = useRef(false);
+ const currentVersionRef = useRef<number | null>(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}
/>
</div>
<div className="shrink-0">
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index bda8af0..d7218f9 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20241225000000_add_file_versions.sql b/makima/migrations/20241225000000_add_file_versions.sql
new file mode 100644
index 0000000..5074eaa
--- /dev/null
+++ b/makima/migrations/20241225000000_add_file_versions.sql
@@ -0,0 +1,49 @@
+-- Create file_versions table to store version history
+CREATE TABLE IF NOT EXISTS file_versions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ version INTEGER NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ summary TEXT,
+ body JSONB NOT NULL DEFAULT '[]'::jsonb,
+ source VARCHAR(32) NOT NULL DEFAULT 'user', -- 'user', 'llm', or 'system'
+ change_description TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ -- Each file can only have one record per version number
+ UNIQUE(file_id, version)
+);
+
+-- Index for efficient version lookups
+CREATE INDEX idx_file_versions_file_id ON file_versions(file_id);
+CREATE INDEX idx_file_versions_file_version ON file_versions(file_id, version DESC);
+
+-- Function to save a version snapshot before file updates
+CREATE OR REPLACE FUNCTION save_file_version()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Save the current state as a version before the update
+ INSERT INTO file_versions (file_id, version, name, description, summary, body, source, change_description, created_at)
+ VALUES (
+ OLD.id,
+ OLD.version,
+ OLD.name,
+ OLD.description,
+ OLD.summary,
+ OLD.body,
+ COALESCE(current_setting('app.version_source', true), 'user'),
+ current_setting('app.change_description', true),
+ OLD.updated_at
+ )
+ ON CONFLICT (file_id, version) DO NOTHING;
+
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+-- Trigger to automatically save versions on update
+CREATE TRIGGER save_file_version_trigger
+ BEFORE UPDATE ON files
+ FOR EACH ROW
+ EXECUTE FUNCTION save_file_version();
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 8204b86..617e590 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -149,3 +149,99 @@ impl From<File> for FileSummary {
}
}
}
+
+// =============================================================================
+// Version History Types
+// =============================================================================
+
+/// Source of a version change
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, sqlx::Type)]
+#[sqlx(type_name = "varchar")]
+#[serde(rename_all = "lowercase")]
+pub enum VersionSource {
+ #[sqlx(rename = "user")]
+ User,
+ #[sqlx(rename = "llm")]
+ Llm,
+ #[sqlx(rename = "system")]
+ System,
+}
+
+impl std::fmt::Display for VersionSource {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ VersionSource::User => write!(f, "user"),
+ VersionSource::Llm => write!(f, "llm"),
+ VersionSource::System => write!(f, "system"),
+ }
+ }
+}
+
+impl std::str::FromStr for VersionSource {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "user" => Ok(VersionSource::User),
+ "llm" => Ok(VersionSource::Llm),
+ "system" => Ok(VersionSource::System),
+ _ => Err(format!("Unknown version source: {}", s)),
+ }
+ }
+}
+
+/// Full version record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileVersion {
+ pub id: Uuid,
+ pub file_id: Uuid,
+ pub version: i32,
+ pub name: String,
+ pub description: Option<String>,
+ pub summary: Option<String>,
+ #[sqlx(json)]
+ pub body: Vec<BodyElement>,
+ pub source: String,
+ pub change_description: Option<String>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Summary of a version for list views
+#[derive(Debug, Clone, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileVersionSummary {
+ pub version: i32,
+ pub source: String,
+ pub created_at: DateTime<Utc>,
+ pub change_description: Option<String>,
+}
+
+impl From<FileVersion> for FileVersionSummary {
+ fn from(v: FileVersion) -> Self {
+ Self {
+ version: v.version,
+ source: v.source,
+ created_at: v.created_at,
+ change_description: v.change_description,
+ }
+ }
+}
+
+/// Response for version list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileVersionListResponse {
+ pub versions: Vec<FileVersionSummary>,
+ pub total: i64,
+}
+
+/// Request to restore a file to a previous version
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct RestoreVersionRequest {
+ /// The version to restore to
+ pub target_version: i32,
+ /// The current version (for optimistic locking)
+ pub current_version: i32,
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 5b962ee..4137ba6 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -4,7 +4,7 @@ use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
-use super::models::{CreateFileRequest, File, UpdateFileRequest};
+use super::models::{CreateFileRequest, File, FileVersion, UpdateFileRequest};
/// Default owner ID for anonymous users.
pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002);
@@ -221,3 +221,145 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
Ok(result.0)
}
+
+// =============================================================================
+// Version History Functions
+// =============================================================================
+
+/// Set the version source for the current transaction.
+/// This is used by the trigger to record who made the change.
+pub async fn set_version_source(pool: &PgPool, source: &str) -> Result<(), sqlx::Error> {
+ sqlx::query(&format!("SET LOCAL app.version_source = '{}'", source))
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+/// Set the change description for the current transaction.
+pub async fn set_change_description(pool: &PgPool, description: &str) -> Result<(), sqlx::Error> {
+ // Escape single quotes for SQL
+ let escaped = description.replace('\'', "''");
+ sqlx::query(&format!("SET LOCAL app.change_description = '{}'", escaped))
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+/// List all versions of a file, ordered by version DESC.
+pub async fn list_file_versions(pool: &PgPool, file_id: Uuid) -> Result<Vec<FileVersion>, sqlx::Error> {
+ // First get the current version from the files table
+ let current = get_file(pool, file_id).await?;
+
+ let mut versions = sqlx::query_as::<_, FileVersion>(
+ r#"
+ SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at
+ FROM file_versions
+ WHERE file_id = $1
+ ORDER BY version DESC
+ "#,
+ )
+ .bind(file_id)
+ .fetch_all(pool)
+ .await?;
+
+ // Add the current version as the first entry if it exists
+ if let Some(file) = current {
+ let current_version = FileVersion {
+ id: file.id,
+ file_id: file.id,
+ version: file.version,
+ name: file.name,
+ description: file.description,
+ summary: file.summary,
+ body: file.body,
+ source: "user".to_string(), // Current version source
+ change_description: None,
+ created_at: file.updated_at,
+ };
+ versions.insert(0, current_version);
+ }
+
+ Ok(versions)
+}
+
+/// Get a specific version of a file.
+pub async fn get_file_version(
+ pool: &PgPool,
+ file_id: Uuid,
+ version: i32,
+) -> Result<Option<FileVersion>, sqlx::Error> {
+ // First check if this is the current version
+ if let Some(file) = get_file(pool, file_id).await? {
+ if file.version == version {
+ return Ok(Some(FileVersion {
+ id: file.id,
+ file_id: file.id,
+ version: file.version,
+ name: file.name,
+ description: file.description,
+ summary: file.summary,
+ body: file.body,
+ source: "user".to_string(),
+ change_description: None,
+ created_at: file.updated_at,
+ }));
+ }
+ }
+
+ // Otherwise, look in the versions table
+ sqlx::query_as::<_, FileVersion>(
+ r#"
+ SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at
+ FROM file_versions
+ WHERE file_id = $1 AND version = $2
+ "#,
+ )
+ .bind(file_id)
+ .bind(version)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Restore a file to a previous version.
+/// This creates a new version with the content from the target version.
+pub async fn restore_file_version(
+ pool: &PgPool,
+ file_id: Uuid,
+ target_version: i32,
+ current_version: i32,
+) -> Result<Option<File>, RepositoryError> {
+ // Get the target version content
+ let target = get_file_version(pool, file_id, target_version).await?;
+ let Some(target) = target else {
+ return Ok(None);
+ };
+
+ // Set version source and description for the trigger
+ set_version_source(pool, "system").await?;
+ set_change_description(pool, &format!("Restored from version {}", target_version)).await?;
+
+ // Update the file with the target version's content
+ // This will trigger the save_file_version trigger to save the current state first
+ let update_req = UpdateFileRequest {
+ name: Some(target.name),
+ description: target.description,
+ transcript: None,
+ summary: target.summary,
+ body: Some(target.body),
+ version: Some(current_version),
+ };
+
+ update_file(pool, file_id, update_req).await
+}
+
+/// Count versions for a file.
+pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as(
+ "SELECT COUNT(*) + 1 FROM file_versions WHERE file_id = $1", // +1 for current version
+ )
+ .bind(file_id)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0)
+}
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs
index 7de8afe..0df492d 100644
--- a/makima/src/llm/mod.rs
+++ b/makima/src/llm/mod.rs
@@ -6,7 +6,7 @@ pub mod tools;
pub use claude::{ClaudeClient, ClaudeModel};
pub use groq::GroqClient;
-pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, AVAILABLE_TOOLS};
+pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS};
/// Available LLM providers and models
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs
index e6b2954..35f321f 100644
--- a/makima/src/llm/tools.rs
+++ b/makima/src/llm/tools.rs
@@ -232,9 +232,62 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
"required": ["input", "filter"]
}),
},
+ // Version history tools
+ Tool {
+ name: "list_versions".to_string(),
+ description: "List all available versions of the current document. Returns version numbers, sources (user/llm/system), timestamps, and change descriptions.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {},
+ "required": []
+ }),
+ },
+ Tool {
+ name: "read_version".to_string(),
+ description: "Read the content of a specific historical version of the document. This is read-only and does not modify the current document.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "integer",
+ "description": "The version number to read"
+ }
+ },
+ "required": ["version"]
+ }),
+ },
+ Tool {
+ name: "restore_version".to_string(),
+ description: "Restore the document to a previous version. This creates a new version with the content from the target version. The current content will be preserved as a historical version.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "target_version": {
+ "type": "integer",
+ "description": "The version number to restore to"
+ },
+ "reason": {
+ "type": "string",
+ "description": "Optional reason for the restore (will be recorded in change description)"
+ }
+ },
+ "required": ["target_version"]
+ }),
+ },
]
});
+/// Request for version-related operations that require async database access
+#[derive(Debug, Clone)]
+pub enum VersionToolRequest {
+ /// List all versions of the current file
+ ListVersions,
+ /// Read a specific version
+ ReadVersion { version: i32 },
+ /// Restore to a specific version
+ RestoreVersion { target_version: i32, reason: Option<String> },
+}
+
/// Result of executing a tool call with modified file state
#[derive(Debug)]
pub struct ToolExecutionResult {
@@ -242,6 +295,8 @@ pub struct ToolExecutionResult {
pub new_body: Option<Vec<BodyElement>>,
pub new_summary: Option<String>,
pub parsed_data: Option<serde_json::Value>,
+ /// Request for async version operations (handled by chat handler)
+ pub version_request: Option<VersionToolRequest>,
}
/// Execute a tool call and return the result along with any state changes
@@ -261,6 +316,10 @@ pub fn execute_tool_call(
"parse_csv" => execute_parse_csv(call),
"clear_body" => execute_clear_body(),
"jq" => execute_jq(call),
+ // Version history tools - return request for async handling
+ "list_versions" => execute_list_versions(),
+ "read_version" => execute_read_version(call),
+ "restore_version" => execute_restore_version(call),
_ => ToolExecutionResult {
result: ToolResult {
success: false,
@@ -269,6 +328,7 @@ pub fn execute_tool_call(
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
},
}
}
@@ -305,6 +365,7 @@ fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExe
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -345,6 +406,7 @@ fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolE
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -410,6 +472,7 @@ fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecu
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -425,6 +488,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
};
@@ -438,6 +502,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -452,6 +517,7 @@ fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -468,6 +534,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
};
@@ -480,6 +547,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
};
@@ -493,6 +561,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -530,6 +599,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
};
@@ -545,6 +615,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -561,6 +632,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
};
@@ -579,6 +651,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -594,6 +667,7 @@ fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> To
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -613,6 +687,7 @@ fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolE
new_body: None,
new_summary: Some(summary),
parsed_data: None,
+ version_request: None,
}
}
@@ -633,6 +708,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -668,6 +744,7 @@ fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: Some(json!(data)),
+ version_request: None,
}
}
@@ -680,6 +757,7 @@ fn execute_clear_body() -> ToolExecutionResult {
new_body: Some(vec![]),
new_summary: None,
parsed_data: None,
+ version_request: None,
}
}
@@ -695,6 +773,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
};
@@ -710,6 +789,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
};
@@ -729,6 +809,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -741,6 +822,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
};
@@ -755,6 +837,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
@@ -779,6 +862,7 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: None,
+ version_request: None,
};
}
}
@@ -808,6 +892,90 @@ fn execute_jq(call: &ToolCall) -> ToolExecutionResult {
new_body: None,
new_summary: None,
parsed_data: Some(output),
+ version_request: None,
+ }
+}
+
+// =============================================================================
+// Version History Tool Execution Functions
+// =============================================================================
+// These return version_request instead of performing the operation directly,
+// because they require async database access which is handled in the chat handler.
+
+fn execute_list_versions() -> ToolExecutionResult {
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: "Listing versions...".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: Some(VersionToolRequest::ListVersions),
+ }
+}
+
+fn execute_read_version(call: &ToolCall) -> ToolExecutionResult {
+ let version = call.arguments.get("version").and_then(|v| v.as_i64());
+
+ let Some(version) = version else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing version parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ };
+ };
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Reading version {}...", version),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }),
+ }
+}
+
+fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult {
+ let target_version = call.arguments.get("target_version").and_then(|v| v.as_i64());
+ let reason = call
+ .arguments
+ .get("reason")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let Some(target_version) = target_version else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing target_version parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ };
+ };
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Restoring to version {}...", target_version),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: Some(VersionToolRequest::RestoreVersion {
+ target_version: target_version as i32,
+ reason,
+ }),
}
}
diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs
index 3bdbc74..396c973 100644
--- a/makima/src/server/handlers/chat.rs
+++ b/makima/src/server/handlers/chat.rs
@@ -10,12 +10,12 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
-use crate::db::{models::BodyElement, repository};
+use crate::db::{models::BodyElement, repository::{self, RepositoryError}};
use crate::llm::{
claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
execute_tool_call,
groq::{GroqClient, GroqError, Message, ToolCallResponse},
- LlmModel, ToolCall, ToolResult, AVAILABLE_TOOLS,
+ LlmModel, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS,
};
use crate::server::state::{FileUpdateNotification, SharedState};
@@ -236,6 +236,10 @@ pub async fn chat_handler(
let mut current_summary = file.summary.clone();
let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new();
let mut final_response: Option<String> = None;
+ // Track if a version restore already happened (to avoid double-saving)
+ let mut version_restored = false;
+ // Track if there were modifications after a restore
+ let mut has_changes_after_restore = false;
// Multi-turn tool calling loop
for round in 0..MAX_TOOL_ROUNDS {
@@ -320,15 +324,49 @@ pub async fn chat_handler(
// Execute each tool call and add results to conversation
for (i, tool_call) in result.tool_calls.iter().enumerate() {
- let execution_result =
+ let mut execution_result =
execute_tool_call(tool_call, &current_body, current_summary.as_deref());
- // Apply state changes
+ // Handle version tool requests that need async database access
+ if let Some(version_request) = &execution_result.version_request {
+ let version_result = handle_version_request(
+ pool,
+ id,
+ version_request,
+ &current_body,
+ current_summary.as_deref(),
+ file.version,
+ )
+ .await;
+
+ // Update execution result with actual version operation result
+ execution_result.result = version_result.result;
+ execution_result.parsed_data = version_result.data;
+
+ // Apply state changes from restore operation
+ if let Some(new_body) = version_result.new_body {
+ current_body = new_body;
+ // Mark that a restore happened - file was already saved
+ version_restored = true;
+ }
+ if let Some(new_summary) = version_result.new_summary {
+ current_summary = Some(new_summary);
+ }
+ }
+
+ // Apply state changes from regular tools
if let Some(new_body) = execution_result.new_body {
current_body = new_body;
+ // If this is a regular tool (not a version operation), track it
+ if execution_result.version_request.is_none() && version_restored {
+ has_changes_after_restore = true;
+ }
}
if let Some(new_summary) = execution_result.new_summary {
current_summary = Some(new_summary);
+ if execution_result.version_request.is_none() && version_restored {
+ has_changes_after_restore = true;
+ }
}
// Build tool result message content
@@ -378,7 +416,9 @@ pub async fn chat_handler(
}
// Save changes to database if any tools were executed
- if !all_tool_call_infos.is_empty() {
+ // Skip if a version restore already happened (file was already saved during restore)
+ // UNLESS there were additional modifications after the restore
+ if !all_tool_call_infos.is_empty() && (!version_restored || has_changes_after_restore) {
let update_req = crate::db::models::UpdateFileRequest {
name: None,
description: None,
@@ -506,3 +546,199 @@ fn build_file_context(file: &crate::db::models::File) -> String {
context
}
+
+/// Result of handling a version tool request
+struct VersionRequestResult {
+ result: ToolResult,
+ data: Option<serde_json::Value>,
+ new_body: Option<Vec<BodyElement>>,
+ new_summary: Option<String>,
+}
+
+/// Handle version tool requests that require async database access
+async fn handle_version_request(
+ pool: &sqlx::PgPool,
+ file_id: Uuid,
+ request: &VersionToolRequest,
+ _current_body: &[BodyElement],
+ _current_summary: Option<&str>,
+ current_version: i32,
+) -> VersionRequestResult {
+ match request {
+ VersionToolRequest::ListVersions => {
+ match repository::list_file_versions(pool, file_id).await {
+ Ok(versions) => {
+ let version_data: Vec<serde_json::Value> = versions
+ .iter()
+ .map(|v| {
+ serde_json::json!({
+ "version": v.version,
+ "source": v.source,
+ "createdAt": v.created_at.to_rfc3339(),
+ "changeDescription": v.change_description,
+ })
+ })
+ .collect();
+
+ VersionRequestResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Found {} versions. Current version is {}.", versions.len(), current_version),
+ },
+ data: Some(serde_json::json!({
+ "currentVersion": current_version,
+ "versions": version_data,
+ })),
+ new_body: None,
+ new_summary: None,
+ }
+ }
+ Err(e) => VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Failed to list versions: {}", e),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ },
+ }
+ }
+ VersionToolRequest::ReadVersion { version } => {
+ match repository::get_file_version(pool, file_id, *version).await {
+ Ok(Some(ver)) => {
+ // Convert body elements to a readable format
+ let body_preview: Vec<String> = ver
+ .body
+ .iter()
+ .enumerate()
+ .map(|(i, element)| {
+ let desc = match element {
+ BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
+ BodyElement::Paragraph { text } => {
+ let preview = if text.len() > 100 {
+ format!("{}...", &text[..100])
+ } else {
+ text.clone()
+ };
+ format!("Paragraph: {}", preview)
+ }
+ BodyElement::Chart { chart_type, title, .. } => {
+ format!(
+ "Chart ({:?}){}",
+ chart_type,
+ title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default()
+ )
+ }
+ BodyElement::Image { alt, .. } => {
+ format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
+ }
+ };
+ format!("[{}] {}", i, desc)
+ })
+ .collect();
+
+ VersionRequestResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "Version {} from {} (source: {}). {} body elements.",
+ ver.version,
+ ver.created_at.format("%Y-%m-%d %H:%M"),
+ ver.source,
+ ver.body.len()
+ ),
+ },
+ data: Some(serde_json::json!({
+ "version": ver.version,
+ "source": ver.source,
+ "createdAt": ver.created_at.to_rfc3339(),
+ "summary": ver.summary,
+ "bodyPreview": body_preview,
+ "changeDescription": ver.change_description,
+ })),
+ new_body: None,
+ new_summary: None,
+ }
+ }
+ Ok(None) => VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Version {} not found", version),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ },
+ Err(e) => VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Failed to read version: {}", e),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ },
+ }
+ }
+ VersionToolRequest::RestoreVersion { target_version, reason } => {
+ // Set change description if provided
+ if let Some(reason) = reason {
+ let _ = repository::set_change_description(pool, reason).await;
+ }
+
+ match repository::restore_file_version(pool, file_id, *target_version, current_version).await {
+ Ok(Some(restored_file)) => {
+ VersionRequestResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "Restored to version {}. New version is {}.",
+ target_version, restored_file.version
+ ),
+ },
+ data: Some(serde_json::json!({
+ "previousVersion": current_version,
+ "restoredFromVersion": target_version,
+ "newVersion": restored_file.version,
+ })),
+ new_body: Some(restored_file.body),
+ new_summary: restored_file.summary,
+ }
+ }
+ Ok(None) => VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Version {} not found", target_version),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ },
+ Err(RepositoryError::VersionConflict { expected, actual }) => {
+ VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!(
+ "Version conflict: expected {}, actual {}. Document was modified.",
+ expected, actual
+ ),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ }
+ }
+ Err(e) => VersionRequestResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Failed to restore version: {}", e),
+ },
+ data: None,
+ new_body: None,
+ new_summary: None,
+ },
+ }
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index c08f1bd..3211f94 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -4,3 +4,4 @@ pub mod chat;
pub mod file_ws;
pub mod files;
pub mod listen;
+pub mod versions;
diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs
new file mode 100644
index 0000000..15118d6
--- /dev/null
+++ b/makima/src/server/handlers/versions.rs
@@ -0,0 +1,207 @@
+//! HTTP handlers for file version history operations.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{FileVersionListResponse, FileVersionSummary, RestoreVersionRequest};
+use crate::db::repository::{self, RepositoryError};
+use crate::server::messages::ApiError;
+use crate::server::state::{FileUpdateNotification, SharedState};
+
+/// List all versions of a file.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files/{id}/versions",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 200, description = "List of file versions", body = FileVersionListResponse),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Versions"
+)]
+pub async fn list_versions(
+ State(state): State<SharedState>,
+ Path(file_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Check if file exists
+ match repository::get_file(pool, file_id).await {
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to check file {}: {}", file_id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ Ok(Some(_)) => {}
+ }
+
+ match repository::list_file_versions(pool, file_id).await {
+ Ok(versions) => {
+ let summaries: Vec<FileVersionSummary> =
+ versions.into_iter().map(FileVersionSummary::from).collect();
+ let total = summaries.len() as i64;
+ Json(FileVersionListResponse {
+ versions: summaries,
+ total,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list versions for file {}: {}", file_id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a specific version of a file.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files/{id}/versions/{version}",
+ params(
+ ("id" = Uuid, Path, description = "File ID"),
+ ("version" = i32, Path, description = "Version number")
+ ),
+ responses(
+ (status = 200, description = "Version details", body = crate::db::models::FileVersion),
+ (status = 404, description = "Version not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Versions"
+)]
+pub async fn get_version(
+ State(state): State<SharedState>,
+ Path((file_id, version)): Path<(Uuid, i32)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_file_version(pool, file_id, version).await {
+ Ok(Some(version)) => Json(version).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Version not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get version {} for file {}: {}", version, file_id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Restore a file to a previous version.
+#[utoipa::path(
+ post,
+ path = "/api/v1/files/{id}/versions/restore",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ request_body = RestoreVersionRequest,
+ responses(
+ (status = 200, description = "File restored to previous version", body = crate::db::models::File),
+ (status = 404, description = "File or version not found", body = ApiError),
+ (status = 409, description = "Version conflict", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Versions"
+)]
+pub async fn restore_version(
+ State(state): State<SharedState>,
+ Path(file_id): Path<Uuid>,
+ Json(req): Json<RestoreVersionRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::restore_file_version(pool, file_id, req.target_version, req.current_version).await {
+ Ok(Some(file)) => {
+ // Broadcast update notification
+ state.broadcast_file_update(FileUpdateNotification {
+ file_id,
+ version: file.version,
+ updated_fields: vec!["body".to_string(), "summary".to_string()],
+ updated_by: "system".to_string(),
+ });
+ Json(file).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File or version not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => {
+ tracing::info!(
+ "Version conflict on file {} restore: expected {}, actual {}",
+ file_id,
+ expected,
+ actual
+ );
+ (
+ StatusCode::CONFLICT,
+ Json(serde_json::json!({
+ "code": "VERSION_CONFLICT",
+ "message": format!(
+ "File was modified by another user. Expected version {}, actual version {}",
+ expected, actual
+ ),
+ "expectedVersion": expected,
+ "actualVersion": actual,
+ })),
+ )
+ .into_response()
+ }
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Failed to restore file {} to version {}: {}", file_id, req.target_version, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index f132cf4..ee5e9bd 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{chat, file_ws, files, listen};
+use crate::server::handlers::{chat, file_ws, files, listen, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -52,6 +52,10 @@ pub fn make_router(state: SharedState) -> Router {
.delete(files::delete_file),
)
.route("/files/{id}/chat", post(chat::chat_handler))
+ // Version history endpoints
+ .route("/files/{id}/versions", get(versions::list_versions))
+ .route("/files/{id}/versions/{version}", get(versions::get_version))
+ .route("/files/{id}/versions/restore", post(versions::restore_version))
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")