@dxos/react-ui-trace 0.11.1 → 0.14.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 (115) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +1722 -561
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/lib/testing.mjs +9 -8
  5. package/dist/lib/testing.mjs.map +1 -1
  6. package/dist/lib/translations.mjs +5 -0
  7. package/dist/lib/translations.mjs.map +1 -1
  8. package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
  9. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
  10. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
  13. package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
  14. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
  15. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
  16. package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
  17. package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
  18. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
  19. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
  20. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  21. package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
  22. package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
  23. package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
  24. package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
  25. package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
  26. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
  27. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
  28. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
  30. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
  31. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
  32. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
  34. package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
  35. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
  36. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
  38. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
  40. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
  41. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
  42. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
  44. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
  46. package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
  47. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
  49. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
  50. package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
  51. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
  52. package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
  53. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
  54. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
  55. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
  56. package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
  57. package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
  58. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
  59. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
  60. package/dist/types/src/session-timeline/index.d.ts +1 -0
  61. package/dist/types/src/session-timeline/index.d.ts.map +1 -1
  62. package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
  63. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
  64. package/dist/types/src/session-timeline/types.d.ts +32 -0
  65. package/dist/types/src/session-timeline/types.d.ts.map +1 -1
  66. package/dist/types/src/testing/simulated-agent.d.ts +5 -4
  67. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
  68. package/dist/types/src/testing/test-trace.d.ts +2 -2
  69. package/dist/types/src/testing/test-trace.d.ts.map +1 -1
  70. package/dist/types/src/translations.d.ts +5 -0
  71. package/dist/types/src/translations.d.ts.map +1 -1
  72. package/dist/types/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +30 -26
  74. package/src/components/Gantt/Gantt.stories.tsx +914 -96
  75. package/src/components/Gantt/Gantt.tsx +964 -261
  76. package/src/components/Gantt/gantt-rows.test.ts +123 -0
  77. package/src/components/Gantt/gantt-rows.ts +71 -0
  78. package/src/components/Gantt/gantt-scale.test.ts +105 -0
  79. package/src/components/Gantt/gantt-scale.ts +143 -0
  80. package/src/components/Gantt/index.ts +2 -0
  81. package/src/components/Gantt/useEnter.ts +33 -0
  82. package/src/components/Gantt/useRangeTween.ts +61 -0
  83. package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
  84. package/src/components/ProcessTree/ProcessTree.tsx +99 -90
  85. package/src/components/Timeline/Timeline.stories.tsx +110 -22
  86. package/src/components/Timeline/Timeline.tsx +319 -271
  87. package/src/components/Timeline/timeline-layout.test.ts +177 -0
  88. package/src/components/Timeline/timeline-layout.ts +153 -0
  89. package/src/components/TracePanel/SpanTreeView.tsx +39 -0
  90. package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
  91. package/src/components/TracePanel/TracePanel.tsx +113 -106
  92. package/src/components/TracePanel/trace-filter.test.ts +31 -2
  93. package/src/components/TracePanel/trace-filter.ts +28 -3
  94. package/src/components/TracePanel/useTraceMenu.ts +0 -1
  95. package/src/execution-graph/SPEC.md +10 -10
  96. package/src/execution-graph/execution-graph.test.ts +16 -15
  97. package/src/execution-graph/execution-graph.ts +108 -60
  98. package/src/execution-graph/pending-block-status.ts +1 -1
  99. package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
  100. package/src/hooks/useExecutionGraph.ts +14 -11
  101. package/src/hooks/useSessionTimeline.ts +35 -9
  102. package/src/hooks/useTraceMessages.ts +1 -1
  103. package/src/session-timeline/TaskHistory.stories.tsx +142 -0
  104. package/src/session-timeline/gantt-mapping.test.ts +118 -0
  105. package/src/session-timeline/gantt-mapping.ts +158 -0
  106. package/src/session-timeline/index.ts +1 -0
  107. package/src/session-timeline/session-timeline.test.ts +585 -35
  108. package/src/session-timeline/session-timeline.ts +433 -62
  109. package/src/session-timeline/types.ts +14 -0
  110. package/src/testing/simulated-agent.ts +17 -16
  111. package/src/testing/test-trace.ts +14 -12
  112. package/src/translations.ts +8 -2
  113. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
  114. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
  115. package/src/execution-graph/remote-snapshot.test.ts +0 -116
