Keep git history graph sticky

Squashed commit from make-git-graph-sticky-current-change.
This commit is contained in:
Jinjing 2026-05-22 01:38:09 -07:00 committed by GitHub
parent 395e98f015
commit 596baf55a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 75 additions and 49 deletions

View File

@ -19,6 +19,14 @@ export type GitHistoryPanelState =
const DEFAULT_GIT_HISTORY_PANEL_HEIGHT = 256
const MIN_GIT_HISTORY_PANEL_HEIGHT = 96
const MAX_GIT_HISTORY_PANEL_HEIGHT = 520
const MAX_GIT_HISTORY_PANEL_VIEWPORT_HEIGHT = '33vh'
type GitHistoryResizeSession = {
startY: number
startHeight: number
previousCursor: string
previousUserSelect: string
}
function clampGitHistoryPanelHeight(height: number): number {
return Math.min(MAX_GIT_HISTORY_PANEL_HEIGHT, Math.max(MIN_GIT_HISTORY_PANEL_HEIGHT, height))
@ -77,29 +85,14 @@ function GitHistoryRow({
const visibleRefs = refs.slice(0, 2)
const hiddenRefs = refs.slice(2)
const rowTooltip = item.message || item.subject
return (
<button
type="button"
className={cn(
'grid min-h-[34px] w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_4.5rem_3.25rem_3.75rem] grid-rows-[auto_auto] items-start gap-x-1.5 px-3 py-1 text-left text-xs transition-colors',
canOpenCommit && 'cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40',
!canOpenCommit && 'cursor-default',
isBoundaryNode && 'text-muted-foreground'
)}
title={rowTooltip}
aria-disabled={!canOpenCommit}
aria-label={
canOpenCommit ? `Open commit ${item.displayId ?? item.id}: ${item.subject}` : item.subject
}
data-testid="git-history-row"
tabIndex={canOpenCommit ? undefined : -1}
onClick={() => {
if (canOpenCommit) {
onOpenCommit?.(item)
}
}}
>
const rowClassName = cn(
'grid min-h-[34px] w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_4.5rem_3.25rem_3.75rem] grid-rows-[auto_auto] items-start gap-x-1.5 px-3 py-1 text-left text-xs transition-colors',
canOpenCommit && 'cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40',
!canOpenCommit && 'cursor-default',
isBoundaryNode && 'text-muted-foreground'
)
const rowContent = (
<>
<div className="row-span-2">
<GitHistoryGraphSvg viewModel={viewModel} />
</div>
@ -162,6 +155,29 @@ function GitHistoryRow({
</div>
)}
</div>
</>
)
if (!canOpenCommit) {
return (
<div className={rowClassName} title={rowTooltip} data-testid="git-history-row">
{rowContent}
</div>
)
}
return (
<button
type="button"
className={rowClassName}
title={rowTooltip}
aria-label={`Open commit ${item.displayId ?? item.id}: ${item.subject}`}
data-testid="git-history-row"
onClick={() => {
onOpenCommit?.(item)
}}
>
{rowContent}
</button>
)
}
@ -199,15 +215,16 @@ 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)
const resizeSessionRef = useRef<GitHistoryResizeSession | null>(null)
const stopResize = useCallback((): void => {
if (!resizeSessionRef.current) {
const session = resizeSessionRef.current
if (!session) {
return
}
resizeSessionRef.current = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.body.style.cursor = session.previousCursor
document.body.style.userSelect = session.previousUserSelect
}, [])
const handleResizePointerMove = useCallback((event: PointerEvent): void => {
@ -228,6 +245,7 @@ export function GitHistoryPanel({
window.removeEventListener('pointerup', stopResize)
window.removeEventListener('pointercancel', stopResize)
window.removeEventListener('blur', stopResize)
stopResize()
}
}, [handleResizePointerMove, stopResize])
@ -237,7 +255,12 @@ export function GitHistoryPanel({
return
}
event.preventDefault()
resizeSessionRef.current = { startY: event.clientY, startHeight: panelHeight }
resizeSessionRef.current = {
startY: event.clientY,
startHeight: panelHeight,
previousCursor: document.body.style.cursor,
previousUserSelect: document.body.style.userSelect
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
event.currentTarget.setPointerCapture(event.pointerId)
@ -263,7 +286,9 @@ export function GitHistoryPanel({
}, [])
const expandedBodyClassName = 'overflow-y-auto scrollbar-sleek'
const expandedBodyStyle = { height: panelHeight }
const expandedBodyStyle = {
height: `min(${panelHeight}px, ${MAX_GIT_HISTORY_PANEL_VIEWPORT_HEIGHT})`
}
return (
<div className="relative">

View File

@ -1285,6 +1285,8 @@ function SourceControlInner(): React.JSX.Element {
}, [entries])
const normalizedFilter = filterQuery.toLowerCase()
const isGitHistoryVisible =
scope === 'all' && !normalizedFilter && Boolean(activeWorktreeId && worktreePath && !isFolder)
const filteredGrouped = useMemo(() => {
if (!normalizedFilter) {
@ -2955,7 +2957,8 @@ function SourceControlInner(): React.JSX.Element {
!worktreePath ||
isFolder ||
!isBranchVisible ||
!isGitHistoryExpanded
!isGitHistoryExpanded ||
!isGitHistoryVisible
) {
return
}
@ -3010,6 +3013,7 @@ function SourceControlInner(): React.JSX.Element {
isBranchVisible,
isFolder,
isGitHistoryExpanded,
isGitHistoryVisible,
worktreePath
])
@ -3033,7 +3037,7 @@ function SourceControlInner(): React.JSX.Element {
useEffect(() => {
// 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) {
if (!isBranchVisible || !isGitHistoryExpanded || !isGitHistoryVisible) {
return
}
void refreshGitHistoryRef.current()
@ -3043,6 +3047,7 @@ function SourceControlInner(): React.JSX.Element {
isBranchVisible,
isFolder,
isGitHistoryExpanded,
isGitHistoryVisible,
worktreePath
])
@ -4136,24 +4141,20 @@ function SourceControlInner(): React.JSX.Element {
</div>
)}
{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.
<div className="sticky bottom-0 z-10 mt-auto shrink-0 border-t border-border bg-sidebar/95 backdrop-blur-sm">
<GitHistoryPanel
state={gitHistoryState}
collapsed={collapsedSections.has('history')}
onToggle={() => toggleSection('history')}
onRefresh={() => void refreshGitHistory()}
onOpenCommit={(item) => void openHistoryCommitDiff(item)}
/>
</div>
)}
{isGitHistoryVisible && (
// 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.
<div className="sticky bottom-0 z-10 mt-auto shrink-0 border-t border-border bg-sidebar/95 backdrop-blur-sm">
<GitHistoryPanel
state={gitHistoryState}
collapsed={collapsedSections.has('history')}
onToggle={() => toggleSection('history')}
onRefresh={() => void refreshGitHistory()}
onOpenCommit={(item) => void openHistoryCommitDiff(item)}
/>
</div>
)}
</div>
{selectedKeys.size > 0 && (