summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/files
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 /makima/frontend/src/components/files
parent8f016a0e9d14badc39dffd67ed6fb862f9d08496 (diff)
downloadsoryu-2faba0388f93d8e4fb86219eba7883b331d501ff.tar.gz
soryu-2faba0388f93d8e4fb86219eba7883b331d501ff.zip
Add versioning to files
Diffstat (limited to 'makima/frontend/src/components/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
3 files changed, 402 insertions, 14 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>
+ );
+}