@@ -3,18 +3,20 @@
3
3
  //
4
4
 
5
5
  import { format } from 'date-fns';
6
- import React, { memo, useEffect, useMemo, useState } from 'react';
6
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  import { addEventListener } from '@dxos/async';
9
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
10
  import { Shimmer } from '@dxos/react-ui-components';
11
+ import { type WindowController, useListModel, useWindow, windowRowProps } from '@dxos/react-ui-virtual';
12
+ import * as Hooks from '@dxos/react-ui/Hooks';
13
+ import * as Icon from '@dxos/react-ui/Icon';
14
+ import * as Util from '@dxos/react-ui/Util';
13
15
  import { mx } from '@dxos/ui-theme';
14
16
  import { trim } from '@dxos/util';
15
17
 
16
18
  import { translationKey } from '../../translations.ts';
17
-
19
+ import { type TimelineLayout, type TimelineRow, type TimelineSpan, layoutTimeline } from './timeline-layout.ts';
18
20
  import { type TimelineOptions, compactOptions, defaultOptions } from './timeline-options.ts';
19
21
 
20
22
  /**
@@ -42,7 +44,7 @@ const hasShimmerEffect = (commit: Commit): boolean => commit.tags?.includes(SHIM
42
44
 
43
45
  const empty = Object.freeze([]);
44
46
 
45
- export type TimelineProps = ThemedClassName<{
47
+ export type TimelineProps = Util.ThemedClassName<{
46
48
  /** Optional whitelist. */
47
49
  branches?: string[];
48
50
  /**
@@ -56,6 +58,11 @@ export type TimelineProps = ThemedClassName<{
56
58
  compact?: boolean;
57
59
  options?: TimelineOptions;
58
60
  debug?: boolean;
61
+ /**
62
+ * The element the rows are windowed against, which the host owns: a `ScrollArea.Viewport` or a
63
+ * `ScrollContainer.Viewport`, as `MessageList` binds to. Rows mount once it exists.
64
+ */
65
+ scroller: HTMLElement | null;
59
66
  onChange?: (props: { current?: number; commit?: Commit }) => void;
60
67
  /**
61
68
  * Callback when a commit with a link is clicked.
@@ -66,10 +73,12 @@ export type TimelineProps = ThemedClassName<{
66
73
 
67
74
  /**
68
75
  * GitGraph-style timeline.
76
+ *
77
+ * Rows are windowed by `useWindow`, so rendering costs the viewport rather than the history. The
78
+ * layout is still computed over the whole history, so a windowed row draws every lane crossing it.
69
79
  */
70
- // TODO(burdon): Virtualize.
71
80
  export const Timeline = memo(
72
- composable<HTMLDivElement, TimelineProps>(
81
+ Util.composable<HTMLDivElement, TimelineProps>(
73
82
  (
74
83
  {
75
84
  branches: branchesProp,
@@ -80,14 +89,15 @@ export const Timeline = memo(
80
89
  compact = false,
81
90
  options = compact ? compactOptions : defaultOptions,
82
91
  debug = false,
92
+ scroller,
83
93
  onChange,
84
94
  onSelect,
85
95
  ...props
86
96
  },
87
97
  forwardedRef,
88
98
  ) => {
89
- const { t } = useTranslation(translationKey);
90
- const containerRef = useForwardedRef(forwardedRef);
99
+ const { t } = Hooks.useTranslation(translationKey);
100
+ const containerRef = Hooks.useForwardedRef(forwardedRef);
91
101
 
92
102
  // Auto-discover branches if not provided.
93
103
  const branches = useMemo(() => {
@@ -104,114 +114,21 @@ export const Timeline = memo(
104
114
  }, [] as string[]);
105
115
  }, [branchesProp, commits]);
106
116
 
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]);
117
+ const layout = useMemo(() => layoutTimeline(commits, branches), [commits, branches]);
201
118
 
202
119
  // Navigation.
203
120
  const [current, setCurrent] = useState<number | undefined>(undefined);
204
- const currentRef = useDynamicRef<number | undefined>(current);
205
- const selectedRef = useDynamicRef<number | undefined>(undefined);
121
+ const currentRef = Hooks.useDynamicRef<number | undefined>(current);
122
+ const selectedRef = Hooks.useDynamicRef<number | undefined>(undefined);
206
123
  const currentCommit = useMemo(() => (current !== undefined ? commits[current] : undefined), [current, commits]);
207
124
 
208
125
  // Controlled `branch` takes precedence over the branch derived from the selected commit.
209
126
  const highlightedBranch = branch ?? currentCommit?.branch;
210
127
 
128
+ const currentRow = current === undefined ? undefined : layout.rowByCommitIndex.get(current);
129
+
211
130
  useEffect(() => {
212
131
  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
132
  }, [onChange, current]);
216
133
 
217
134
  // When the controlled `branch` changes, jump to the first commit on that branch — but
@@ -294,83 +211,55 @@ export const Timeline = memo(
294
211
  });
295
212
  }, [commits, containerRef.current]);
296
213
 
214
+ // Clicking the current commit clears the selection, as Enter does.
215
+ const handleRowClick = useCallback(
216
+ (index: number) => {
217
+ if (selectedRef.current === index) {
218
+ selectedRef.current = undefined;
219
+ setCurrent(undefined);
220
+ onSelect?.(undefined);
221
+ return;
222
+ }
223
+ setCurrent(index);
224
+ selectedRef.current = index;
225
+ onSelect?.(commits[index]);
226
+ },
227
+ [commits, onSelect],
228
+ );
229
+
230
+ // Positioned rows cannot share one subgrid, so every row carries the template the subgrid
231
+ // used to provide. The columns still line up: a layout pass gives every row the same lane
232
+ // width, and the icon and timestamp columns are fixed.
233
+ const gridTemplateColumns = useMemo(
234
+ () => ['min-content', showIcon && '1.25rem', '1fr', showTimestamp && 'max-content'].filter(Boolean).join(' '),
235
+ [showIcon, showTimestamp],
236
+ );
237
+
297
238
  return (
298
239
  <div
299
- {...composableProps(props, { classNames: 'grid auto-rows-min outline-none' })}
240
+ {...Util.composableProps(props, { classNames: 'relative outline-none' })}
241
+ role='list'
300
242
  tabIndex={0}
301
- style={{
302
- gridTemplateColumns: ['min-content', showIcon && '1.25rem', '1fr', showTimestamp && 'max-content']
303
- .filter(Boolean)
304
- .join(' '),
305
- }}
306
243
  ref={containerRef}
307
244
  >
308
- {commits.length < 1 ? (
309
- <p className='col-span-full text-description p-trim-md'>{t('no-commits.message')}</p>
245
+ {layout.rows.length < 1 ? (
246
+ <p className='text-fg-muted p-trim-md'>{t('no-commits.message')}</p>
310
247
  ) : (
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
- })
248
+ scroller && (
249
+ <TimelineWindow
250
+ scroller={scroller}
251
+ layout={layout}
252
+ options={options}
253
+ currentRow={currentRow}
254
+ showIcon={showIcon}
255
+ showTimestamp={showTimestamp}
256
+ debug={debug}
257
+ highlightedBranch={highlightedBranch}
258
+ linkable={!!onSelect}
259
+ gridTemplateColumns={gridTemplateColumns}
260
+ onRowClick={handleRowClick}
261
+ />
262
+ )
374
263
  )}
375
264
  </div>
376
265
  );
@@ -380,36 +269,198 @@ export const Timeline = memo(
380
269
 
381
270
  Timeline.displayName = 'Timeline';
382
271
 
272
+ //
273
+ // TimelineWindow
274
+ //
275
+
276
+ type TimelineRowContext = {
277
+ layout: TimelineLayout;
278
+ options: TimelineOptions;
279
+ showIcon: boolean;
280
+ showTimestamp: boolean;
281
+ debug: boolean;
282
+ highlightedBranch: string | undefined;
283
+ /** Whether a commit's link is navigable, i.e. the host passed `onSelect`. */
284
+ linkable: boolean;
285
+ gridTemplateColumns: string;
286
+ onRowClick: (commitIndex: number) => void;
287
+ };
288
+
289
+ type TimelineWindowProps = TimelineRowContext & {
290
+ scroller: HTMLElement;
291
+ currentRow: number | undefined;
292
+ };
293
+
294
+ const getRowId = (row: TimelineRow) => row.commit.id;
295
+
296
+ /**
297
+ * The mounted rows: a sizer that gives the scrollbar the whole history's extent, and a window
298
+ * holding the rows in view, translated to where they belong. Rendered only once the host's
299
+ * scroller exists, because the placement binds to the element on mount.
300
+ */
301
+ const TimelineWindow = ({ scroller, layout, options, currentRow, ...context }: TimelineWindowProps) => {
302
+ const scrollerRef = useRef<HTMLElement | null>(scroller);
303
+ scrollerRef.current = scroller;
304
+
305
+ const model = useListModel(layout.rows, getRowId);
306
+ // Exact: every row is `lineHeight` tall, so offsets are a prefix sum and nothing is ever corrected.
307
+ const extents = useMemo(() => ({ of: () => options.lineHeight, exact: true }), [options.lineHeight]);
308
+ const controllerRef = useRef<WindowController>(null);
309
+ const {
310
+ layout: { visible },
311
+ windowRef,
312
+ offset,
313
+ sizerExtent,
314
+ first,
315
+ last,
316
+ } = useWindow({ scrollerRef, model, extents, controllerRef });
317
+
318
+ // Nearest edge, as the `scrollIntoView({ block: 'nearest' })` this replaces did: a row already in
319
+ // view is left alone, so an arrow press moves the view only when the current row would leave it.
320
+ const visibleRef = useRef(visible);
321
+ visibleRef.current = visible;
322
+ useEffect(() => {
323
+ if (currentRow === undefined) {
324
+ return;
325
+ }
326
+
327
+ const { first, last } = visibleRef.current;
328
+ if (currentRow < first) {
329
+ controllerRef.current?.scrollToIndex(currentRow, 'start');
330
+ } else if (currentRow > last) {
331
+ controllerRef.current?.scrollToIndex(currentRow, 'end');
332
+ }
333
+ }, [currentRow]);
334
+
335
+ const rows = [];
336
+ for (let index = first; index <= last; index++) {
337
+ // From the layout rather than the model: the model folds a new layout in one effect later, and
338
+ // a row read from it in between would be drawn against spans it was not laid out with.
339
+ const row = layout.rows[index];
340
+ if (!row) {
341
+ continue;
342
+ }
343
+
344
+ rows.push(
345
+ <TimelineRowView
346
+ key={row.commit.id}
347
+ index={index}
348
+ row={row}
349
+ current={index === currentRow}
350
+ layout={layout}
351
+ options={options}
352
+ {...context}
353
+ />,
354
+ );
355
+ }
356
+
357
+ return (
358
+ <>
359
+ <div style={{ height: sizerExtent }} />
360
+ <div
361
+ ref={windowRef}
362
+ className='absolute top-0 left-0 flex flex-col w-full'
363
+ style={{ transform: `translateY(${offset}px)` }}
364
+ >
365
+ {rows}
366
+ </div>
367
+ </>
368
+ );
369
+ };
370
+
371
+ //
372
+ // TimelineRow
373
+ //
374
+
375
+ type TimelineRowViewProps = TimelineRowContext & {
376
+ /** Row index in the window, which the placement measures rows by. */
377
+ index: number;
378
+ row: TimelineRow;
379
+ current: boolean;
380
+ };
381
+
382
+ const TimelineRowView = memo(
383
+ ({
384
+ index,
385
+ row,
386
+ current,
387
+ layout,
388
+ options,
389
+ showIcon,
390
+ showTimestamp,
391
+ debug,
392
+ highlightedBranch,
393
+ linkable,
394
+ gridTemplateColumns,
395
+ onRowClick,
396
+ }: TimelineRowViewProps) => {
397
+ const { commit } = row;
398
+ const handleClick = useCallback(() => onRowClick(row.index), [onRowClick, row.index]);
399
+
400
+ const hasLink = !!commit.link && linkable;
401
+ const message = debug ? JSON.stringify({ id: commit.id, parents: commit.parents }) : commit.message;
402
+
403
+ return (
404
+ <div
405
+ role='listitem'
406
+ data-object-id={commit.id}
407
+ {...windowRowProps(index, commit.id)}
408
+ data-commit-index={row.index}
409
+ aria-current={current ? 'true' : undefined}
410
+ className='group/row grid gap-1 px-[2px] overflow-hidden items-center pe-2 dx-hover dx-current'
411
+ style={{ gridTemplateColumns, height: `${options.lineHeight}px` }}
412
+ onClick={handleClick}
413
+ >
414
+ <div className='px-2'>
415
+ <LineVector
416
+ layout={layout}
417
+ index={row.index}
418
+ commit={commit}
419
+ highlightedBranch={highlightedBranch}
420
+ options={options}
421
+ />
422
+ </div>
423
+ {showIcon && <CommitIcon commit={commit} />}
424
+ <div
425
+ className={mx(
426
+ 'text-sm truncate cursor-pointer text-fg-muted font-thin group-aria-current/row:text-current-fg hover:text-current-fg',
427
+ hasLink && 'underline decoration-dotted underline-offset-2',
428
+ )}
429
+ >
430
+ {hasShimmerEffect(commit) ? <Shimmer>{message}</Shimmer> : message}
431
+ </div>
432
+ {showTimestamp && (
433
+ <div className='text-xs tabular-nums items-center text-fg-muted font-thin'>
434
+ {commit.timestamp && format(commit.timestamp, TIMESTAMP_FORMAT)}
435
+ </div>
436
+ )}
437
+ </div>
438
+ );
439
+ },
440
+ );
441
+
442
+ TimelineRowView.displayName = 'TimelineRowView';
443
+
383
444
  const CommitIcon = memo(({ commit }: { commit: Commit }) => {
384
445
  if (!commit.icon) {
385
446
  return <div />;
386
447
  }
387
448
 
388
449
  return (
389
- <Icon
450
+ <Icon.Icon
390
451
  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
- )}
452
+ size='md'
453
+ spin={commit.icon === 'ph--spinner-gap--regular'}
454
+ classNames={commit.level !== undefined ? levelColors[commit.level] : undefined}
397
455
  />
398
456
  );
399
457
  });
