Dock git history at the bottom of source control (#2608)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
41a2d7ba90
commit
191c5b239c
|
|
@ -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({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'grid min-h-[34px] w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-stretch gap-1.5 px-3 py-1 text-left text-xs transition-colors disabled:cursor-default disabled:opacity-100',
|
||||
'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"
|
||||
disabled={!canOpenCommit}
|
||||
tabIndex={canOpenCommit ? undefined : -1}
|
||||
onClick={() => {
|
||||
if (canOpenCommit) {
|
||||
onOpenCommit?.(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitHistoryGraphSvg viewModel={viewModel} />
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden">
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground" title={rowTooltip}>
|
||||
{item.subject}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-96 whitespace-pre-wrap">
|
||||
{rowTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{item.author && (
|
||||
<span className="max-w-[5.5rem] shrink truncate text-[11px] text-muted-foreground">
|
||||
<div className="row-span-2">
|
||||
<GitHistoryGraphSvg viewModel={viewModel} />
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 truncate text-foreground" title={rowTooltip}>
|
||||
{item.subject}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-96 whitespace-pre-wrap">
|
||||
{rowTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{item.author ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="min-w-0 truncate text-right text-[11px] leading-4 text-muted-foreground"
|
||||
title={item.author}
|
||||
>
|
||||
{item.author}
|
||||
</span>
|
||||
)}
|
||||
{timestamp && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{timestamp}</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all">
|
||||
{item.author}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-right text-[11px] leading-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-right text-[11px] leading-4 text-muted-foreground">
|
||||
{timestamp}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-right font-mono text-[10px] leading-4 text-muted-foreground">
|
||||
{!isBoundaryNode ? item.displayId : ''}
|
||||
</span>
|
||||
<div className="col-span-4 col-start-2 min-w-0 overflow-hidden">
|
||||
{refs.length > 0 && (
|
||||
<div className="mt-0.5 flex h-3.5 min-w-0 items-center gap-1 overflow-hidden">
|
||||
{visibleRefs.map((ref) => (
|
||||
|
|
@ -132,11 +162,6 @@ function GitHistoryRow({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.displayId && !isBoundaryNode && (
|
||||
<span className="mt-0.5 shrink-0 font-mono text-[10px] leading-none text-muted-foreground">
|
||||
{item.displayId}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>): 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<HTMLDivElement>): 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 (
|
||||
<div>
|
||||
<div className="pl-1 pr-3 pt-3 pb-1">
|
||||
<div className="group/history flex items-center rounded-md pr-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="relative">
|
||||
{!collapsed && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize graph"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={MIN_GIT_HISTORY_PANEL_HEIGHT}
|
||||
aria-valuemax={MAX_GIT_HISTORY_PANEL_HEIGHT}
|
||||
aria-valuenow={panelHeight}
|
||||
tabIndex={0}
|
||||
className="absolute inset-x-0 -top-1 z-10 h-2 cursor-row-resize outline-none focus-visible:bg-ring/30"
|
||||
onPointerDown={startResize}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
)}
|
||||
<div className="h-7 pl-1 pr-3">
|
||||
<div className="flex h-full items-stretch rounded-md pr-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 group-hover/history:text-accent-foreground"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 px-0.5 text-left text-[11px] font-semibold uppercase tracking-wider text-foreground/70"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 shrink-0 transition-transform', collapsed && '-rotate-90')}
|
||||
className={cn('size-3 shrink-0 transition-transform', collapsed && '-rotate-90')}
|
||||
/>
|
||||
<span>Graph</span>
|
||||
<span className="text-[11px] font-medium tabular-nums">{count}</span>
|
||||
{result?.hasMore && <span className="text-[11px] font-medium">+</span>}
|
||||
{result && <span className="text-[10px] font-medium tabular-nums">{count}</span>}
|
||||
{result?.hasMore && <span className="text-[10px] font-medium">+</span>}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -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({
|
|||
</div>
|
||||
</div>
|
||||
{!collapsed && state.status === 'error' && !result && (
|
||||
<div className="px-6 py-2 text-[11px] text-destructive">{state.error}</div>
|
||||
<div
|
||||
className={cn(expandedBodyClassName, 'px-6 py-2 text-[11px] text-destructive')}
|
||||
style={expandedBodyStyle}
|
||||
>
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && state.status === 'loading' && !result && (
|
||||
<div className="flex items-center gap-2 px-6 py-2 text-[11px] text-muted-foreground">
|
||||
{!collapsed && (state.status === 'idle' || state.status === 'loading') && !result && (
|
||||
<div
|
||||
className={cn(
|
||||
expandedBodyClassName,
|
||||
'flex items-start gap-2 px-6 py-2 text-[11px] text-muted-foreground'
|
||||
)}
|
||||
style={expandedBodyStyle}
|
||||
>
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
<span>Loading graph...</span>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && result && viewModels.length === 0 && (
|
||||
<div className="px-6 py-2 text-[11px] text-muted-foreground">No commits yet</div>
|
||||
<div
|
||||
className={cn(expandedBodyClassName, 'px-6 py-2 text-[11px] text-muted-foreground')}
|
||||
style={expandedBodyStyle}
|
||||
>
|
||||
No commits yet
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && viewModels.length > 0 && (
|
||||
<div className="max-h-[33vh] overflow-y-auto scrollbar-sleek">
|
||||
<div className={expandedBodyClassName} style={expandedBodyStyle}>
|
||||
{viewModels.map((viewModel) => (
|
||||
<GitHistoryRow
|
||||
key={`${viewModel.kind}:${viewModel.historyItem.id}`}
|
||||
|
|
|
|||
|
|
@ -1036,6 +1036,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const gitHistoryState = activeWorktreeId
|
||||
? (gitHistoryByWorktree[activeWorktreeId] ?? EMPTY_GIT_HISTORY_STATE)
|
||||
: EMPTY_GIT_HISTORY_STATE
|
||||
const isGitHistoryExpanded = !collapsedSections.has('history')
|
||||
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
|
|
@ -2949,7 +2950,13 @@ function SourceControlInner(): React.JSX.Element {
|
|||
refreshBranchCompareRef.current = refreshBranchCompare
|
||||
|
||||
const refreshGitHistory = useCallback(async (): Promise<void> => {
|
||||
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 {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex flex-1 flex-col overflow-auto scrollbar-sleek py-1"
|
||||
className="relative flex flex-1 flex-col overflow-auto scrollbar-sleek pt-1"
|
||||
style={{ paddingBottom: selectedKeys.size > 0 ? 50 : undefined }}
|
||||
>
|
||||
{unresolvedConflictReviewEntries.length > 0 && (
|
||||
|
|
@ -4116,20 +4136,24 @@ function SourceControlInner(): React.JSX.Element {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{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.
|
||||
<div className="mt-auto">
|
||||
<GitHistoryPanel
|
||||
state={gitHistoryState}
|
||||
collapsed={collapsedSections.has('history')}
|
||||
onToggle={() => toggleSection('history')}
|
||||
onRefresh={() => void refreshGitHistory()}
|
||||
onOpenCommit={(item) => void openHistoryCommitDiff(item)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedKeys.size > 0 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue