diff --git a/src/features/version-control/git/components/git-commit-history.tsx b/src/features/version-control/git/components/git-commit-history.tsx index 93b0deb2..81a3221d 100644 --- a/src/features/version-control/git/components/git-commit-history.tsx +++ b/src/features/version-control/git/components/git-commit-history.tsx @@ -1,5 +1,6 @@ -import { ChevronDown, ChevronRight, Clock, Hash, User } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Clock, Hash, User } from "lucide-react"; +import { type MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { getCommitDiff } from "@/features/version-control/git/controllers/git"; import { useGitStore } from "@/features/version-control/git/controllers/git-store"; @@ -9,46 +10,20 @@ interface GitCommitHistoryProps { } const EMPTY_FILES_ARRAY: any[] = []; +const HOVER_CLOSE_DELAY_MS = 250; +const MAX_VISIBLE_FILES = 5; +const FILE_ROW_HEIGHT = 26; interface CommitItemProps { commit: any; - expandedCommits: Set; - commitFiles: Record; - loadingCommits: Set; - copiedHashes: Set; - onToggleExpansion: (commitHash: string) => void; - onViewCommitDiff: (commitHash: string, filePath?: string) => void; - onCopyHash: (hash: string) => void; + isActive: boolean; + onHover: (commit: any, target: HTMLElement) => void; + onHoverEnd: () => void; + onViewCommitDiff: (commitHash: string) => void; } const CommitItem = memo( - ({ - commit, - expandedCommits, - commitFiles, - loadingCommits, - copiedHashes, - onToggleExpansion, - onViewCommitDiff, - onCopyHash, - }: CommitItemProps) => { - const isExpanded = expandedCommits.has(commit.hash); - const files = commitFiles[commit.hash] || EMPTY_FILES_ARRAY; - const isLoading = loadingCommits.has(commit.hash); - const isCopied = copiedHashes.has(commit.hash); - - const handleCommitClick = useCallback(() => { - onViewCommitDiff(commit.hash); - }, [commit.hash, onViewCommitDiff]); - - const handleToggleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleExpansion(commit.hash); - }, - [commit.hash, onToggleExpansion], - ); - + ({ commit, isActive, onHover, onHoverEnd, onViewCommitDiff }: CommitItemProps) => { const formattedDate = useMemo(() => { try { const date = new Date(commit.date); @@ -75,71 +50,80 @@ const CommitItem = memo( } }, [commit.date]); + const handleCommitClick = useCallback(() => { + onViewCommitDiff(commit.hash); + }, [commit.hash, onViewCommitDiff]); + + const handleMouseEnter = useCallback( + (event: MouseEvent) => { + onHover(commit, event.currentTarget); + }, + [commit, onHover], + ); + return ( -
- {/* Commit Header */} -
-
- -
-
- {commit.message} -
- -
- - - {commit.author} - - - - - {formattedDate} - -
+
+
+
+
+ {commit.message} +
+ +
+ + + {commit.author} + + + + + {formattedDate} +
- - {/* Expanded Commit Details */} - {isExpanded && ( - - )}
); }, ); -interface ExpandedCommitDetailsProps { +interface CommitHoverPreviewProps { commit: any; files: any[]; isLoading: boolean; isCopied: boolean; - onViewCommitDiff: (commitHash: string, filePath?: string) => void; + anchorRect: { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; + scrollbarWidth: number; + }; + onKeepOpen: () => void; + onRequestClose: () => void; onCopyHash: (hash: string) => void; + onViewCommitDiff: (commitHash: string, filePath?: string) => void; } -const ExpandedCommitDetails = memo( +const CommitHoverPreview = memo( ({ commit, files, isLoading, isCopied, - onViewCommitDiff, + anchorRect, + onKeepOpen, + onRequestClose, onCopyHash, - }: ExpandedCommitDetailsProps) => { + onViewCommitDiff, + }: CommitHoverPreviewProps) => { const handleCopyClick = useCallback(() => { onCopyHash(commit.hash); }, [commit.hash, onCopyHash]); @@ -151,28 +135,56 @@ const ExpandedCommitDetails = memo( [commit.hash, onViewCommitDiff], ); - if (isLoading) { - return ( -
-
Loading files...
-
- ); - } - - if (files.length === 0) { - return ( -
-
No files changed
-
- ); + const portalTarget = typeof document !== "undefined" ? document.body : null; + + const { top, right, scrollbarWidth } = anchorRect; + const filesScrollable = files.length > MAX_VISIBLE_FILES; + const listMaxHeight = filesScrollable ? MAX_VISIBLE_FILES * FILE_ROW_HEIGHT : undefined; + const windowHeight = typeof window !== "undefined" ? window.innerHeight : 0; + const windowWidth = typeof window !== "undefined" ? window.innerWidth : 0; + + const estimatedHeight = useMemo(() => { + if (isLoading) return 160; + const visibleCount = Math.min(files.length, MAX_VISIBLE_FILES); + const base = files.length === 0 ? 140 : visibleCount * FILE_ROW_HEIGHT + 120; + return Math.min(Math.max(base, 140), 340); + }, [files.length, isLoading]); + + const cardWidth = 260; + const viewportPadding = 12; + + const verticalPosition = (() => { + if (!windowHeight) return top; + const minTop = viewportPadding; + const maxTop = Math.max(windowHeight - estimatedHeight - viewportPadding, viewportPadding); + return Math.min(Math.max(top, minTop), maxTop); + })(); + + const desiredLeft = right + scrollbarWidth + 4; + const horizontalPosition = (() => { + if (!windowWidth) return desiredLeft; + const maxLeft = windowWidth - cardWidth - viewportPadding; + const clamped = Math.min(desiredLeft, maxLeft); + return Math.max(clamped, viewportPadding); + })(); + + if (!portalTarget) { + return null; } - return ( -
-
-
- - {files.length} file{files.length !== 1 ? "s" : ""} changed + return createPortal( +
+
+
{commit.message}
+
+ + + {commit.author}
- {files.map((file, fileIndex) => ( - - ))}
-
+ + {isLoading ? ( +
Loading files...
+ ) : files.length === 0 ? ( +
No files changed
+ ) : ( +
+
+ {files.length} file{files.length !== 1 ? "s" : ""} changed +
+
+ {files.map((file, index) => ( + + ))} +
+
+ )} +
, + portalTarget, ); }, ); @@ -204,18 +237,18 @@ const FileItem = memo(({ file, onFileClick }: FileItemProps) => { const statusColor = useMemo(() => { if (file.is_new) return "text-green-400"; if (file.is_deleted) return "text-red-400"; - return "text-yellow-400"; // modified + return "text-yellow-400"; }, [file.is_new, file.is_deleted]); const statusChar = useMemo(() => { if (file.is_new) return "A"; if (file.is_deleted) return "D"; - return "M"; // modified + return "M"; }, [file.is_new, file.is_deleted]); return (
{statusChar} @@ -229,67 +262,108 @@ const FileItem = memo(({ file, onFileClick }: FileItemProps) => { const GitCommitHistory = ({ onViewCommitDiff, repoPath }: GitCommitHistoryProps) => { const { commits, hasMoreCommits, isLoadingMoreCommits, actions } = useGitStore(); - const [expandedCommits, setExpandedCommits] = useState>(new Set()); const [commitFiles, setCommitFiles] = useState>({}); const [loadingCommits, setLoadingCommits] = useState>(new Set()); const [copiedHashes, setCopiedHashes] = useState>(new Set()); + const [hoveredCommit, setHoveredCommit] = useState<{ + commit: any; + anchorRect: { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; + scrollbarWidth: number; + }; + } | null>(null); const scrollContainerRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); const lastScrollTop = useRef(0); const copyHashTimeoutRef = useRef>(new Map()); const scrollSetupTimeoutRef = useRef(null); const scrollSetupRafRef = useRef(null); - const toggleCommitExpansion = useCallback( - (commitHash: string) => { - setExpandedCommits((prev) => { - const newExpanded = new Set(prev); + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); - if (prev.has(commitHash)) { - newExpanded.delete(commitHash); - return newExpanded; - } else { - newExpanded.add(commitHash); - - if (!commitFiles[commitHash] && repoPath) { - setLoadingCommits((prev) => new Set(prev).add(commitHash)); - - getCommitDiff(repoPath, commitHash) - .then((diffs) => { - setCommitFiles((prev) => ({ - ...prev, - [commitHash]: diffs || [], - })); - }) - .catch(() => {}) - .finally(() => { - setLoadingCommits((prev) => { - const newSet = new Set(prev); - newSet.delete(commitHash); - return newSet; - }); - }); - } + const scheduleHoverClear = useCallback(() => { + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(() => { + setHoveredCommit(null); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimeout]); - return newExpanded; - } + const handleCommitHover = useCallback( + (commit: any, target: HTMLElement) => { + clearHoverTimeout(); + + const container = scrollContainerRef.current; + if (!container) return; + + const targetRect = target.getBoundingClientRect(); + const scrollbarWidth = Math.max(container.offsetWidth - container.clientWidth, 0); + setHoveredCommit({ + commit, + anchorRect: { + top: targetRect.top, + bottom: targetRect.bottom, + left: targetRect.left, + right: targetRect.right, + width: targetRect.width, + height: targetRect.height, + scrollbarWidth, + }, }); + + if (!commitFiles[commit.hash] && repoPath) { + setLoadingCommits((prev) => { + if (prev.has(commit.hash)) return prev; + const next = new Set(prev); + next.add(commit.hash); + return next; + }); + + getCommitDiff(repoPath, commit.hash) + .then((diffs) => { + setCommitFiles((prev) => ({ + ...prev, + [commit.hash]: diffs || [], + })); + }) + .catch(() => {}) + .finally(() => { + setLoadingCommits((prev) => { + const next = new Set(prev); + next.delete(commit.hash); + return next; + }); + }); + } }, - [commitFiles, repoPath], + [clearHoverTimeout, commitFiles, repoPath], ); + const handleCommitHoverEnd = useCallback(() => { + scheduleHoverClear(); + }, [scheduleHoverClear]); + const copyCommitHash = useCallback((hash: string) => { navigator.clipboard.writeText(hash); setCopiedHashes((prev) => new Set(prev).add(hash)); - // Clear any existing timeout for this hash const existingTimeout = copyHashTimeoutRef.current.get(hash); if (existingTimeout) { clearTimeout(existingTimeout); } const timeoutId = setTimeout(() => { setCopiedHashes((prev) => { - const newSet = new Set(prev); - newSet.delete(hash); - return newSet; + const next = new Set(prev); + next.delete(hash); + return next; }); copyHashTimeoutRef.current.delete(hash); }, 1000); @@ -303,6 +377,12 @@ const GitCommitHistory = ({ onViewCommitDiff, repoPath }: GitCommitHistoryProps) [onViewCommitDiff], ); + useEffect(() => { + return () => { + clearHoverTimeout(); + }; + }, [clearHoverTimeout]); + useEffect(() => { if (!repoPath) return; @@ -313,6 +393,11 @@ const GitCommitHistory = ({ onViewCommitDiff, repoPath }: GitCommitHistoryProps) const container = scrollContainerRef.current; if (!container) return; + if (hoveredCommit) { + clearHoverTimeout(); + setHoveredCommit(null); + } + const { scrollTop, scrollHeight, clientHeight } = container; const isScrollingDown = scrollTop > lastScrollTop.current; lastScrollTop.current = scrollTop; @@ -379,12 +464,27 @@ const GitCommitHistory = ({ onViewCommitDiff, repoPath }: GitCommitHistoryProps) clearTimeout(scrollSetupTimeoutRef.current); scrollSetupTimeoutRef.current = null; } - // Clear all copy hash timeouts copyHashTimeoutRef.current.forEach((timeout) => clearTimeout(timeout)); copyHashTimeoutRef.current.clear(); removeScrollListener(); }; - }, [commits.length, hasMoreCommits, isLoadingMoreCommits, repoPath, actions]); + }, [ + commits.length, + hasMoreCommits, + isLoadingMoreCommits, + repoPath, + actions, + hoveredCommit, + clearHoverTimeout, + ]); + + useEffect(() => { + if (!hoveredCommit) return; + const stillExists = commits.some((commit) => commit.hash === hoveredCommit.commit.hash); + if (!stillExists) { + setHoveredCommit(null); + } + }, [commits, hoveredCommit]); if (commits.length === 0) { return ( @@ -407,31 +507,44 @@ const GitCommitHistory = ({ onViewCommitDiff, repoPath }: GitCommitHistoryProps) commits
-
- {commits.map((commit) => ( - - ))} +
+
+ {commits.map((commit) => ( + + ))} - {isLoadingMoreCommits && ( -
- Loading older commits... -
- )} + {isLoadingMoreCommits && ( +
+ Loading older commits... +
+ )} - {!hasMoreCommits && commits.length > 0 && ( -
- — end of history — -
+ {!hasMoreCommits && commits.length > 0 && ( +
+ — end of history — +
+ )} +
+ + {hoveredCommit && ( + )}