@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.
- package/README.md +1 -1
- package/dist/lib/index.mjs +1722 -561
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +9 -8
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/lib/translations.mjs +5 -0
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
- package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
- package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
- package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
- package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +1 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -1
- package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
- package/dist/types/src/session-timeline/types.d.ts +32 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -1
- package/dist/types/src/testing/simulated-agent.d.ts +5 -4
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
- package/dist/types/src/testing/test-trace.d.ts +2 -2
- package/dist/types/src/testing/test-trace.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -26
- package/src/components/Gantt/Gantt.stories.tsx +914 -96
- package/src/components/Gantt/Gantt.tsx +964 -261
- package/src/components/Gantt/gantt-rows.test.ts +123 -0
- package/src/components/Gantt/gantt-rows.ts +71 -0
- package/src/components/Gantt/gantt-scale.test.ts +105 -0
- package/src/components/Gantt/gantt-scale.ts +143 -0
- package/src/components/Gantt/index.ts +2 -0
- package/src/components/Gantt/useEnter.ts +33 -0
- package/src/components/Gantt/useRangeTween.ts +61 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
- package/src/components/ProcessTree/ProcessTree.tsx +99 -90
- package/src/components/Timeline/Timeline.stories.tsx +110 -22
- package/src/components/Timeline/Timeline.tsx +319 -271
- package/src/components/Timeline/timeline-layout.test.ts +177 -0
- package/src/components/Timeline/timeline-layout.ts +153 -0
- package/src/components/TracePanel/SpanTreeView.tsx +39 -0
- package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
- package/src/components/TracePanel/TracePanel.tsx +113 -106
- package/src/components/TracePanel/trace-filter.test.ts +31 -2
- package/src/components/TracePanel/trace-filter.ts +28 -3
- package/src/components/TracePanel/useTraceMenu.ts +0 -1
- package/src/execution-graph/SPEC.md +10 -10
- package/src/execution-graph/execution-graph.test.ts +16 -15
- package/src/execution-graph/execution-graph.ts +108 -60
- package/src/execution-graph/pending-block-status.ts +1 -1
- package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
- package/src/hooks/useExecutionGraph.ts +14 -11
- package/src/hooks/useSessionTimeline.ts +35 -9
- package/src/hooks/useTraceMessages.ts +1 -1
- package/src/session-timeline/TaskHistory.stories.tsx +142 -0
- package/src/session-timeline/gantt-mapping.test.ts +118 -0
- package/src/session-timeline/gantt-mapping.ts +158 -0
- package/src/session-timeline/index.ts +1 -0
- package/src/session-timeline/session-timeline.test.ts +585 -35
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +17 -16
- package/src/testing/test-trace.ts +14 -12
- package/src/translations.ts +8 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
- 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
|
-
|
|
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: '
|
|
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
|
-
{
|
|
309
|
-
<p className='
|
|
245
|
+
{layout.rows.length < 1 ? (
|
|
246
|
+
<p className='text-fg-muted p-trim-md'>{t('no-commits.message')}</p>
|
|
310
247
|
) : (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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=
|
|
392
|
-
|
|
393
|
-
classNames={
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
});
|