git-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.tracerc.example +38 -0
  2. package/README.md +136 -0
  3. package/bun.lock +511 -0
  4. package/bunchee.config.ts +11 -0
  5. package/cli/index.ts +251 -0
  6. package/cli/parser.ts +76 -0
  7. package/cli/tsconfig.json +6 -0
  8. package/dist/cli/index.d.ts +1 -0
  9. package/dist/cli/index.js +858 -0
  10. package/dist/config.cjs +66 -0
  11. package/dist/config.d.ts +15 -0
  12. package/dist/config.js +63 -0
  13. package/dist/highlight/index.cjs +770 -0
  14. package/dist/highlight/index.d.ts +26 -0
  15. package/dist/highlight/index.js +766 -0
  16. package/dist/index.cjs +849 -0
  17. package/dist/index.d.ts +52 -0
  18. package/dist/index.js +845 -0
  19. package/examples/demo/App.tsx +78 -0
  20. package/examples/demo/index.html +12 -0
  21. package/examples/demo/main.tsx +10 -0
  22. package/examples/demo/mockData.ts +170 -0
  23. package/examples/demo/styles.css +103 -0
  24. package/examples/demo/tsconfig.json +21 -0
  25. package/examples/demo/tsconfig.node.json +10 -0
  26. package/examples/demo/vite.config.ts +20 -0
  27. package/package.json +58 -0
  28. package/src/Trace.tsx +717 -0
  29. package/src/cache.ts +118 -0
  30. package/src/config.ts +51 -0
  31. package/src/entries/config.ts +7 -0
  32. package/src/entries/gitea.ts +4 -0
  33. package/src/entries/github.ts +5 -0
  34. package/src/entries/gitlab.ts +4 -0
  35. package/src/gitea.ts +58 -0
  36. package/src/github.ts +100 -0
  37. package/src/gitlab.ts +65 -0
  38. package/src/highlight/highlight.ts +119 -0
  39. package/src/highlight/index.ts +4 -0
  40. package/src/host.ts +32 -0
  41. package/src/index.ts +6 -0
  42. package/src/patterns.ts +6 -0
  43. package/src/shared.ts +108 -0
  44. package/src/themes.ts +98 -0
  45. package/src/types.ts +72 -0
  46. package/test/e2e.html +424 -0
  47. package/tsconfig.json +18 -0
  48. package/vercel.json +4 -0
