diff --git a/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx index 98136a43c..c2ef31106 100644 --- a/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx +++ b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, CircleHelp, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -16,6 +16,14 @@ export type GitHistoryPanelState = | { status: 'refreshing' | 'ready'; result: GitHistoryResult; error?: string } | { status: 'error'; result?: GitHistoryResult; error: string } +const DEFAULT_GIT_HISTORY_PANEL_HEIGHT = 256 +const MIN_GIT_HISTORY_PANEL_HEIGHT = 96 +const MAX_GIT_HISTORY_PANEL_HEIGHT = 520 + +function clampGitHistoryPanelHeight(height: number): number { + return Math.min(MAX_GIT_HISTORY_PANEL_HEIGHT, Math.max(MIN_GIT_HISTORY_PANEL_HEIGHT, height)) +} + function formatHistoryTimestamp(timestamp: number | undefined): string { if (!timestamp) { return '' @@ -74,41 +82,63 @@ function GitHistoryRow({ ) } @@ -173,26 +198,102 @@ export function GitHistoryPanel({ const loading = state.status === 'loading' || state.status === 'refreshing' const count = result?.items.length ?? 0 + const [panelHeight, setPanelHeight] = useState(DEFAULT_GIT_HISTORY_PANEL_HEIGHT) + const resizeSessionRef = useRef<{ startY: number; startHeight: number } | null>(null) - if (!result && state.status === 'idle') { - return null - } + const stopResize = useCallback((): void => { + if (!resizeSessionRef.current) { + return + } + resizeSessionRef.current = null + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + const handleResizePointerMove = useCallback((event: PointerEvent): void => { + const session = resizeSessionRef.current + if (!session) { + return + } + setPanelHeight(clampGitHistoryPanelHeight(session.startHeight + session.startY - event.clientY)) + }, []) + + useEffect(() => { + window.addEventListener('pointermove', handleResizePointerMove) + window.addEventListener('pointerup', stopResize) + window.addEventListener('pointercancel', stopResize) + window.addEventListener('blur', stopResize) + return () => { + window.removeEventListener('pointermove', handleResizePointerMove) + window.removeEventListener('pointerup', stopResize) + window.removeEventListener('pointercancel', stopResize) + window.removeEventListener('blur', stopResize) + } + }, [handleResizePointerMove, stopResize]) + + const startResize = useCallback( + (event: React.PointerEvent): void => { + if (collapsed) { + return + } + event.preventDefault() + resizeSessionRef.current = { startY: event.clientY, startHeight: panelHeight } + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + event.currentTarget.setPointerCapture(event.pointerId) + }, + [collapsed, panelHeight] + ) + + const handleResizeKeyDown = useCallback((event: React.KeyboardEvent): void => { + const step = event.shiftKey ? 32 : 16 + if (event.key === 'ArrowUp') { + event.preventDefault() + setPanelHeight((height) => clampGitHistoryPanelHeight(height + step)) + } else if (event.key === 'ArrowDown') { + event.preventDefault() + setPanelHeight((height) => clampGitHistoryPanelHeight(height - step)) + } else if (event.key === 'Home') { + event.preventDefault() + setPanelHeight(MIN_GIT_HISTORY_PANEL_HEIGHT) + } else if (event.key === 'End') { + event.preventDefault() + setPanelHeight(MAX_GIT_HISTORY_PANEL_HEIGHT) + } + }, []) + + const expandedBodyClassName = 'overflow-y-auto scrollbar-sleek' + const expandedBodyStyle = { height: panelHeight } return ( -
-
-
+
+ {!collapsed && ( +
+ )} +
+
@@ -200,7 +301,7 @@ export function GitHistoryPanel({ type="button" variant="ghost" size="icon-xs" - className="h-auto w-auto p-0.5 text-muted-foreground hover:text-foreground" + className="my-auto h-auto w-auto p-0.5 text-muted-foreground hover:bg-transparent hover:text-muted-foreground dark:hover:bg-transparent [&_svg]:size-3" aria-label="What are graph refs?" onClick={(event) => { event.stopPropagation() @@ -220,9 +321,13 @@ export function GitHistoryPanel({ type="button" variant="ghost" size="icon-xs" - className="h-auto w-auto p-0.5 text-muted-foreground hover:text-foreground" + className="my-auto h-auto w-auto p-0.5 text-muted-foreground hover:bg-transparent hover:text-muted-foreground dark:hover:bg-transparent [&_svg]:size-3" onClick={(event) => { event.stopPropagation() + if (collapsed) { + onToggle() + return + } onRefresh() }} aria-label="Refresh graph" @@ -237,19 +342,35 @@ export function GitHistoryPanel({
{!collapsed && state.status === 'error' && !result && ( -
{state.error}
+
+ {state.error} +
)} - {!collapsed && state.status === 'loading' && !result && ( -
+ {!collapsed && (state.status === 'idle' || state.status === 'loading') && !result && ( +
Loading graph...
)} {!collapsed && result && viewModels.length === 0 && ( -
No commits yet
+
+ No commits yet +
)} {!collapsed && viewModels.length > 0 && ( -
+
{viewModels.map((viewModel) => ( => { - if (!activeWorktreeId || !worktreePath || isFolder || !isBranchVisible) { + if ( + !activeWorktreeId || + !worktreePath || + isFolder || + !isBranchVisible || + !isGitHistoryExpanded + ) { return } @@ -2997,7 +3004,14 @@ function SourceControlInner(): React.JSX.Element { } }) } - }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + }, [ + activeWorktreeId, + effectiveBaseRef, + isBranchVisible, + isFolder, + isGitHistoryExpanded, + worktreePath + ]) const refreshGitHistoryRef = useRef(refreshGitHistory) refreshGitHistoryRef.current = refreshGitHistory @@ -3017,14 +3031,20 @@ function SourceControlInner(): React.JSX.Element { }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) useEffect(() => { - // Why: history shells out to git, but unlike branch compare it only needs - // visible-load and mutation refreshes. Avoid polling so long sessions don't - // spawn git processes for a decorative graph. - if (!isBranchVisible) { + // Why: history shells out to git. Defer the first load until the user + // expands Graph so source control stays cheap for large/remote repos. + if (!isBranchVisible || !isGitHistoryExpanded) { return } void refreshGitHistoryRef.current() - }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + }, [ + activeWorktreeId, + effectiveBaseRef, + isBranchVisible, + isFolder, + isGitHistoryExpanded, + worktreePath + ]) useEffect(() => { // Why: gate on isBranchVisible so we don't spawn git processes while the @@ -3697,7 +3717,7 @@ function SourceControlInner(): React.JSX.Element {
0 ? 50 : undefined }} > {unresolvedConflictReviewEntries.length > 0 && ( @@ -4116,20 +4136,24 @@ function SourceControlInner(): React.JSX.Element {
)} - {scope === 'all' && !normalizedFilter && ( - // Why: the graph is reference context for the whole panel, so when - // file sections are short it should occupy the bottom instead of - // crowding the commit controls. -
- toggleSection('history')} - onRefresh={() => void refreshGitHistory()} - onOpenCommit={(item) => void openHistoryCommitDiff(item)} - /> -
- )} + {scope === 'all' && + !normalizedFilter && + activeWorktreeId && + worktreePath && + !isFolder && ( + // Why: the graph is reference context for the whole panel, so when + // file sections are short it should occupy the bottom, and when the + // pane scrolls it should remain docked as branch context. +
+ toggleSection('history')} + onRefresh={() => void refreshGitHistory()} + onOpenCommit={(item) => void openHistoryCommitDiff(item)} + /> +
+ )}
{selectedKeys.size > 0 && (