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.
- package/.tracerc.example +38 -0
- package/README.md +136 -0
- package/bun.lock +511 -0
- package/bunchee.config.ts +11 -0
- package/cli/index.ts +251 -0
- package/cli/parser.ts +76 -0
- package/cli/tsconfig.json +6 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +858 -0
- package/dist/config.cjs +66 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +63 -0
- package/dist/highlight/index.cjs +770 -0
- package/dist/highlight/index.d.ts +26 -0
- package/dist/highlight/index.js +766 -0
- package/dist/index.cjs +849 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +845 -0
- package/examples/demo/App.tsx +78 -0
- package/examples/demo/index.html +12 -0
- package/examples/demo/main.tsx +10 -0
- package/examples/demo/mockData.ts +170 -0
- package/examples/demo/styles.css +103 -0
- package/examples/demo/tsconfig.json +21 -0
- package/examples/demo/tsconfig.node.json +10 -0
- package/examples/demo/vite.config.ts +20 -0
- package/package.json +58 -0
- package/src/Trace.tsx +717 -0
- package/src/cache.ts +118 -0
- package/src/config.ts +51 -0
- package/src/entries/config.ts +7 -0
- package/src/entries/gitea.ts +4 -0
- package/src/entries/github.ts +5 -0
- package/src/entries/gitlab.ts +4 -0
- package/src/gitea.ts +58 -0
- package/src/github.ts +100 -0
- package/src/gitlab.ts +65 -0
- package/src/highlight/highlight.ts +119 -0
- package/src/highlight/index.ts +4 -0
- package/src/host.ts +32 -0
- package/src/index.ts +6 -0
- package/src/patterns.ts +6 -0
- package/src/shared.ts +108 -0
- package/src/themes.ts +98 -0
- package/src/types.ts +72 -0
- package/test/e2e.html +424 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|