11import React , { useState , useEffect } from 'react' ;
2- import type { Repository , Commit } from '../../types' ;
2+ import type { Repository , Commit , CommitDiffFile } from '../../types' ;
33import { ClockIcon } from '../icons/ClockIcon' ;
44import { XIcon } from '../icons/XIcon' ;
55import { MagnifyingGlassIcon } from '../icons/MagnifyingGlassIcon' ;
6+ import { ChevronDownIcon } from '../icons/ChevronDownIcon' ;
7+ import { ChevronRightIcon } from '../icons/ChevronRightIcon' ;
8+ import { ClipboardIcon } from '../icons/ClipboardIcon' ;
69
710interface CommitHistoryModalProps {
811 isOpen : boolean ;
@@ -33,6 +36,49 @@ const HighlightedText: React.FC<{ text: string; highlight: string }> = ({ text,
3336} ;
3437
3538
39+ const DIFF_PAGE_SIZE = 5 ;
40+
41+ const getFileExtension = ( filePath : string ) : string => {
42+ const trimmed = filePath . split ( '/' ) . pop ( ) ?? filePath ;
43+ const lastDot = trimmed . lastIndexOf ( '.' ) ;
44+ if ( lastDot === - 1 ) {
45+ return '(no extension)' ;
46+ }
47+ return trimmed . slice ( lastDot + 1 ) . toLowerCase ( ) ;
48+ } ;
49+
50+ const DiffContent : React . FC < { diff : string } > = ( { diff } ) => {
51+ const lines = diff . split ( '\n' ) ;
52+ return (
53+ < pre className = "bg-gray-900 text-gray-100 text-xs leading-relaxed rounded-md p-3 overflow-auto max-h-96 whitespace-pre font-mono" >
54+ { lines . map ( ( line , idx ) => {
55+ let lineClass = '' ;
56+ if ( line . startsWith ( '+++' ) || line . startsWith ( '---' ) ) {
57+ lineClass = 'text-blue-300' ;
58+ } else if ( line . startsWith ( '@@' ) ) {
59+ lineClass = 'text-amber-300' ;
60+ } else if ( line . startsWith ( '+' ) ) {
61+ lineClass = 'text-emerald-400' ;
62+ } else if ( line . startsWith ( '-' ) ) {
63+ lineClass = 'text-rose-400' ;
64+ } else if ( line . startsWith ( 'diff --git' ) || line . startsWith ( 'Index: ' ) ) {
65+ lineClass = 'text-sky-300 font-semibold' ;
66+ }
67+
68+ const classes = [ 'block' ] ;
69+ if ( lineClass ) {
70+ classes . push ( lineClass ) ;
71+ }
72+
73+ return (
74+ < code key = { idx } className = { classes . join ( ' ' ) } > { line || '\u00A0' } </ code >
75+ ) ;
76+ } ) }
77+ </ pre >
78+ ) ;
79+ } ;
80+
81+
3682const CommitHistoryModal : React . FC < CommitHistoryModalProps > = ( { isOpen, repository, initialCommits, onClose } ) => {
3783 const [ commits , setCommits ] = useState < Commit [ ] > ( [ ] ) ;
3884 const [ isLoading , setIsLoading ] = useState ( false ) ;
@@ -41,6 +87,13 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
4187 const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
4288 const [ debouncedSearchQuery , setDebouncedSearchQuery ] = useState ( '' ) ;
4389 const [ matchStats , setMatchStats ] = useState ( { commitCount : 0 , occurrenceCount : 0 } ) ;
90+ const [ expandedCommits , setExpandedCommits ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
91+ const [ diffCache , setDiffCache ] = useState < Record < string , CommitDiffFile [ ] > > ( { } ) ;
92+ const [ diffLoading , setDiffLoading ] = useState < Record < string , boolean > > ( { } ) ;
93+ const [ diffErrors , setDiffErrors ] = useState < Record < string , string | null > > ( { } ) ;
94+ const [ diffFilters , setDiffFilters ] = useState < Record < string , string > > ( { } ) ;
95+ const [ diffVisibleCount , setDiffVisibleCount ] = useState < Record < string , number > > ( { } ) ;
96+ const [ copiedCommit , setCopiedCommit ] = useState < string | null > ( null ) ;
4497
4598 // Debounce search input
4699 useEffect ( ( ) => {
@@ -103,6 +156,30 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
103156 }
104157 } , [ isOpen ] ) ;
105158
159+ useEffect ( ( ) => {
160+ if ( ! isOpen ) {
161+ setExpandedCommits ( new Set ( ) ) ;
162+ setDiffCache ( { } ) ;
163+ setDiffLoading ( { } ) ;
164+ setDiffErrors ( { } ) ;
165+ setDiffFilters ( { } ) ;
166+ setDiffVisibleCount ( { } ) ;
167+ setCopiedCommit ( null ) ;
168+ }
169+ } , [ isOpen ] ) ;
170+
171+ useEffect ( ( ) => {
172+ if ( ! repository ) {
173+ setExpandedCommits ( new Set ( ) ) ;
174+ setDiffCache ( { } ) ;
175+ setDiffLoading ( { } ) ;
176+ setDiffErrors ( { } ) ;
177+ setDiffFilters ( { } ) ;
178+ setDiffVisibleCount ( { } ) ;
179+ setCopiedCommit ( null ) ;
180+ }
181+ } , [ repository ] ) ;
182+
106183 const handleLoadMore = async ( ) => {
107184 if ( ! repository || isMoreLoading ) return ;
108185 setIsMoreLoading ( true ) ;
@@ -129,6 +206,80 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
129206 }
130207 } ;
131208
209+ const loadCommitDiff = async ( commit : Commit ) => {
210+ if ( ! repository ) {
211+ return ;
212+ }
213+
214+ setDiffLoading ( prev => ( { ...prev , [ commit . hash ] : true } ) ) ;
215+ setDiffErrors ( prev => ( { ...prev , [ commit . hash ] : null } ) ) ;
216+ try {
217+ const files = await window . electronAPI . getCommitDiff ( repository , commit . hash ) ;
218+ setDiffCache ( prev => ( { ...prev , [ commit . hash ] : files } ) ) ;
219+ setDiffFilters ( prev => ( { ...prev , [ commit . hash ] : 'all' } ) ) ;
220+ const initialVisible = files . length === 0 ? 0 : Math . min ( DIFF_PAGE_SIZE , files . length ) ;
221+ setDiffVisibleCount ( prev => ( { ...prev , [ commit . hash ] : initialVisible } ) ) ;
222+ } catch ( error ) {
223+ console . error ( `Failed to load diff for commit ${ commit . hash } ` , error ) ;
224+ setDiffErrors ( prev => ( { ...prev , [ commit . hash ] : 'Failed to load diff for this commit.' } ) ) ;
225+ } finally {
226+ setDiffLoading ( prev => ( { ...prev , [ commit . hash ] : false } ) ) ;
227+ }
228+ } ;
229+
230+ const handleToggleCommit = ( commit : Commit ) => {
231+ const isExpanded = expandedCommits . has ( commit . hash ) ;
232+ setExpandedCommits ( prev => {
233+ const next = new Set ( prev ) ;
234+ if ( next . has ( commit . hash ) ) {
235+ next . delete ( commit . hash ) ;
236+ } else {
237+ next . add ( commit . hash ) ;
238+ }
239+ return next ;
240+ } ) ;
241+
242+ if ( ! isExpanded && ! diffCache [ commit . hash ] && ! diffLoading [ commit . hash ] ) {
243+ loadCommitDiff ( commit ) ;
244+ }
245+ } ;
246+
247+ const handleCopyDiff = async ( commitHash : string ) => {
248+ const files = diffCache [ commitHash ] ;
249+ if ( ! files || files . length === 0 ) {
250+ return ;
251+ }
252+
253+ try {
254+ if ( ! navigator ?. clipboard ?. writeText ) {
255+ console . warn ( 'Clipboard API is not available in this environment.' ) ;
256+ return ;
257+ }
258+ await navigator . clipboard . writeText ( files . map ( file => file . diff ) . join ( '\n\n' ) ) ;
259+ setCopiedCommit ( commitHash ) ;
260+ setTimeout ( ( ) => setCopiedCommit ( prev => ( prev === commitHash ? null : prev ) ) , 2000 ) ;
261+ } catch ( error ) {
262+ console . error ( 'Failed to copy diff to clipboard' , error ) ;
263+ }
264+ } ;
265+
266+ const handleFilterChange = ( commitHash : string , filter : string ) => {
267+ setDiffFilters ( prev => ( { ...prev , [ commitHash ] : filter } ) ) ;
268+ const files = diffCache [ commitHash ] || [ ] ;
269+ const filteredLength = filter === 'all'
270+ ? files . length
271+ : files . filter ( file => getFileExtension ( file . filePath ) === filter ) . length ;
272+ const nextCount = filteredLength === 0 ? 0 : Math . min ( DIFF_PAGE_SIZE , filteredLength ) ;
273+ setDiffVisibleCount ( prev => ( { ...prev , [ commitHash ] : nextCount } ) ) ;
274+ } ;
275+
276+ const handleShowMoreFiles = ( commitHash : string ) => {
277+ setDiffVisibleCount ( prev => ( {
278+ ...prev ,
279+ [ commitHash ] : ( prev [ commitHash ] || DIFF_PAGE_SIZE ) + DIFF_PAGE_SIZE ,
280+ } ) ) ;
281+ } ;
282+
132283
133284 if ( ! isOpen || ! repository ) {
134285 return null ;
@@ -189,17 +340,134 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
189340 ) : (
190341 < >
191342 < ul className = "space-y-3" >
192- { commits . map ( commit => (
193- < li key = { commit . hash } className = "p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700" >
194- < pre className = "font-sans whitespace-pre-wrap text-gray-900 dark:text-gray-100" >
195- < HighlightedText text = { commit . message } highlight = { debouncedSearchQuery } />
196- </ pre >
197- < div className = "flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700" >
198- < span > { commit . author } </ span >
199- < span title = { commit . hash } className = "font-mono" > { commit . shortHash } • { commit . date } </ span >
200- </ div >
201- </ li >
202- ) ) }
343+ { commits . map ( commit => {
344+ const isExpanded = expandedCommits . has ( commit . hash ) ;
345+ const diffFiles = diffCache [ commit . hash ] || [ ] ;
346+ const filter = diffFilters [ commit . hash ] ?? 'all' ;
347+ const filteredFiles = filter === 'all' ? diffFiles : diffFiles . filter ( file => getFileExtension ( file . filePath ) === filter ) ;
348+ const visibleCount = diffVisibleCount [ commit . hash ] ?? ( filteredFiles . length === 0 ? 0 : Math . min ( DIFF_PAGE_SIZE , filteredFiles . length ) ) ;
349+ const visibleFiles = filteredFiles . slice ( 0 , visibleCount ) ;
350+ const hasMoreFiles = visibleCount < filteredFiles . length ;
351+ const commitDiffError = diffErrors [ commit . hash ] ;
352+ const isDiffLoading = diffLoading [ commit . hash ] ;
353+ const fileTypes = Array . from ( new Set ( diffFiles . map ( file => getFileExtension ( file . filePath ) ) ) ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
354+ const copyLabel = copiedCommit === commit . hash ? 'Copied!' : 'Copy patch' ;
355+ const showingCount = Math . min ( visibleCount , filteredFiles . length ) ;
356+ const noFilesMessage = diffFiles . length === 0 && filter === 'all'
357+ ? 'No files changed in this commit.'
358+ : 'No files match the selected filter.' ;
359+
360+ return (
361+ < li key = { commit . hash } className = "p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700" >
362+ < button
363+ type = "button"
364+ onClick = { ( ) => handleToggleCommit ( commit ) }
365+ className = "flex w-full items-start gap-3 text-left"
366+ >
367+ < span className = "mt-1 text-gray-500 dark:text-gray-400" >
368+ { isExpanded ? < ChevronDownIcon className = "h-5 w-5" /> : < ChevronRightIcon className = "h-5 w-5" /> }
369+ </ span >
370+ < div className = "flex-1" >
371+ < div className = "font-sans whitespace-pre-wrap text-gray-900 dark:text-gray-100" >
372+ < HighlightedText text = { commit . message } highlight = { debouncedSearchQuery } />
373+ </ div >
374+ </ div >
375+ </ button >
376+ < div className = "flex flex-col gap-2 text-xs text-gray-500 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between" >
377+ < span > { commit . author } </ span >
378+ < span title = { commit . hash } className = "font-mono" > { commit . shortHash } • { commit . date } </ span >
379+ </ div >
380+
381+ { isExpanded && (
382+ < div className = "mt-3 space-y-3" >
383+ { isDiffLoading ? (
384+ < p className = "text-sm text-gray-500 dark:text-gray-400" > Loading diff...</ p >
385+ ) : commitDiffError ? (
386+ < p className = "text-sm text-red-500 dark:text-red-400" > { commitDiffError } </ p >
387+ ) : (
388+ < div className = "space-y-3" >
389+ < div className = "flex flex-col gap-3 rounded-md border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900/60 sm:flex-row sm:items-center sm:justify-between" >
390+ < div className = "space-y-1 text-gray-600 dark:text-gray-300" >
391+ < p >
392+ Showing < span className = "font-semibold text-gray-900 dark:text-gray-100" > { showingCount } </ span > of{ ' ' }
393+ < span className = "font-semibold text-gray-900 dark:text-gray-100" > { filteredFiles . length } </ span > file{ filteredFiles . length === 1 ? '' : 's' }
394+ { filter !== 'all' && diffFiles . length > 0 ? (
395+ < span className = "ml-1 text-gray-500 dark:text-gray-400" > (filtering from { diffFiles . length } )</ span >
396+ ) : null }
397+ </ p >
398+ </ div >
399+ < div className = "flex flex-wrap items-center gap-2" >
400+ { fileTypes . length > 0 && (
401+ < select
402+ value = { filter }
403+ onChange = { ( event ) => handleFilterChange ( commit . hash , event . target . value ) }
404+ className = "rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
405+ >
406+ < option value = "all" > All file types</ option >
407+ { fileTypes . map ( type => (
408+ < option key = { type } value = { type } > { type } </ option >
409+ ) ) }
410+ </ select >
411+ ) }
412+ < button
413+ type = "button"
414+ onClick = { ( ) => handleCopyDiff ( commit . hash ) }
415+ className = "inline-flex items-center gap-1 rounded-md border border-blue-500 px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/40"
416+ >
417+ < ClipboardIcon className = "h-4 w-4" />
418+ { copyLabel }
419+ </ button >
420+ < button
421+ type = "button"
422+ onClick = { ( ) => handleToggleCommit ( commit ) }
423+ className = "inline-flex items-center gap-1 rounded-md border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
424+ >
425+ Collapse
426+ </ button >
427+ </ div >
428+ </ div >
429+
430+ { filteredFiles . length === 0 ? (
431+ < p className = "text-sm text-gray-500 dark:text-gray-400" > { noFilesMessage } </ p >
432+ ) : (
433+ < div className = "space-y-3" >
434+ { visibleFiles . map ( file => {
435+ const extension = getFileExtension ( file . filePath ) ;
436+ return (
437+ < div key = { `${ commit . hash } -${ file . filePath } ` } className = "overflow-hidden rounded-md border border-gray-200 bg-gray-100 dark:border-gray-700 dark:bg-gray-900" >
438+ < div className = "flex flex-wrap items-center justify-between gap-2 border-b border-gray-200 bg-gray-200 px-3 py-2 text-xs font-medium text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" >
439+ < span className = "truncate" title = { file . filePath } > { file . filePath } </ span >
440+ < div className = "flex flex-wrap items-center gap-2" >
441+ < span className = "rounded bg-gray-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-700 dark:text-gray-200" > { extension } </ span >
442+ { file . isBinary && (
443+ < span className = "rounded bg-amber-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-900 dark:bg-amber-500/20 dark:text-amber-200" > Binary</ span >
444+ ) }
445+ </ div >
446+ </ div >
447+ < DiffContent diff = { file . diff } />
448+ </ div >
449+ ) ;
450+ } ) }
451+ { hasMoreFiles && (
452+ < div className = "text-center" >
453+ < button
454+ type = "button"
455+ onClick = { ( ) => handleShowMoreFiles ( commit . hash ) }
456+ className = "rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700"
457+ >
458+ Load more files
459+ </ button >
460+ </ div >
461+ ) }
462+ </ div >
463+ ) }
464+ </ div >
465+ ) }
466+ </ div >
467+ ) }
468+ </ li >
469+ ) ;
470+ } ) }
203471 </ ul >
204472 { hasMore && (
205473 < div className = "mt-4 text-center" >
0 commit comments