package/src/Trace.tsx ADDED
@@ -0,0 +1,717 @@
1
+ // Git history visualizer component — renders commit timeline with diff view
2
+
3
+ import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react'
4
+ import type { TraceProps, Commit, DiffLine, FilterOptions, AuthorTypeFilter } from './types'
5
+ import { escapeHtml } from './shared'
6
+ import { themes, themeToVars } from './themes'
7
+
8
+ const styles = `
9
+ .trace-root {
10
+ --trace-bg: #09090b;
11
+ --trace-fg: #e4e4e7;
12
+ --trace-dim: #71717a;
13
+ --trace-human: #22c55e;
14
+ --trace-ai: #a855f7;
15
+ --trace-add: rgba(34, 102, 68, 0.2);
16
+ --trace-remove: rgba(185, 28, 28, 0.2);
17
+ --trace-border: #27272a;
18
+ --trace-border-subtle: #18181b;
19
+ --trace-font-code: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
20
+ --trace-line-height: 1.6;
21
+ --trace-radius: 0;
22
+ --trace-duration: 0.18s;
23
+ --trace-stagger: 35ms;
24
+ display: block;
25
+ font-family: var(--trace-font-code);
26
+ background: var(--trace-bg);
27
+ color: var(--trace-fg);
28
+ border-radius: var(--trace-radius);
29
+ overflow: hidden;
30
+ font-size: 13px;
31
+ }
32
+ .trace-container {
33
+ display: flex;
34
+ height: 100%;
35
+ min-height: 420px;
36
+ }
37
+ .trace-timeline {
38
+ width: 240px;
39
+ border-right: 1px solid var(--trace-border-subtle);
40
+ padding: 0;
41
+ overflow-y: auto;
42
+ flex-shrink: 0;
43
+ position: relative;
44
+ background: var(--trace-bg);
45
+ /* Performance: isolate layout and paint */
46
+ contain: layout style paint;
47
+ will-change: scroll-position;
48
+ }
49
+ .trace-progress {
50
+ position: absolute;
51
+ left: 0;
52
+ top: 0;
53
+ bottom: 0;
54
+ width: 2px;
55
+ background: var(--trace-commit-color, var(--trace-human));
56
+ opacity: 0.6;
57
+ pointer-events: none;
58
+ transition: height 0.25s ease-out;
59
+ }
60
+ .trace-commit {
61
+ position: relative;
62
+ padding: 12px 16px;
63
+ cursor: pointer;
64
+ transition: background 0.12s ease;
65
+ border-left: 2px solid transparent;
66
+ }
67
+ .trace-commit:hover {
68
+ background: rgba(255, 255, 255, 0.02);
69
+ }
70
+ .trace-commit.active {
71
+ background: rgba(255, 255, 255, 0.03);
72
+ border-left-color: var(--trace-commit-color, var(--trace-human));
73
+ }
74
+ .trace-commit-header {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 8px;
78
+ }
79
+ .trace-commit-dot {
80
+ width: 6px;
81
+ height: 6px;
82
+ border-radius: 50%;
83
+ background: var(--trace-commit-color, var(--trace-human));
84
+ flex-shrink: 0;
85
+ }
86
+ .trace-commit-message {
87
+ font-size: 12px;
88
+ font-weight: 400;
89
+ white-space: nowrap;
90
+ overflow: hidden;
91
+ text-overflow: ellipsis;
92
+ flex: 1;
93
+ color: var(--trace-fg);
94
+ }
95
+ .trace-commit-meta {
96
+ font-size: 11px;
97
+ color: var(--trace-dim);
98
+ margin-top: 4px;
99
+ padding-left: 14px;
100
+ }
101
+ .trace-badge {
102
+ font-size: 10px;
103
+ color: var(--trace-dim);
104
+ font-weight: 400;
105
+ text-transform: none;
106
+ letter-spacing: -0.02em;
107
+ }
108
+ .trace-diff {
109
+ flex: 1;
110
+ padding: 0;
111
+ overflow-y: auto;
112
+ font-size: 13px;
113
+ line-height: var(--trace-line-height);
114
+ background: var(--trace-bg);
115
+ /* Performance: isolate layout and paint */
116
+ contain: layout style paint;
117
+ will-change: scroll-position;
118
+ }
119
+ .trace-diff-header {
120
+ padding: 12px 16px;
121
+ border-bottom: 1px solid var(--trace-border-subtle);
122
+ background: var(--trace-bg);
123
+ position: sticky;
124
+ top: 0;
125
+ z-index: 1;
126
+ }
127
+ .trace-diff-header-message {
128
+ font-size: 13px;
129
+ font-weight: 500;
130
+ color: var(--trace-fg);
131
+ margin-bottom: 4px;
132
+ }
133
+ .trace-diff-header-meta {
134
+ font-size: 11px;
135
+ color: var(--trace-dim);
136
+ }
137
+ .trace-line {
138
+ padding: 1px 16px;
139
+ /* 2026: content-visibility for lazy rendering off-screen lines */
140
+ content-visibility: auto;
141
+ contain-intrinsic-size: auto 22px;
142
+ min-height: 22px;
143
+ display: flex;
144
+ align-items: flex-start;
145
+ /* Performance: isolate layout changes */
146
+ contain: layout style paint;
147
+ }
148
+ .trace-line-prefix {
149
+ width: 16px;
150
+ flex-shrink: 0;
151
+ color: var(--trace-dim);
152
+ opacity: 0.5;
153
+ font-size: 11px;
154
+ user-select: none;
155
+ text-align: center;
156
+ }
157
+ .trace-line-content {
158
+ flex: 1;
159
+ white-space: pre;
160
+ overflow-x: auto;
161
+ }
162
+ @keyframes trace-line-in {
163
+ from {
164
+ opacity: 0;
165
+ transform: translateX(-2px);
166
+ }
167
+ to {
168
+ opacity: 1;
169
+ transform: translateX(0);
170
+ }
171
+ }
172
+ .trace-line.add {
173
+ background: var(--trace-add);
174
+ color: #86efac;
175
+ }
176
+ .trace-line.add .trace-line-prefix {
177
+ color: #22c55e;
178
+ }
179
+ .trace-line.remove {
180
+ background: var(--trace-remove);
181
+ color: #fca5a5;
182
+ }
183
+ .trace-line.remove .trace-line-prefix {
184
+ color: #ef4444;
185
+ }
186
+ .trace-line.ctx {
187
+ opacity: 0.5;
188
+ }
189
+ .trace-controls {
190
+ display: flex;
191
+ gap: 4px;
192
+ padding: 8px 16px;
193
+ border-bottom: 1px solid var(--trace-border-subtle);
194
+ align-items: center;
195
+ background: var(--trace-bg);
196
+ }
197
+ .trace-btn {
198
+ background: transparent;
199
+ border: 1px solid var(--trace-border);
200
+ color: var(--trace-fg);
201
+ padding: 4px 10px;
202
+ border-radius: var(--trace-radius);
203
+ font-size: 11px;
204
+ cursor: pointer;
205
+ transition: all 0.12s ease;
206
+ font-family: inherit;
207
+ }
208
+ .trace-btn:hover:not(:disabled) {
209
+ background: rgba(255, 255, 255, 0.05);
210
+ border-color: var(--trace-dim);
211
+ }
212
+ .trace-btn:disabled {
213
+ opacity: 0.3;
214
+ cursor: not-allowed;
215
+ }
216
+ .trace-info {
217
+ font-size: 11px;
218
+ color: var(--trace-dim);
219
+ margin-left: auto;
220
+ }
221
+ .trace-empty {
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: center;
225
+ height: 400px;
226
+ color: var(--trace-dim);
227
+ }
228
+ .trace-filter-bar {
229
+ display: flex;
230
+ gap: 8px;
231
+ padding: 8px 16px;
232
+ border-bottom: 1px solid var(--trace-border-subtle);
233
+ align-items: center;
234
+ background: var(--trace-bg);
235
+ flex-wrap: wrap;
236
+ }
237
+ .trace-filter-input {
238
+ flex: 1;
239
+ min-width: 140px;
240
+ background: transparent;
241
+ border: 1px solid var(--trace-border);
242
+ color: var(--trace-fg);
243
+ padding: 6px 10px;
244
+ border-radius: var(--trace-radius);
245
+ font-size: 12px;
246
+ font-family: inherit;
247
+ outline: none;
248
+ transition: border-color 0.12s ease;
249
+ }
250
+ .trace-filter-input:focus {
251
+ border-color: var(--trace-dim);
252
+ }
253
+ .trace-filter-input::placeholder {
254
+ color: var(--trace-dim);
255
+ }
256
+ .trace-filter-group {
257
+ display: flex;
258
+ gap: 4px;
259
+ align-items: center;
260
+ }
261
+ .trace-filter-btn {
262
+ background: transparent;
263
+ border: 1px solid var(--trace-border);
264
+ color: var(--trace-dim);
265
+ padding: 4px 10px;
266
+ border-radius: var(--trace-radius);
267
+ font-size: 11px;
268
+ cursor: pointer;
269
+ transition: all 0.12s ease;
270
+ font-family: inherit;
271
+ }
272
+ .trace-filter-btn:hover {
273
+ color: var(--trace-fg);
274
+ border-color: var(--trace-dim);
275
+ }
276
+ .trace-filter-btn.active {
277
+ background: rgba(168, 85, 247, 0.15);
278
+ color: var(--trace-ai);
279
+ border-color: var(--trace-ai);
280
+ }
281
+ .trace-filter-btn.active[data-filter="human"] {
282
+ background: rgba(34, 197, 94, 0.15);
283
+ color: var(--trace-human);
284
+ border-color: var(--trace-human);
285
+ }
286
+ .trace-filter-clear {
287
+ background: transparent;
288
+ border: none;
289
+ color: var(--trace-dim);
290
+ padding: 4px 8px;
291
+ border-radius: var(--trace-radius);
292
+ font-size: 11px;
293
+ cursor: pointer;
294
+ transition: color 0.12s ease;
295
+ font-family: inherit;
296
+ }
297
+ .trace-filter-clear:hover {
298
+ color: var(--trace-fg);
299
+ }
300
+ .trace-filter-stats {
301
+ font-size: 11px;
302
+ color: var(--trace-dim);
303
+ margin-left: auto;
304
+ }
305
+ `
306
+
307
+ // Line prefix lookup - constant outside render
308
+ const LINE_PREFIX: Record<string, string> = {
309
+ add: '+',
310
+ remove: '-',
311
+ ctx: ' '
312
+ }
313
+
314
+ const STYLE_ID = 'trace-styles'
315
+
316
+ // Filter bar component with search and filters
317
+ const FilterBar = memo(function FilterBar({
318
+ filter,
319
+ onFilterChange,
320
+ totalCount,
321
+ filteredCount
322
+ }: {
323
+ filter: FilterOptions
324
+ onFilterChange: (f: FilterOptions) => void
325
+ totalCount: number
326
+ filteredCount: number
327
+ }) {
328
+ const authorType = filter.authorType ?? 'all'
329
+
330
+ const setAuthorType = (type: AuthorTypeFilter) => {
331
+ onFilterChange({ ...filter, authorType: type === authorType ? 'all' : type })
332
+ }
333
+
334
+ const setSearch = (search: string) => {
335
+ onFilterChange({ ...filter, search: search || undefined })
336
+ }
337
+
338
+ const clearFilter = () => {
339
+ onFilterChange({})
340
+ }
341
+
342
+ const hasActiveFilter = filter.search || filter.authorType && filter.authorType !== 'all'
343
+
344
+ return (
345
+ <div className="trace-filter-bar">
346
+ <input
347
+ type="text"
348
+ className="trace-filter-input"
349
+ placeholder="Search commits..."
350
+ value={filter.search ?? ''}
351
+ onChange={(e) => setSearch(e.target.value)}
352
+ aria-label="Search commits"
353
+ />
354
+ <div className="trace-filter-group">
355
+ <button
356
+ className={`trace-filter-btn ${authorType === 'ai' ? 'active' : ''}`}
357
+ data-filter="ai"
358
+ onClick={() => setAuthorType('ai')}
359
+ aria-label="Filter by AI commits"
360
+ >
361
+ AI
362
+ </button>
363
+ <button
364
+ className={`trace-filter-btn ${authorType === 'human' ? 'active' : ''}`}
365
+ data-filter="human"
366
+ onClick={() => setAuthorType('human')}
367
+ aria-label="Filter by Human commits"
368
+ >
369
+ Human
370
+ </button>
371
+ </div>
372
+ {hasActiveFilter && (
373
+ <button className="trace-filter-clear" onClick={clearFilter} aria-label="Clear filters">
374
+ clear
375
+ </button>
376
+ )}
377
+ <span className="trace-filter-stats">
378
+ {filteredCount} / {totalCount}
379
+ </span>
380
+ </div>
381
+ )
382
+ })
383
+
384
+ // Memoized line component - only re-renders when line content changes
385
+ const DiffLine = memo(function DiffLine({
386
+ line,
387
+ prefix
388
+ }: {
389
+ line: DiffLine
390
+ prefix: string
391
+ }) {
392
+ return (
393
+ <div className={`trace-line ${line.type}`}>
394
+ <span className="trace-line-prefix">{prefix}</span>
395
+ <span
396
+ className="trace-line-content"
397
+ dangerouslySetInnerHTML={{
398
+ __html: escapeHtml(line.content)
399
+ }}
400
+ />
401
+ </div>
402
+ )
403
+ })
404
+
405
+ // Memoized commit item - only re-renders when active state or commit changes
406
+ const CommitItem = memo(function CommitItem({
407
+ commit,
408
+ index,
409
+ isActive,
410
+ onClick
411
+ }: {
412
+ commit: Commit
413
+ index: number
414
+ isActive: boolean
415
+ onClick: () => void
416
+ }) {
417
+ return (
418
+ <div
419
+ className={`trace-commit ${isActive ? 'active' : ''}`}
420
+ style={{
421
+ '--trace-commit-color':
422
+ commit.authorType === 'ai' ? 'var(--trace-ai)' : 'var(--trace-human)'
423
+ } as React.CSSProperties}
424
+ onClick={onClick}
425
+ >
426
+ <div className="trace-commit-header">
427
+ <span className="trace-commit-dot" aria-hidden="true" />
428
+ <span className="trace-commit-message">
429
+ {commit.message}
430
+ </span>
431
+ <span className={`trace-badge ${commit.authorType}`} aria-label={`Author type: ${commit.authorType}`}>
432
+ {commit.authorType}
433
+ </span>
434
+ </div>
435
+ <div className="trace-commit-meta">
436
+ {commit.author} · {commit.time}
437
+ </div>
438
+ </div>
439
+ )
440
+ })
441
+
442
+ // Memoized diff content - only re-renders when active commit changes
443
+ const DiffContent = memo(function DiffContent({
444
+ commit,
445
+ activeIndex,
446
+ linePrefix
447
+ }: {
448
+ commit: Commit
449
+ activeIndex: number
450
+ linePrefix: Record<string, string>
451
+ }) {
452
+ return (
453
+ <>
454
+ <div className="trace-diff-header">
455
+ <div className="trace-diff-header-message">
456
+ {commit.message}
457
+ </div>
458
+ <div className="trace-diff-header-meta">
459
+ {commit.hash} · {commit.author}
460
+ </div>
461
+ </div>
462
+ {commit.lines.map((line, index) => (
463
+ <DiffLine
464
+ key={`${activeIndex}-${index}-${line.type}`}
465
+ line={line}
466
+ prefix={linePrefix[line.type] || ' '}
467
+ />
468
+ ))}
469
+ </>
470
+ )
471
+ })
472
+
473
+ function injectStyles() {
474
+ if (typeof document === 'undefined') return
475
+ if (document.getElementById(STYLE_ID)) return
476
+
477
+ const style = document.createElement('style')
478
+ style.id = STYLE_ID
479
+ style.textContent = styles
480
+ document.head.appendChild(style)
481
+ }
482
+
483
+ export function Trace({
484
+ commits = [],
485
+ autoPlay = false,
486
+ interval = 2000,
487
+ onCommit,
488
+ className = '',
489
+ theme = 'dark',
490
+ filterable = false,
491
+ defaultFilter = {},
492
+ onFilterChange
493
+ }: TraceProps) {
494
+ const themeVars = themeToVars(themes[theme])
495
+ const [activeIndex, setActiveIndex] = useState(0)
496
+ const [isPlaying, setIsPlaying] = useState(autoPlay)
497
+ const [filter, setFilter] = useState<FilterOptions>(defaultFilter)
498
+ const intervalRef = useRef<number | undefined>(undefined)
499
+ const commitsRef = useRef(commits)
500
+ const onCommitRef = useRef(onCommit)
501
+
502
+ // Filter commits based on filter options
503
+ const filteredCommits = useMemo(() => {
504
+ let result = commits
505
+
506
+ if (filter.search) {
507
+ const searchLower = filter.search.toLowerCase()
508
+ result = result.filter(c =>
509
+ c.message.toLowerCase().includes(searchLower) ||
510
+ c.author.toLowerCase().includes(searchLower) ||
511
+ c.hash.toLowerCase().includes(searchLower)
512
+ )
513
+ }
514
+
515
+ if (filter.authorType && filter.authorType !== 'all') {
516
+ result = result.filter(c => c.authorType === filter.authorType)
517
+ }
518
+
519
+ return result
520
+ }, [commits, filter])
521
+
522
+ // Handle filter change
523
+ const handleFilterChange = useCallback((newFilter: FilterOptions) => {
524
+ setFilter(newFilter)
525
+ setActiveIndex(0)
526
+ setIsPlaying(false)
527
+ onFilterChange?.(newFilter)
528
+ }, [onFilterChange])
529
+
530
+ useEffect(() => {
531
+ injectStyles()
532
+ }, [])
533
+
534
+ useEffect(() => {
535
+ commitsRef.current = commits
536
+ onCommitRef.current = onCommit
537
+ }, [commits, onCommit])
538
+
539
+ useEffect(() => {
540
+ if (!isPlaying) {
541
+ if (intervalRef.current) {
542
+ clearInterval(intervalRef.current)
543
+ intervalRef.current = undefined
544
+ }
545
+ return
546
+ }
547
+
548
+ intervalRef.current = window.setInterval(() => {
549
+ setActiveIndex(i => {
550
+ const next = i + 1
551
+ const maxIndex = filteredCommits.length - 1
552
+ if (next >= maxIndex) {
553
+ setIsPlaying(false)
554
+ return maxIndex
555
+ }
556
+ return next
557
+ })
558
+ }, interval)
559
+
560
+ return () => {
561
+ if (intervalRef.current) {
562
+ clearInterval(intervalRef.current)
563
+ }
564
+ }
565
+ }, [isPlaying, interval, filteredCommits.length])
566
+
567
+ useEffect(() => {
568
+ if (filteredCommits[activeIndex]) {
569
+ onCommitRef.current?.(filteredCommits[activeIndex])
570
+ }
571
+ }, [activeIndex, filteredCommits])
572
+
573
+ // Helper to navigate with View Transitions API (2026: native smooth transitions)
574
+ const navigateToIndex = useCallback((newIndex: number) => {
575
+ const setActive = () => setActiveIndex(newIndex)
576
+
577
+ // Use View Transitions API for smooth native transitions (Chrome 111+)
578
+ // Disabled for performance - can be enabled via prop if needed
579
+ // if (document.startViewTransition) {
580
+ // try {
581
+ // document.startViewTransition(setActive)
582
+ // } catch (err: unknown) {
583
+ // setActive()
584
+ // console.error('View transition failed:', err)
585
+ // }
586
+ // } else {
587
+ setActive()
588
+ // }
589
+ }, [])
590
+
591
+ const handlePrev = useCallback(() => {
592
+ navigateToIndex(Math.max(0, activeIndex - 1))
593
+ }, [activeIndex, navigateToIndex])
594
+
595
+ const handleNext = useCallback(() => {
596
+ navigateToIndex(Math.min(filteredCommits.length - 1, activeIndex + 1))
597
+ }, [activeIndex, navigateToIndex, filteredCommits.length])
598
+
599
+ const togglePlay = useCallback(() => {
600
+ setIsPlaying(v => !v)
601
+ }, [])
602
+
603
+ // Keyboard navigation handler
604
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
605
+ switch (e.key) {
606
+ case 'ArrowUp':
607
+ case 'ArrowLeft':
608
+ e.preventDefault()
609
+ handlePrev()
610
+ break
611
+ case 'ArrowDown':
612
+ case 'ArrowRight':
613
+ e.preventDefault()
614
+ handleNext()
615
+ break
616
+ case ' ':
617
+ e.preventDefault()
618
+ togglePlay()
619
+ break
620
+ case 'Escape':
621
+ e.preventDefault()
622
+ navigateToIndex(0)
623
+ setIsPlaying(false)
624
+ break
625
+ case 'Home':
626
+ e.preventDefault()
627
+ navigateToIndex(0)
628
+ break
629
+ case 'End':
630
+ e.preventDefault()
631
+ navigateToIndex(filteredCommits.length - 1)
632
+ break
633
+ }
634
+ }, [handlePrev, handleNext, togglePlay, navigateToIndex, filteredCommits.length])
635
+
636
+ if (commits.length === 0) {
637
+ return (
638
+ <div className={`trace-root ${className}`}>
639
+ <div className="trace-empty">no commits to display</div>
640
+ </div>
641
+ )
642
+ }
643
+
644
+ if (filteredCommits.length === 0) {
645
+ return (
646
+ <div className={`trace-root ${className}`}>
647
+ <div className="trace-empty">no commits match your filter</div>
648
+ </div>
649
+ )
650
+ }
651
+
652
+ const activeCommit = filteredCommits[activeIndex]
653
+ const progressHeight = filteredCommits.length > 0 ? ((activeIndex + 1) / filteredCommits.length) * 100 : 0
654
+
655
+ // Guard against out-of-bounds activeIndex (can happen with rapid navigation)
656
+ if (!activeCommit) {
657
+ return (
658
+ <div className={`trace-root ${className}`}>
659
+ <div className="trace-empty">commit not found</div>
660
+ </div>
661
+ )
662
+ }
663
+
664
+ return (
665
+ <div
666
+ className={`trace-root ${className}`}
667
+ style={themeVars as React.CSSProperties}
668
+ onKeyDown={handleKeyDown}
669
+ tabIndex={0}
670
+ >
671
+ {filterable && (
672
+ <FilterBar
673
+ filter={filter}
674
+ onFilterChange={handleFilterChange}
675
+ totalCount={commits.length}
676
+ filteredCount={filteredCommits.length}
677
+ />
678
+ )}
679
+
680
+ <div className="trace-controls">
681
+ <button className="trace-btn" onClick={handlePrev} disabled={activeIndex === 0}>
682
+ prev
683
+ </button>
684
+ <button className="trace-btn" onClick={togglePlay}>
685
+ {isPlaying ? 'pause' : 'play'}
686
+ </button>
687
+ <button className="trace-btn" onClick={handleNext} disabled={activeIndex >= filteredCommits.length - 1}>
688
+ next
689
+ </button>
690
+ <span className="trace-info">{activeIndex + 1} / {filteredCommits.length}</span>
691
+ </div>
692
+
693
+ <div className="trace-container">
694
+ <div className="trace-timeline">
695
+ <div className="trace-progress" style={{ height: `${progressHeight}%` }} />
696
+ {filteredCommits.map((commit, index) => (
697
+ <CommitItem
698
+ key={commit.hash}
699
+ commit={commit}
700
+ index={index}
701
+ isActive={index === activeIndex}
702
+ onClick={() => navigateToIndex(index)}
703
+ />
704
+ ))}
705
+ </div>
706
+
707
+ <div className="trace-diff">
708
+ <DiffContent
709
+ commit={activeCommit}
710
+ activeIndex={activeIndex}
711
+ linePrefix={LINE_PREFIX}
712
+ />
713
+ </div>
714
+ </div>
715
+ </div>
716
+ )
717
+ }