@dxos/react-ui-trace 0.11.1

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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +3 -0
  3. package/dist/lib/index.mjs +2868 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +338 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/lib/translations.mjs +19 -0
  8. package/dist/lib/translations.mjs.map +1 -0
  9. package/dist/types/src/components/Gantt/Gantt.d.ts +68 -0
  10. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -0
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +30 -0
  12. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  14. package/dist/types/src/components/Gantt/index.d.ts.map +1 -0
  15. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +32 -0
  16. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -0
  17. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts +8 -0
  18. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -0
  19. package/dist/types/src/components/ProcessTree/index.d.ts +2 -0
  20. package/dist/types/src/components/ProcessTree/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Timeline/Timeline.d.ts +50 -0
  22. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -0
  23. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +24 -0
  24. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -0
  25. package/dist/types/src/components/Timeline/index.d.ts +4 -0
  26. package/dist/types/src/components/Timeline/index.d.ts.map +1 -0
  27. package/dist/types/src/components/Timeline/timeline-options.d.ts +9 -0
  28. package/dist/types/src/components/Timeline/timeline-options.d.ts.map +1 -0
  29. package/dist/types/src/components/Timeline/timeline-printer.d.ts +7 -0
  30. package/dist/types/src/components/Timeline/timeline-printer.d.ts.map +1 -0
  31. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts +2 -0
  32. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts.map +1 -0
  33. package/dist/types/src/components/TracePanel/TracePanel.d.ts +28 -0
  34. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -0
  35. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts +19 -0
  36. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts +2 -0
  38. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/index.d.ts +4 -0
  40. package/dist/types/src/components/TracePanel/index.d.ts.map +1 -0
  41. package/dist/types/src/components/TracePanel/trace-filter.d.ts +39 -0
  42. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts +2 -0
  44. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts.map +1 -0
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts +15 -0
  46. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -0
  47. package/dist/types/src/components/index.d.ts +5 -0
  48. package/dist/types/src/components/index.d.ts.map +1 -0
  49. package/dist/types/src/execution-graph/execution-graph.d.ts +139 -0
  50. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -0
  51. package/dist/types/src/execution-graph/execution-graph.test.d.ts +2 -0
  52. package/dist/types/src/execution-graph/execution-graph.test.d.ts.map +1 -0
  53. package/dist/types/src/execution-graph/index.d.ts +4 -0
  54. package/dist/types/src/execution-graph/index.d.ts.map +1 -0
  55. package/dist/types/src/execution-graph/pending-block-status.d.ts +26 -0
  56. package/dist/types/src/execution-graph/pending-block-status.d.ts.map +1 -0
  57. package/dist/types/src/execution-graph/pending-block-status.test.d.ts +2 -0
  58. package/dist/types/src/execution-graph/pending-block-status.test.d.ts.map +1 -0
  59. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +2 -0
  60. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +1 -0
  61. package/dist/types/src/execution-graph/span-tree.d.ts +106 -0
  62. package/dist/types/src/execution-graph/span-tree.d.ts.map +1 -0
  63. package/dist/types/src/execution-graph/span-tree.test.d.ts +2 -0
  64. package/dist/types/src/execution-graph/span-tree.test.d.ts.map +1 -0
  65. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts +2 -0
  66. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts.map +1 -0
  67. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts +40 -0
  68. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts.map +1 -0
  69. package/dist/types/src/execution-graph/testing/index.d.ts +2 -0
  70. package/dist/types/src/execution-graph/testing/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/index.d.ts +4 -0
  72. package/dist/types/src/hooks/index.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useExecutionGraph.d.ts +17 -0
  74. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useSessionTimeline.d.ts +17 -0
  76. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useTraceMessages.d.ts +17 -0
  78. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +5 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/session-timeline/index.d.ts +3 -0
  82. package/dist/types/src/session-timeline/index.d.ts.map +1 -0
  83. package/dist/types/src/session-timeline/session-timeline.d.ts +19 -0
  84. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -0
  85. package/dist/types/src/session-timeline/session-timeline.test.d.ts +2 -0
  86. package/dist/types/src/session-timeline/session-timeline.test.d.ts.map +1 -0
  87. package/dist/types/src/session-timeline/types.d.ts +129 -0
  88. package/dist/types/src/session-timeline/types.d.ts.map +1 -0
  89. package/dist/types/src/testing/index.d.ts +5 -0
  90. package/dist/types/src/testing/index.d.ts.map +1 -0
  91. package/dist/types/src/testing/simulated-agent.d.ts +63 -0
  92. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -0
  93. package/dist/types/src/testing/snapshot-playback.d.ts +12 -0
  94. package/dist/types/src/testing/snapshot-playback.d.ts.map +1 -0
  95. package/dist/types/src/testing/test-trace.d.ts +5 -0
  96. package/dist/types/src/testing/test-trace.d.ts.map +1 -0
  97. package/dist/types/src/translations.d.ts +19 -0
  98. package/dist/types/src/translations.d.ts.map +1 -0
  99. package/dist/types/tsconfig.tsbuildinfo +1 -0
  100. package/package.json +77 -0
  101. package/src/components/Gantt/Gantt.stories.tsx +241 -0
  102. package/src/components/Gantt/Gantt.tsx +589 -0
  103. package/src/components/Gantt/index.ts +5 -0
  104. package/src/components/ProcessTree/ProcessTree.stories.tsx +95 -0
  105. package/src/components/ProcessTree/ProcessTree.tsx +265 -0
  106. package/src/components/ProcessTree/index.ts +5 -0
  107. package/src/components/Timeline/Timeline.stories.tsx +326 -0
  108. package/src/components/Timeline/Timeline.tsx +534 -0
  109. package/src/components/Timeline/git.drawio.svg +821 -0
  110. package/src/components/Timeline/index.ts +7 -0
  111. package/src/components/Timeline/timeline-options.ts +27 -0
  112. package/src/components/Timeline/timeline-printer.test.ts +76 -0
  113. package/src/components/Timeline/timeline-printer.ts +223 -0
  114. package/src/components/TracePanel/TracePanel.tsx +248 -0
  115. package/src/components/TracePanel/dxn-extractor.test.ts +153 -0
  116. package/src/components/TracePanel/dxn-extractor.ts +166 -0
  117. package/src/components/TracePanel/index.ts +7 -0
  118. package/src/components/TracePanel/trace-filter.test.ts +137 -0
  119. package/src/components/TracePanel/trace-filter.ts +120 -0
  120. package/src/components/TracePanel/useTraceMenu.ts +95 -0
  121. package/src/components/index.ts +8 -0
  122. package/src/execution-graph/SPEC.md +749 -0
  123. package/src/execution-graph/execution-graph.test.ts +1205 -0
  124. package/src/execution-graph/execution-graph.ts +1076 -0
  125. package/src/execution-graph/index.ts +35 -0
  126. package/src/execution-graph/pending-block-status.test.ts +118 -0
  127. package/src/execution-graph/pending-block-status.ts +88 -0
  128. package/src/execution-graph/remote-snapshot.test.ts +116 -0
  129. package/src/execution-graph/span-tree.test.ts +370 -0
  130. package/src/execution-graph/span-tree.ts +404 -0
  131. package/src/execution-graph/sub-agent-delegation.test.ts +65 -0
  132. package/src/execution-graph/testing/collect-trace-events.ts +76 -0
  133. package/src/execution-graph/testing/index.ts +5 -0
  134. package/src/execution-graph/testing/sub-agent-delegation.json +1285 -0
  135. package/src/hooks/index.ts +7 -0
  136. package/src/hooks/useExecutionGraph.ts +107 -0
  137. package/src/hooks/useSessionTimeline.ts +53 -0
  138. package/src/hooks/useTraceMessages.ts +58 -0
  139. package/src/index.ts +8 -0
  140. package/src/session-timeline/index.ts +6 -0
  141. package/src/session-timeline/session-timeline.test.ts +662 -0
  142. package/src/session-timeline/session-timeline.ts +605 -0
  143. package/src/session-timeline/types.ts +100 -0
  144. package/src/testing/index.ts +8 -0
  145. package/src/testing/simulated-agent.ts +293 -0
  146. package/src/testing/snapshot-playback.ts +45 -0
  147. package/src/testing/test-trace.ts +22 -0
  148. package/src/translations.ts +29 -0
