1- import { useMemo , useState } from 'react' ;
1+ import { useEffect , useMemo , useRef , useState } from 'react' ;
22import { Filter , GitCommitHorizontal , Search , X } from 'lucide-react' ;
33import { useGitStore } from '../../store/gitStore' ;
4- import type { CommitInfo } from '../../types/git' ;
4+ import type { CommitInfo , HistoryFilters } from '../../types/git' ;
55
66const LANE_COLORS = [ '#38bdf8' , '#a78bfa' , '#34d399' , '#fb923c' , '#f472b6' , '#facc15' , '#60a5fa' , '#4ade80' , '#e879f9' , '#f87171' ] ;
77const ROW_H = 34 ;
@@ -18,114 +18,73 @@ function buildRows(commits: CommitInfo[]): RowData[] {
1818 const findLane = ( hash : string ) => lanes . findIndex ( l => l ?. hash === hash ) ;
1919 const freeLane = ( ) => { const idx = lanes . findIndex ( l => l === null ) ; return idx === - 1 ? lanes . length : idx ; } ;
2020 const rows : RowData [ ] = [ ] ;
21-
2221 for ( const commit of commits ) {
2322 let myLane = findLane ( commit . hash ) ;
2423 let myColorIdx : number ;
25- if ( myLane === - 1 ) {
26- myLane = freeLane ( ) ;
27- myColorIdx = nextColor ( ) ;
28- if ( myLane === lanes . length ) lanes . push ( null ) ;
29- lanes [ myLane ] = { hash : commit . hash , colorIdx : myColorIdx } ;
30- } else {
31- myColorIdx = lanes [ myLane ] ! . colorIdx ;
32- }
33-
24+ if ( myLane === - 1 ) { myLane = freeLane ( ) ; myColorIdx = nextColor ( ) ; if ( myLane === lanes . length ) lanes . push ( null ) ; lanes [ myLane ] = { hash : commit . hash , colorIdx : myColorIdx } ; }
25+ else myColorIdx = lanes [ myLane ] ! . colorIdx ;
3426 const myColor = LANE_COLORS [ myColorIdx ] ;
3527 const lines : RowData [ 'lines' ] = [ ] ;
3628 for ( let i = 0 ; i < lanes . length ; i ++ ) if ( i !== myLane && lanes [ i ] ) lines . push ( { fromLane : i , toLane : i , color : LANE_COLORS [ lanes [ i ] ! . colorIdx ] , pos : 'top' } ) ;
37-
3829 const parents = commit . parents ?? [ ] ;
3930 lanes [ myLane ] = null ;
40- if ( parents [ 0 ] ) {
41- const existingLane = findLane ( parents [ 0 ] ) ;
42- if ( existingLane === - 1 ) lanes [ myLane ] = { hash : parents [ 0 ] , colorIdx : myColorIdx } ;
43- else lines . push ( { fromLane : myLane , toLane : existingLane , color : myColor , pos : 'bottom' } ) ;
44- }
45- for ( let i = 1 ; i < parents . length ; i ++ ) {
46- const parent = parents [ i ] ;
47- const existingLane = findLane ( parent ) ;
48- if ( existingLane === - 1 ) {
49- const newLane = freeLane ( ) ;
50- const newColorIdx = nextColor ( ) ;
51- if ( newLane === lanes . length ) lanes . push ( null ) ;
52- lanes [ newLane ] = { hash : parent , colorIdx : newColorIdx } ;
53- lines . push ( { fromLane : myLane , toLane : newLane , color : LANE_COLORS [ newColorIdx ] , pos : 'bottom' } ) ;
54- } else lines . push ( { fromLane : myLane , toLane : existingLane , color : LANE_COLORS [ lanes [ existingLane ] ! . colorIdx ] , pos : 'bottom' } ) ;
55- }
31+ if ( parents [ 0 ] ) { const existingLane = findLane ( parents [ 0 ] ) ; if ( existingLane === - 1 ) lanes [ myLane ] = { hash : parents [ 0 ] , colorIdx : myColorIdx } ; else lines . push ( { fromLane : myLane , toLane : existingLane , color : myColor , pos : 'bottom' } ) ; }
32+ for ( let i = 1 ; i < parents . length ; i ++ ) { const parent = parents [ i ] ; const existingLane = findLane ( parent ) ; if ( existingLane === - 1 ) { const newLane = freeLane ( ) ; const newColorIdx = nextColor ( ) ; if ( newLane === lanes . length ) lanes . push ( null ) ; lanes [ newLane ] = { hash : parent , colorIdx : newColorIdx } ; lines . push ( { fromLane : myLane , toLane : newLane , color : LANE_COLORS [ newColorIdx ] , pos : 'bottom' } ) ; } else lines . push ( { fromLane : myLane , toLane : existingLane , color : LANE_COLORS [ lanes [ existingLane ] ! . colorIdx ] , pos : 'bottom' } ) ; }
5633 for ( let i = 0 ; i < lanes . length ; i ++ ) if ( i !== myLane && lanes [ i ] ) lines . push ( { fromLane : i , toLane : i , color : LANE_COLORS [ lanes [ i ] ! . colorIdx ] , pos : 'bottom' } ) ;
5734 rows . push ( { commit, lane : myLane , color : myColor , lines, maxLane : Math . max ( myLane , ...lanes . map ( ( l , i ) => l ? i : 0 ) ) } ) ;
5835 }
5936 return rows ;
6037}
6138
6239const normalized = ( value : string ) => value . toLowerCase ( ) . trim ( ) ;
63-
64- function matchesCommit ( commit : CommitInfo , query : string , author : string , ref : string ) {
65- const q = normalized ( query ) ;
40+ function matchesCommit ( commit : CommitInfo , query : string ) {
41+ const q = normalized ( query ) ; if ( ! q ) return true ;
6642 const refNames = commit . refs . join ( ' ' ) ;
67- const textMatch = ! q || [ commit . message , commit . hash , commit . shortHash , commit . author , refNames ] . some ( v => normalized ( v ) . includes ( q ) ) ;
68- const authorMatch = author === 'all' || commit . author === author ;
69- const refMatch = ref === 'all' || commit . refs . some ( r => r . includes ( ref ) ) ;
70- return textMatch && authorMatch && refMatch ;
43+ return [ commit . message , commit . hash , commit . shortHash , commit . author , refNames ] . some ( v => normalized ( v ) . includes ( q ) ) ;
7144}
7245
73- function GraphRow ( { row, selected, dimmed, onClick } : { row : RowData ; selected : boolean ; dimmed : boolean ; onClick : ( ) => void } ) {
46+ function GraphRow ( { row, selected, dimmed, onClick, rowRef } : { row : RowData ; selected : boolean ; dimmed : boolean ; onClick : ( ) => void ; rowRef ?: ( node : HTMLButtonElement | null ) => void } ) {
7447 const svgWidth = ( row . maxLane + 1 ) * LANE_W + PAD_LEFT * 2 ;
75- const cy = ROW_H / 2 ;
76- const cx = PAD_LEFT + row . lane * LANE_W ;
77- return < button onClick = { onClick } className = { `flex w-full items-center border-t border-pilot-line text-left transition-colors hover:bg-slate-800/60 ${ selected ? 'bg-slate-800 ring-1 ring-inset ring-sky-500/40' : '' } ${ dimmed ? 'opacity-35' : '' } ` } style = { { height : ROW_H } } >
48+ const cy = ROW_H / 2 ; const cx = PAD_LEFT + row . lane * LANE_W ;
49+ return < button ref = { rowRef } onClick = { onClick } className = { `flex w-full items-center border-t border-pilot-line text-left transition-colors hover:bg-slate-800/60 ${ selected ? 'bg-slate-800 ring-1 ring-inset ring-sky-500/40' : '' } ${ dimmed ? 'opacity-35' : '' } ` } style = { { height : ROW_H } } >
7850 < svg width = { svgWidth } height = { ROW_H } className = "shrink-0 overflow-visible" >
7951 { row . lines . map ( ( ln , i ) => { const x1 = PAD_LEFT + ln . fromLane * LANE_W ; const x2 = PAD_LEFT + ln . toLane * LANE_W ; const y1 = ln . pos === 'bottom' ? cy : 0 ; const y2 = ln . pos === 'top' ? cy : ROW_H ; return x1 === x2 ? < line key = { i } x1 = { x1 } y1 = { y1 } x2 = { x2 } y2 = { y2 } stroke = { ln . color } strokeWidth = { 1.6 } /> : < path key = { i } d = { `M ${ x1 } ${ y1 } C ${ x1 } ${ ( y1 + y2 ) / 2 } , ${ x2 } ${ ( y1 + y2 ) / 2 } , ${ x2 } ${ y2 } ` } fill = "none" stroke = { ln . color } strokeWidth = { 1.6 } /> ; } ) }
80- < circle cx = { cx } cy = { cy } r = { CIRCLE_R + 3 } fill = { row . color } opacity = { 0.18 } />
81- < circle cx = { cx } cy = { cy } r = { CIRCLE_R } fill = { row . color } />
82- { row . commit . head && < circle cx = { cx } cy = { cy } r = { CIRCLE_R + 3 } fill = "none" stroke = { row . color } strokeWidth = { 1.5 } /> }
52+ < circle cx = { cx } cy = { cy } r = { CIRCLE_R + 3 } fill = { row . color } opacity = { 0.18 } /> < circle cx = { cx } cy = { cy } r = { CIRCLE_R } fill = { row . color } /> { row . commit . head && < circle cx = { cx } cy = { cy } r = { CIRCLE_R + 3 } fill = "none" stroke = { row . color } strokeWidth = { 1.5 } /> }
8353 </ svg >
84- < div className = "min-w-0 flex flex-1 items-center gap-3 pr-3" >
85- < div className = "min-w-0 flex-1" >
86- < div className = "flex min-w-0 items-center gap-1.5" >
87- { row . commit . refs . slice ( 0 , 4 ) . map ( ref => < span key = { ref } className = "shrink-0 rounded border border-sky-400/30 bg-sky-400/10 px-1 py-0 text-[9px] font-semibold text-sky-300" > { ref . replace ( 'HEAD -> ' , '' ) . replace ( 'tag: ' , '' ) } </ span > ) }
88- < span className = "truncate text-xs text-slate-200" > { row . commit . message } </ span >
89- </ div >
90- < div className = "mt-0.5 text-[10px] text-slate-500" > { row . commit . shortHash } · { row . commit . author } · { row . commit . date } </ div >
91- </ div >
92- { row . commit . parents . length > 1 && < span className = "rounded bg-violet-400/10 px-1.5 py-0.5 text-[10px] text-violet-300" > merge</ span > }
93- </ div >
54+ < div className = "min-w-0 flex flex-1 items-center gap-3 pr-3" > < div className = "min-w-0 flex-1" > < div className = "flex min-w-0 items-center gap-1.5" > { row . commit . refs . slice ( 0 , 5 ) . map ( ref => < span key = { ref } className = "shrink-0 rounded border border-sky-400/30 bg-sky-400/10 px-1 py-0 text-[9px] font-semibold text-sky-300" > { ref . replace ( 'HEAD -> ' , '' ) . replace ( 'tag: ' , '' ) } </ span > ) } < span className = "truncate text-xs text-slate-200" > { row . commit . message } </ span > </ div > < div className = "mt-0.5 text-[10px] text-slate-500" > { row . commit . shortHash } · { row . commit . author } · { row . commit . date } </ div > </ div > { row . commit . parents . length > 1 && < span className = "rounded bg-violet-400/10 px-1.5 py-0.5 text-[10px] text-violet-300" > merge</ span > } </ div >
9455 </ button > ;
9556}
9657
9758export function GitGraph ( ) {
98- const { history, selectedCommit, selectCommit } = useGitStore ( s => ( { history : s . history , selectedCommit : s . selectedCommit , selectCommit : s . selectCommit } ) ) ;
99- const [ query , setQuery ] = useState ( '' ) ;
100- const [ author , setAuthor ] = useState ( 'all' ) ;
101- const [ ref , setRef ] = useState ( 'all' ) ;
59+ const { history, selectedCommit, selectCommit, historyLimit, historyFilters, loadHistory } = useGitStore ( s => ( { history : s . history , selectedCommit : s . selectedCommit , selectCommit : s . selectCommit , historyLimit : s . historyLimit , historyFilters : s . historyFilters , loadHistory : s . loadHistory } ) ) ;
60+ const [ search , setSearch ] = useState ( '' ) ;
61+ const [ filters , setFilters ] = useState < HistoryFilters > ( historyFilters ) ;
62+ const selectedRef = useRef < HTMLButtonElement | null > ( null ) ;
63+ useEffect ( ( ) => { const id = window . setTimeout ( ( ) => { void loadHistory ( filters , historyLimit ) ; } , 350 ) ; return ( ) => window . clearTimeout ( id ) ; } , [ filters , historyLimit , loadHistory ] ) ;
64+ useEffect ( ( ) => { selectedRef . current ?. scrollIntoView ( { block : 'nearest' } ) ; } , [ selectedCommit ?. hash ] ) ;
10265 const rows = useMemo ( ( ) => buildRows ( history ) , [ history ] ) ;
10366 const authors = useMemo ( ( ) => Array . from ( new Set ( history . map ( c => c . author ) ) ) . sort ( ) , [ history ] ) ;
10467 const refs = useMemo ( ( ) => Array . from ( new Set ( history . flatMap ( c => c . refs . map ( r => r . replace ( 'HEAD -> ' , '' ) . replace ( 'tag: ' , '' ) ) ) ) ) . sort ( ) , [ history ] ) ;
105- const visibleRows = rows . filter ( row => matchesCommit ( row . commit , query , author , ref ) ) ;
106- const hasFilter = Boolean ( query || author !== 'all' || ref !== 'all' ) ;
107-
68+ const visibleRows = rows . filter ( row => matchesCommit ( row . commit , search ) ) ;
69+ const hasFilter = Boolean ( search || filters . branch || filters . author || filters . since || filters . until || filters . keyword || filters . filePath ) ;
70+ const clear = ( ) => { setSearch ( '' ) ; setFilters ( { } ) ; } ;
10871 if ( history . length === 0 ) return < div className = "flex h-full items-center justify-center text-sm text-slate-500" > Open a repository to see commit history</ div > ;
109-
11072 return < div className = "flex min-h-0 flex-1 flex-col overflow-hidden bg-[#090e1b]" >
11173 < div className = "sticky top-0 z-10 border-b border-pilot-line bg-[#0d1324] p-2" >
112- < div className = "mb-2 flex items-center justify-between gap-3" >
113- < div className = "flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-slate-500" > < GitCommitHorizontal size = { 13 } /> Commit Graph · { visibleRows . length } /{ history . length } </ div >
114- { hasFilter && < button className = "icon-btn h-6" onClick = { ( ) => { setQuery ( '' ) ; setAuthor ( 'all' ) ; setRef ( 'all' ) ; } } > < X size = { 12 } /> Clear</ button > }
115- </ div >
116- < div className = "grid grid-cols-[1fr_150px_150px] gap-2" >
117- < label className = "relative" > < Search size = { 13 } className = "pointer-events-none absolute left-2 top-1.5 text-slate-500" /> < input className = "input h-7 w-full pl-7 text-xs" value = { query } onChange = { e => setQuery ( e . target . value ) } placeholder = "Search message, hash, author, branch…" /> </ label >
118- < label className = "relative" > < Filter size = { 12 } className = "pointer-events-none absolute left-2 top-2 text-slate-500" /> < select className = "input h-7 w-full pl-7 text-xs" value = { author } onChange = { e => setAuthor ( e . target . value ) } > < option value = "all" > All authors</ option > { authors . map ( a => < option key = { a } value = { a } > { a } </ option > ) } </ select > </ label >
119- < select className = "input h-7 w-full text-xs" value = { ref } onChange = { e => setRef ( e . target . value ) } > < option value = "all" > All branches/tags</ option > { refs . map ( r => < option key = { r } value = { r } > { r } </ option > ) } </ select >
74+ < div className = "mb-2 flex items-center justify-between gap-3" > < div className = "flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-slate-500" > < GitCommitHorizontal size = { 13 } /> Commit Graph · { visibleRows . length } /{ history . length } </ div > { hasFilter && < button className = "icon-btn h-6" onClick = { clear } > < X size = { 12 } /> Clear</ button > } </ div >
75+ < div className = "grid grid-cols-[1.2fr_140px_140px_110px_110px] gap-2" >
76+ < label className = "relative" > < Search size = { 13 } className = "pointer-events-none absolute left-2 top-1.5 text-slate-500" /> < input className = "input h-7 w-full pl-7 text-xs" value = { search } onChange = { e => setSearch ( e . target . value ) } placeholder = "Search hash, message, author…" /> </ label >
77+ < label className = "relative" > < Filter size = { 12 } className = "pointer-events-none absolute left-2 top-2 text-slate-500" /> < select className = "input h-7 w-full pl-7 text-xs" value = { filters . author ?? '' } onChange = { e => setFilters ( f => ( { ...f , author : e . target . value || undefined } ) ) } > < option value = "" > All authors</ option > { authors . map ( a => < option key = { a } value = { a } > { a } </ option > ) } </ select > </ label >
78+ < select className = "input h-7 w-full text-xs" value = { filters . branch ?? '' } onChange = { e => setFilters ( f => ( { ...f , branch : e . target . value || undefined } ) ) } > < option value = "" > All refs</ option > { refs . map ( r => < option key = { r } value = { r } > { r } </ option > ) } </ select >
79+ < input className = "input h-7 text-xs" type = "date" value = { filters . since ?? '' } onChange = { e => setFilters ( f => ( { ...f , since : e . target . value || undefined } ) ) } title = "Since" />
80+ < input className = "input h-7 text-xs" type = "date" value = { filters . until ?? '' } onChange = { e => setFilters ( f => ( { ...f , until : e . target . value || undefined } ) ) } title = "Until" />
12081 </ div >
82+ < div className = "mt-2 grid grid-cols-2 gap-2" > < input className = "input h-7 text-xs" value = { filters . keyword ?? '' } onChange = { e => setFilters ( f => ( { ...f , keyword : e . target . value || undefined } ) ) } placeholder = "Commit message keyword (git --grep)" /> < input className = "input h-7 text-xs" value = { filters . filePath ?? '' } onChange = { e => setFilters ( f => ( { ...f , filePath : e . target . value || undefined } ) ) } placeholder = "File path filter (e.g. src/App.tsx)" /> </ div >
12183 </ div >
12284 < div className = "min-h-0 flex-1 overflow-auto" >
123- { rows . map ( row => {
124- const matched = matchesCommit ( row . commit , query , author , ref ) ;
125- if ( hasFilter && ! matched ) return null ;
126- return < GraphRow key = { row . commit . hash } row = { row } selected = { selectedCommit ?. hash === row . commit . hash } dimmed = { hasFilter && ! matched } onClick = { ( ) => void selectCommit ( row . commit ) } /> ;
127- } ) }
85+ { visibleRows . map ( row => < GraphRow key = { row . commit . hash } row = { row } selected = { selectedCommit ?. hash === row . commit . hash } dimmed = { false } rowRef = { selectedCommit ?. hash === row . commit . hash ? node => { selectedRef . current = node ; } : undefined } onClick = { ( ) => void selectCommit ( row . commit ) } /> ) }
12886 { visibleRows . length === 0 && < div className = "p-6 text-center text-sm text-slate-500" > No commits match the current GitKraken-style filters.</ div > }
87+ < div className = "border-t border-pilot-line p-3 text-center" > < button className = "btn" onClick = { ( ) => void loadHistory ( filters , historyLimit + 500 ) } > Load 500 more commits</ button > </ div >
12988 </ div >
13089 </ div > ;
13190}
0 commit comments