400
458
 
401
- type Span = {
402
- start: number;
403
- end: number;
404
- };
405
-
406
459
  type Color = {
407
460
  stroke: string;
408
461
  fill: string;
409
462
  };
410
463
 
411
- const defaultColor: Color = { stroke: 'stroke-neutral-surface', fill: 'fill-neutral-surface' };
412
-
413
464
  const colors: Color[] = [
414
465
  { stroke: 'stroke-orange-500', fill: 'group-aria-[current=true]:fill-orange-500' },
415
466
  { stroke: 'stroke-sky-500', fill: 'group-aria-[current=true]:fill-sky-500' },
@@ -431,9 +482,7 @@ const levelColors: Record<LogLevel, string> = {
431
482
  };
432
483
 
433
484
  type LineVectorProps = {
434
- branchLane: Map<string, number>;
435
- laneCount: number;
436
- spans: Map<string, Span>;
485
+ layout: TimelineLayout;
437
486
  index: number;
438
487
  commit: Commit;
439
488
  highlightedBranch: string | undefined;
@@ -443,92 +492,91 @@ type LineVectorProps = {
443
492
  /**
444
493
  * SVG for node and connector paths.
445
494
  */
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`
495
+ const LineVector = memo(({ layout, index, commit, highlightedBranch, options }: LineVectorProps) => {
496
+ const { branchLane, laneCount, spans } = layout;
497
+ const halfHeight = options.lineHeight / 2;
498
+ const cx = (column: number) => column * options.columnWidth + options.columnWidth / 2;
499
+ const getBranchIndex = (branch: string): number => branchLane.get(branch) ?? -1;
500
+
501
+ // Create connector path.
502
+ const createPath = (index: number, commit: Commit, branch: string, span: TimelineSpan): string | undefined => {
503
+ const parents = commit.parents ?? [];
504
+ const commitIndex = getBranchIndex(commit.branch);
505
+ const branchIndex = getBranchIndex(branch);
506
+
507
+ // Vertical connectors.
508
+ if (span.start < index && index < span.end) {
509
+ return `M ${cx(branchIndex)} 0 l 0 ${options.lineHeight}`;
510
+ } else if (commit.branch === branch && parents.length > 0) {
511
+ return `M ${cx(branchIndex)} 0 l 0 ${halfHeight}`;
512
+ } else if (commit.branch === branch && index < span.end) {
513
+ return `M ${cx(branchIndex)} ${halfHeight} l 0 ${options.lineHeight}`;
514
+ }
515
+
516
+ // Branch.
517
+ // TODO(burdon): Assumes can only branch to the right.
518
+ if (commit.branch !== branch && index === span.start) {
519
+ return trim`
471
520
  M ${cx(commitIndex)} ${halfHeight}
472
521
  L ${cx(branchIndex) - halfHeight} ${halfHeight}
473
522
  a ${halfHeight} ${halfHeight} 0 0 1 ${halfHeight} ${halfHeight}
474
523
  `;
475
- }
524
+ }
476
525
 
477
- // Merge.
478
- if (commit.branch !== branch && index === span.end) {
479
- return trim`
526
+ // Merge.
527
+ if (commit.branch !== branch && index === span.end) {
528
+ return trim`
480
529
  M ${cx(commitIndex)} ${halfHeight}
481
530
  L ${cx(branchIndex) - halfHeight} ${halfHeight}
482
531
  a ${halfHeight} ${halfHeight} -90 0 0 ${halfHeight} ${-halfHeight}
483
532
  `;
484
- }
485
- };
533
+ }
534
+ };
486
535
 
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
- ];
536
+ const col = getBranchIndex(commit.branch);
537
+ const color = colors[col % colors.length];
538
+ const opacity = (branch: string | undefined) => [
539
+ 'duration-500 transition-opacity',
540
+ highlightedBranch === undefined || branch === highlightedBranch ? 'opacity-100' : 'opacity-40',
541
+ ];
493
542
 
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
- }
543
+ return (
544
+ <svg width={laneCount * options.columnWidth} height={options.lineHeight}>
545
+ {/* Connectors */}
546
+ {[...spans.entries()].map(([branch, span]) => {
547
+ const lane = getBranchIndex(branch);
548
+ if (lane < 0) {
549
+ return null;
550
+ }
502
551
 
503
- const color = colors[lane % colors.length];
504
- const path = createPath(index, commit, branch, span);
505
- if (!path) {
506
- return null;
507
- }
552
+ const color = colors[lane % colors.length];
553
+ const path = createPath(index, commit, branch, span);
554
+ if (!path) {
555
+ return null;
556
+ }
508
557
 
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
- );
558
+ return (
559
+ <path
560
+ key={branch}
561
+ d={path}
562
+ fill='none'
563
+ className={mx(options.lineStyle, color.stroke, color.fill, opacity(branch))}
564
+ />
565
+ );
566
+ })}
567
+
568
+ <circle
569
+ cx={cx(col)}
570
+ cy={halfHeight}
571
+ r={options.nodeRadius}
572
+ className={mx('fill-base-surface stroke-base-surface')}
573
+ />
574
+ <circle
575
+ cx={cx(col)}
576
+ cy={halfHeight}
577
+ r={options.nodeRadius}
578
+ className={mx('fill-base-surface', options.lineStyle, color?.stroke, color?.fill, opacity(commit.branch))}
579
+ />
580
+ </svg>
581
+ );
582
+ });