diff options
Diffstat (limited to 'makima/frontend/src/components/files')
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({ > ← 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> + ); +} |