@@ -0,0 +1,534 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { format } from 'date-fns';
6
+ import React, { memo, useEffect, useMemo, useState } from 'react';
7
+
8
+ import { addEventListener } from '@dxos/async';
9
+ import { LogLevel } from '@dxos/log';
10
+ import { Icon, type ThemedClassName, useDynamicRef, useForwardedRef, useTranslation } from '@dxos/react-ui';
11
+ import { composable, composableProps } from '@dxos/react-ui';
12
+ import { Shimmer } from '@dxos/react-ui-components';
13
+ import { mx } from '@dxos/ui-theme';
14
+ import { trim } from '@dxos/util';
15
+
16
+ import { translationKey } from '../../translations.ts';
17
+
18
+ import { type TimelineOptions, compactOptions, defaultOptions } from './timeline-options.ts';
19
+
20
+ /**
21
+ * Mercurial-style Commit.
22
+ */
23
+ export type Commit = {
24
+ id: string;
25
+ timestamp?: Date; // TODO(burdon): Unix time?
26
+ parents?: string[];
27
+ branch: string;
28
+ icon?: string;
29
+ level?: LogLevel;
30
+ message: string;
31
+ tags?: string[];
32
+ /** DXN link for navigation to referenced objects. */
33
+ link?: string;
34
+ };
35
+
36
+ const SHIMMER_EFFECT_TAG = 'effect:shimmer';
37
+
38
+ /** Wall-clock to the second: a trace line is read against its neighbours, and milliseconds only widen the column. */
39
+ const TIMESTAMP_FORMAT = 'HH:mm:ss';
40
+
41
+ const hasShimmerEffect = (commit: Commit): boolean => commit.tags?.includes(SHIMMER_EFFECT_TAG) ?? false;
42
+
43
+ const empty = Object.freeze([]);
44
+
45
+ export type TimelineProps = ThemedClassName<{
46
+ /** Optional whitelist. */
47
+ branches?: string[];
48
+ /**
49
+ * Controlled value for the highlighted branch.
50
+ * When provided, takes precedence over the branch derived from the currently selected commit.
51
+ */
52
+ branch?: string | null;
53
+ commits?: Commit[];
54
+ showTimestamp?: boolean;
55
+ showIcon?: boolean;
56
+ compact?: boolean;
57
+ options?: TimelineOptions;
58
+ debug?: boolean;
59
+ onChange?: (props: { current?: number; commit?: Commit }) => void;
60
+ /**
61
+ * Callback when a commit with a link is clicked.
62
+ * If provided, commits with links will be navigable.
63
+ */
64
+ onSelect?: (commit: Commit | undefined) => void;
65
+ }>;
66
+
67
+ /**
68
+ * GitGraph-style timeline.
69
+ */
70
+ // TODO(burdon): Virtualize.
71
+ export const Timeline = memo(
72
+ composable<HTMLDivElement, TimelineProps>(
73
+ (
74
+ {
75
+ branches: branchesProp,
76
+ branch,
77
+ commits = empty,
78
+ showTimestamp = false,
79
+ showIcon = true,
80
+ compact = false,
81
+ options = compact ? compactOptions : defaultOptions,
82
+ debug = false,
83
+ onChange,
84
+ onSelect,
85
+ ...props
86
+ },
87
+ forwardedRef,
88
+ ) => {
89
+ const { t } = useTranslation(translationKey);
90
+ const containerRef = useForwardedRef(forwardedRef);
91
+
92
+ // Auto-discover branches if not provided.
93
+ const branches = useMemo(() => {
94
+ if (branchesProp) {
95
+ return branchesProp;
96
+ }
97
+
98
+ return commits.reduce((branches, commit) => {
99
+ if (!branches.includes(commit.branch)) {
100
+ branches.push(commit.branch);
101
+ }
102
+
103
+ return branches;
104
+ }, [] as string[]);
105
+ }, [branchesProp, commits]);
106
+
107
+ // NOTE: Assumes commits are in topological order.
108
+ const getCommitIndex = (id: string) => commits.findIndex((c) => c.id === id);
109
+ const getBranch = (id: string) => commits.find((c) => c.id === id)?.branch;
110
+
111
+ /**
112
+ * Assign branches to lanes, reusing lanes once a branch has been merged.
113
+ * Unmerged branches keep their lane active (they may still be in progress).
114
+ */
115
+ const { branchLane, laneCount } = useMemo(() => {
116
+ const visibleBranches = new Set(branches);
117
+
118
+ const commitBranch = new Map<string, string>();
119
+ for (const commit of commits) {
120
+ commitBranch.set(commit.id, commit.branch);
121
+ }
122
+
123
+ // Find the row at which each branch is merged by another branch.
124
+ const mergeRow = new Map<string, number>();
125
+ for (let row = 0; row < commits.length; row++) {
126
+ const commit = commits[row]!;
127
+ for (const parentId of commit.parents ?? []) {
128
+ const parentBranch = commitBranch.get(parentId);
129
+ if (parentBranch && parentBranch !== commit.branch && visibleBranches.has(parentBranch)) {
130
+ mergeRow.set(parentBranch, Math.max(mergeRow.get(parentBranch) ?? row, row));
131
+ }
132
+ }
133
+ }
134
+
135
+ const branchLane = new Map<string, number>();
136
+ if (branches.length > 0) {
137
+ branchLane.set(branches[0]!, 0);
138
+ }
139
+
140
+ const activeLanes = new Set<number>([0]);
141
+ let maxLane = 0;
142
+
143
+ for (let row = 0; row < commits.length; row++) {
144
+ const commit = commits[row]!;
145
+ if (visibleBranches.has(commit.branch) && !branchLane.has(commit.branch)) {
146
+ let lane = 1;
147
+ while (activeLanes.has(lane)) {
148
+ lane++;
149
+ }
150
+ branchLane.set(commit.branch, lane);
151
+ activeLanes.add(lane);
152
+ maxLane = Math.max(maxLane, lane);
153
+ }
154
+
155
+ // Release lanes for branches that have been merged at this row.
156
+ for (const [branch, endRow] of mergeRow) {
157
+ if (endRow === row && branch !== branches[0] && branchLane.has(branch)) {
158
+ activeLanes.delete(branchLane.get(branch)!);
159
+ }
160
+ }
161
+ }
162
+
163
+ return { branchLane, laneCount: maxLane + 1 };
164
+ }, [commits, branches]);
165
+
166
+ const getBranchIndex = (branch: string): number => branchLane.get(branch) ?? -1;
167
+
168
+ /**
169
+ * Create spans for each branch.
170
+ */
171
+ const spans = useMemo(() => {
172
+ const spans = new Map<string, Span>();
173
+ commits.forEach((commit, index) => {
174
+ let span = spans.get(commit.branch);
175
+ if (!span) {
176
+ span = { start: index, end: index };
177
+ spans.set(commit.branch, span);
178
+ } else {
179
+ span.end = index;
180
+ }
181
+
182
+ const parents = commit.parents ?? [];
183
+ for (const parent of parents) {
184
+ const branch = getBranch(parent);
185
+ if (branch && branch !== commit.branch) {
186
+ span.start = Math.min(span.start, getCommitIndex(parent));
187
+
188
+ // Detect merge.
189
+ if (parents.length > 1) {
190
+ const parentSpan = spans.get(branch);
191
+ if (parentSpan) {
192
+ parentSpan.end = Math.max(parentSpan.end, index);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ });
198
+
199
+ return spans;
200
+ }, [commits, branches]);
201
+
202
+ // Navigation.
203
+ const [current, setCurrent] = useState<number | undefined>(undefined);
204
+ const currentRef = useDynamicRef<number | undefined>(current);
205
+ const selectedRef = useDynamicRef<number | undefined>(undefined);
206
+ const currentCommit = useMemo(() => (current !== undefined ? commits[current] : undefined), [current, commits]);
207
+
208
+ // Controlled `branch` takes precedence over the branch derived from the selected commit.
209
+ const highlightedBranch = branch ?? currentCommit?.branch;
210
+
211
+ useEffect(() => {
212
+ onChange?.({ current, commit: current === undefined ? undefined : commits[current] });
213
+ const el = containerRef.current?.querySelector(`[data-index="${current}"]`);
214
+ el?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
215
+ }, [onChange, current]);
216
+
217
+ // When the controlled `branch` changes, jump to the first commit on that branch — but
218
+ // never override an explicit commit selection that already sits on that branch. Otherwise a
219
+ // click that selects a commit (and drives `branch` via the parent) would be snapped
220
+ // back to the branch's first commit.
221
+ useEffect(() => {
222
+ if (!branch) {
223
+ return;
224
+ }
225
+ if (currentRef.current !== undefined && commits[currentRef.current]?.branch === branch) {
226
+ return;
227
+ }
228
+
229
+ const index = commits.findIndex((commit) => commit.branch === branch);
230
+ if (index >= 0) {
231
+ setCurrent(index);
232
+ }
233
+ }, [branch]);
234
+
235
+ useEffect(() => {
236
+ if (!containerRef.current) {
237
+ return;
238
+ }
239
+
240
+ return addEventListener(containerRef.current, 'keydown', (event) => {
241
+ switch (event.key) {
242
+ case 'ArrowUp': {
243
+ event.preventDefault(); // Prevent implicit scrolling.
244
+ if (event.metaKey || currentRef.current === undefined) {
245
+ setCurrent(0);
246
+ } else {
247
+ setCurrent((selected) => {
248
+ if (event.shiftKey && selected !== undefined) {
249
+ const branch = commits[selected].branch;
250
+ for (let i = selected - 1; i >= 0; i--) {
251
+ if (commits[i].branch === branch) {
252
+ return i;
253
+ }
254
+ }
255
+
256
+ return selected;
257
+ } else {
258
+ return selected === undefined ? commits.length - 1 : Math.max(0, selected - 1);
259
+ }
260
+ });
261
+ }
262
+ break;
263
+ }
264
+ case 'ArrowDown': {
265
+ event.preventDefault(); // Prevent implicit scrolling.
266
+ if (event.metaKey || currentRef.current === undefined) {
267
+ setCurrent(commits.length - 1);
268
+ } else {
269
+ setCurrent((selected) => {
270
+ if (event.shiftKey && selected !== undefined) {
271
+ const branch = commits[selected].branch;
272
+ for (let i = selected + 1; i <= commits.length - 1; i++) {
273
+ if (commits[i].branch === branch) {
274
+ return i;
275
+ }
276
+ }
277
+ return selected;
278
+ } else {
279
+ return selected === undefined ? 0 : Math.min(commits.length - 1, selected + 1);
280
+ }
281
+ });
282
+ }
283
+ break;
284
+ }
285
+ case 'Enter': {
286
+ event.preventDefault();
287
+ if (currentRef.current !== undefined) {
288
+ selectedRef.current = currentRef.current === selectedRef.current ? undefined : currentRef.current;
289
+ onSelect?.(selectedRef.current !== undefined ? commits[selectedRef.current] : undefined);
290
+ }
291
+ break;
292
+ }
293
+ }
294
+ });
295
+ }, [commits, containerRef.current]);
296
+
297
+ return (
298
+ <div
299
+ {...composableProps(props, { classNames: 'grid auto-rows-min outline-none' })}
300
+ tabIndex={0}
301
+ style={{
302
+ gridTemplateColumns: ['min-content', showIcon && '1.25rem', '1fr', showTimestamp && 'max-content']
303
+ .filter(Boolean)
304
+ .join(' '),
305
+ }}
306
+ ref={containerRef}
307
+ >
308
+ {commits.length < 1 ? (
309
+ <p className='col-span-full text-description p-trim-md'>{t('no-commits.message')}</p>
310
+ ) : (
311
+ commits.map((commit, index) => {
312
+ // Skip branches that are not whitelisted.
313
+ const idx = getBranchIndex(commit.branch);
314
+ if (idx === -1) {
315
+ return null;
316
+ }
317
+
318
+ const hasLink = !!commit.link && !!onSelect;
319
+ // Clicking the selected commit clears the selection, as Enter does.
320
+ const handleClick = () => {
321
+ if (selectedRef.current === index) {
322
+ selectedRef.current = undefined;
323
+ setCurrent(undefined);
324
+ onSelect?.(undefined);
325
+ return;
326
+ }
327
+ setCurrent(index);
328
+ selectedRef.current = index;
329
+ onSelect?.(commit);
330
+ };
331
+
332
+ const message = debug ? JSON.stringify({ id: commit.id, parents: commit.parents }) : commit.message;
333
+
334
+ return (
335
+ <div
336
+ key={commit.id}
337
+ data-index={index}
338
+ aria-current={current === index}
339
+ className={mx(
340
+ 'group/row col-span-full grid grid-cols-subgrid gap-1 px-[2px] overflow-hidden items-center cursor-pointer pe-2',
341
+ 'aria-current:bg-current-surface! hover:bg-hover-surface-subtle',
342
+ )}
343
+ style={{ height: `${options.lineHeight}px` }}
344
+ onClick={handleClick}
345
+ >
346
+ <div className='px-2'>
347
+ <LineVector
348
+ branchLane={branchLane}
349
+ laneCount={laneCount}
350
+ spans={spans}
351
+ index={index}
352
+ commit={commit}
353
+ highlightedBranch={highlightedBranch}
354
+ options={options}
355
+ />
356
+ </div>
357
+ {showIcon && <CommitIcon commit={commit} />}
358
+ <div
359
+ className={mx(
360
+ 'text-sm truncate cursor-pointer text-description font-thin group-aria-current/row:text-current-fg hover:text-current-fg',
361
+ hasLink && 'underline decoration-dotted underline-offset-2',
362
+ )}
363
+ >
364
+ {hasShimmerEffect(commit) ? <Shimmer>{message}</Shimmer> : message}
365
+ </div>
366
+ {showTimestamp && (
367
+ <div className='text-xs tabular-nums items-center text-description font-thin'>
368
+ {commit.timestamp && format(commit.timestamp, TIMESTAMP_FORMAT)}
369
+ </div>
370
+ )}
371
+ </div>
372
+ );
373
+ })
374
+ )}
375
+ </div>
376
+ );
377
+ },
378
+ ),
379
+ );
380
+
381
+ Timeline.displayName = 'Timeline';
382
+
383
+ const CommitIcon = memo(({ commit }: { commit: Commit }) => {
384
+ if (!commit.icon) {
385
+ return <div />;
386
+ }
387
+
388
+ return (
389
+ <Icon
390
+ icon={commit.icon}
391
+ size={4}
392
+ synchronized
393
+ classNames={mx(
394
+ commit.icon === 'ph--spinner-gap--regular' && 'animate-spin',
395
+ commit.level !== undefined && levelColors[commit.level],
396
+ )}
397
+ />
398
+ );
399
+ });
400
+
401
+ type Span = {
402
+ start: number;
403
+ end: number;
404
+ };
405
+
406
+ type Color = {
407
+ stroke: string;
408
+ fill: string;
409
+ };
410
+
411
+ const defaultColor: Color = { stroke: 'stroke-neutral-surface', fill: 'fill-neutral-surface' };
412
+
413
+ const colors: Color[] = [
414
+ { stroke: 'stroke-orange-500', fill: 'group-aria-[current=true]:fill-orange-500' },
415
+ { stroke: 'stroke-sky-500', fill: 'group-aria-[current=true]:fill-sky-500' },
416
+ { stroke: 'stroke-green-500', fill: 'group-aria-[current=true]:fill-green-500' },
417
+ { stroke: 'stroke-fuchsia-500', fill: 'group-aria-[current=true]:fill-fuchsia-500' },
418
+ { stroke: 'stroke-cyan-500', fill: 'group-aria-[current=true]:fill-cyan-500' },
419
+ { stroke: 'stroke-emerald-500', fill: 'group-aria-[current=true]:fill-emerald-500' },
420
+ { stroke: 'stroke-violet-500', fill: 'group-aria-[current=true]:fill-violet-500' },
421
+ { stroke: 'stroke-teal-500', fill: 'group-aria-[current=true]:fill-teal-500' },
422
+ ];
423
+
424
+ const levelColors: Record<LogLevel, string> = {
425
+ [LogLevel.TRACE]: 'text-gray-500',
426
+ [LogLevel.DEBUG]: 'text-gray-500',
427
+ [LogLevel.VERBOSE]: 'text-gray-500',
428
+ [LogLevel.INFO]: 'text-green-500',
429
+ [LogLevel.WARN]: 'text-orange-500',
430
+ [LogLevel.ERROR]: 'text-red-500',
431
+ };
432
+
433
+ type LineVectorProps = {
434
+ branchLane: Map<string, number>;
435
+ laneCount: number;
436
+ spans: Map<string, Span>;
437
+ index: number;
438
+ commit: Commit;
439
+ highlightedBranch: string | undefined;
440
+ options: TimelineOptions;
441
+ };
442
+
443
+ /**
444
+ * SVG for node and connector paths.
445
+ */
446
+ const LineVector = memo(
447
+ ({ branchLane, laneCount, spans, index, commit, highlightedBranch, options }: LineVectorProps) => {
448
+ const halfHeight = options.lineHeight / 2;
449
+ const cx = (c: number) => c * options.columnWidth + options.columnWidth / 2;
450
+ const getBranchIndex = (branch: string): number => branchLane.get(branch) ?? -1;
451
+
452
+ // Create connector path.
453
+ const createPath = (index: number, commit: Commit, branch: string, span: Span): string | undefined => {
454
+ const parents = commit.parents ?? [];
455
+ const commitIndex = getBranchIndex(commit.branch);
456
+ const branchIndex = getBranchIndex(branch);
457
+
458
+ // Vertical connectors.
459
+ if (span.start < index && index < span.end) {
460
+ return `M ${cx(branchIndex)} 0 l 0 ${options.lineHeight}`;
461
+ } else if (commit.branch === branch && parents.length > 0) {
462
+ return `M ${cx(branchIndex)} 0 l 0 ${halfHeight}`;
463
+ } else if (commit.branch === branch && index < span.end) {
464
+ return `M ${cx(branchIndex)} ${halfHeight} l 0 ${options.lineHeight}`;
465
+ }
466
+
467
+ // Branch.
468
+ // TODO(burdon): Assumes can only branch to the right.
469
+ if (commit.branch !== branch && index === span.start) {
470
+ return trim`
471
+ M ${cx(commitIndex)} ${halfHeight}
472
+ L ${cx(branchIndex) - halfHeight} ${halfHeight}
473
+ a ${halfHeight} ${halfHeight} 0 0 1 ${halfHeight} ${halfHeight}
474
+ `;
475
+ }
476
+
477
+ // Merge.
478
+ if (commit.branch !== branch && index === span.end) {
479
+ return trim`
480
+ M ${cx(commitIndex)} ${halfHeight}
481
+ L ${cx(branchIndex) - halfHeight} ${halfHeight}
482
+ a ${halfHeight} ${halfHeight} -90 0 0 ${halfHeight} ${-halfHeight}
483
+ `;
484
+ }
485
+ };
486
+
487
+ const col = getBranchIndex(commit.branch);
488
+ const color = colors[col % colors.length];
489
+ const opacity = (branch: string | undefined) => [
490
+ 'duration-500 transition-opacity',
491
+ highlightedBranch === undefined || branch === highlightedBranch ? 'opacity-100' : 'opacity-50',
492
+ ];
493
+
494
+ return (
495
+ <svg width={laneCount * options.columnWidth} height={options.lineHeight}>
496
+ {/* Connectors */}
497
+ {[...spans.entries()].map(([branch, span]) => {
498
+ const lane = getBranchIndex(branch);
499
+ if (lane < 0) {
500
+ return null;
501
+ }
502
+
503
+ const color = colors[lane % colors.length];
504
+ const path = createPath(index, commit, branch, span);
505
+ if (!path) {
506
+ return null;
507
+ }
508
+
509
+ return (
510
+ <path
511
+ key={branch}
512
+ d={path}
513
+ fill='none'
514
+ className={mx(options.lineStyle, color.stroke, color.fill, opacity(branch))}
515
+ />
516
+ );
517
+ })}
518
+
519
+ <circle
520
+ cx={cx(col)}
521
+ cy={halfHeight}
522
+ r={options.nodeRadius}
523
+ className={mx('fill-base-surface stroke-base-surface')}
524
+ />
525
+ <circle
526
+ cx={cx(col)}
527
+ cy={halfHeight}
528
+ r={options.nodeRadius}
529
+ className={mx('fill-base-surface', options.lineStyle, color?.stroke, color?.fill, opacity(commit.branch))}
530
+ />
531
+ </svg>
532
+ );
533
+ },
534
+ );