@dxos/react-ui-trace 0.11.1 → 0.13.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 +3 -3
- 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 +3 -2
- 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 +5 -6
- 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 +567 -19
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +3 -2
- package/src/testing/test-trace.ts +2 -2
- 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
|
@@ -8,9 +8,11 @@ import * as Option from 'effect/Option';
|
|
|
8
8
|
import React, { useCallback, useContext, useMemo, useRef } from 'react';
|
|
9
9
|
|
|
10
10
|
import * as Process from '@dxos/compute/Process';
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import
|
|
11
|
+
import { Tree, type TreeNode, type TreeSelectEvent, createStaticTreeModel } from '@dxos/react-ui-list';
|
|
12
|
+
import * as Button from '@dxos/react-ui/Button';
|
|
13
|
+
import * as Icon from '@dxos/react-ui/Icon';
|
|
14
|
+
import * as Tooltip from '@dxos/react-ui/Tooltip';
|
|
15
|
+
import * as Util from '@dxos/react-ui/Util';
|
|
14
16
|
import { Unit } from '@dxos/util';
|
|
15
17
|
|
|
16
18
|
const DEFAULT_DEPTH = 1;
|
|
@@ -23,7 +25,7 @@ const NESTED_ACTIVE_STATES = new Set<Process.State>([
|
|
|
23
25
|
|
|
24
26
|
export type ProcessTreeProps = {
|
|
25
27
|
// TODO(burdon): Atom.
|
|
26
|
-
processes: readonly Process.
|
|
28
|
+
processes: readonly Process.Process[];
|
|
27
29
|
/**
|
|
28
30
|
* Maximum nesting depth from the root (1 = top-level processes only).
|
|
29
31
|
*
|
|
@@ -36,19 +38,19 @@ export type ProcessTreeProps = {
|
|
|
36
38
|
*
|
|
37
39
|
* Must be referentially stable — `ProcessTree` is memoized on its props.
|
|
38
40
|
*/
|
|
39
|
-
resolveLabel?: (process: Process.
|
|
41
|
+
resolveLabel?: (process: Process.Process) => string | undefined;
|
|
40
42
|
/** Pids drawn as selected; the tree is controlled, so a click reports through `onSelectedChange`. */
|
|
41
43
|
selected?: readonly string[];
|
|
42
44
|
/** The selection after a click: the clicked pid alone, or toggled among the others on a meta-click. */
|
|
43
45
|
onSelectedChange?: (selected: string[]) => void;
|
|
44
|
-
onProcessTerminate?: (process: Process.
|
|
46
|
+
onProcessTerminate?: (process: Process.Process) => void;
|
|
45
47
|
};
|
|
46
48
|
|
|
47
49
|
/** Node of the pruned process forest handed to the tree model. */
|
|
48
50
|
type ProcessNode = {
|
|
49
51
|
id: string;
|
|
50
52
|
/** Absent on the synthetic root, which anchors the top-level processes and is never rendered. */
|
|
51
|
-
process?: Process.
|
|
53
|
+
process?: Process.Process;
|
|
52
54
|
children: ProcessNode[];
|
|
53
55
|
};
|
|
54
56
|
|
|
@@ -63,7 +65,7 @@ const NO_SELECTION: readonly string[] = [];
|
|
|
63
65
|
* model and is re-seeded — a collapse must survive the next tick. Renders through `Tree`.
|
|
64
66
|
*/
|
|
65
67
|
export const ProcessTree = React.memo(
|
|
66
|
-
composable<HTMLDivElement, ProcessTreeProps>(
|
|
68
|
+
Util.composable<HTMLDivElement, ProcessTreeProps>(
|
|
67
69
|
(
|
|
68
70
|
{
|
|
69
71
|
processes,
|
|
@@ -109,7 +111,7 @@ export const ProcessTree = React.memo(
|
|
|
109
111
|
);
|
|
110
112
|
|
|
111
113
|
const handleSelect = useCallback(
|
|
112
|
-
({ item, current, meta }:
|
|
114
|
+
({ item, current, meta }: TreeSelectEvent<ProcessNode>) => {
|
|
113
115
|
if (!item.process) {
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
@@ -121,91 +123,98 @@ export const ProcessTree = React.memo(
|
|
|
121
123
|
[selected, onSelectedChange],
|
|
122
124
|
);
|
|
123
125
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
+
const renderRow = useCallback(
|
|
127
|
+
(node: TreeNode<ProcessNode>) => <ProcessRow node={node} onProcessTerminate={onProcessTerminate} />,
|
|
128
|
+
[onProcessTerminate],
|
|
129
|
+
);
|
|
126
130
|
|
|
127
131
|
return (
|
|
128
|
-
<
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</ScrollArea.Viewport>
|
|
143
|
-
</ScrollArea.Root>
|
|
132
|
+
<div {...Util.composableProps(props, { classNames: 'flex flex-col min-h-0' })} ref={forwardedRef}>
|
|
133
|
+
<Tree.Root
|
|
134
|
+
id={ROOT_ID}
|
|
135
|
+
model={model}
|
|
136
|
+
virtual='fixed'
|
|
137
|
+
size='sm'
|
|
138
|
+
selectionMode='multiple'
|
|
139
|
+
columns={COLUMNS}
|
|
140
|
+
onOpenChange={handleOpenChange}
|
|
141
|
+
onSelect={handleSelect}
|
|
142
|
+
>
|
|
143
|
+
<Tree.Content>{renderRow}</Tree.Content>
|
|
144
|
+
</Tree.Root>
|
|
145
|
+
</div>
|
|
144
146
|
);
|
|
145
147
|
},
|
|
146
148
|
),
|
|
147
149
|
);
|
|
148
150
|
|
|
149
|
-
/**
|
|
150
|
-
const
|
|
151
|
-
(): IconRenderer<ProcessNode> =>
|
|
152
|
-
({ item: { process } }) =>
|
|
153
|
-
process === undefined ? null : (
|
|
154
|
-
<Tooltip.Trigger content={process.state.toString()}>
|
|
155
|
-
<Icon
|
|
156
|
-
size={4}
|
|
157
|
-
synchronized
|
|
158
|
-
classNames={mx(
|
|
159
|
-
'shrink-0',
|
|
160
|
-
process.state === Process.State.RUNNING && 'animate-spin',
|
|
161
|
-
process.state === Process.State.FAILED && 'text-error-text',
|
|
162
|
-
process.state === Process.State.SUCCEEDED && 'text-success-text',
|
|
163
|
-
)}
|
|
164
|
-
icon={Match.value(process.state).pipe(
|
|
165
|
-
Match.when(Process.State.RUNNING, () => 'ph--spinner-gap--regular'),
|
|
166
|
-
Match.when(Process.State.SUCCEEDED, () => 'ph--check-circle--regular'),
|
|
167
|
-
Match.when(Process.State.FAILED, () => 'ph--warning--regular'),
|
|
168
|
-
Match.when(Process.State.HYBERNATING, () => 'ph--spinner--regular'),
|
|
169
|
-
Match.when(Process.State.IDLE, () => 'ph--moon-stars--regular'),
|
|
170
|
-
Match.when(Process.State.TERMINATING, () => 'ph--x-circle--regular'),
|
|
171
|
-
Match.when(Process.State.TERMINATED, () => 'ph--x-circle--regular'),
|
|
172
|
-
Match.orElse(() => 'ph--spinner-gap--regular'),
|
|
173
|
-
)}
|
|
174
|
-
/>
|
|
175
|
-
</Tooltip.Trigger>
|
|
176
|
-
);
|
|
151
|
+
/** Disclosure, status glyph, label, then the elapsed time and the terminate control. */
|
|
152
|
+
const COLUMNS = 'var(--dx-half-block-size) var(--dx-block-size) minmax(0, 1fr) min-content min-content';
|
|
177
153
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
154
|
+
type ProcessRowProps = {
|
|
155
|
+
node: TreeNode<ProcessNode>;
|
|
156
|
+
onProcessTerminate?: (process: Process.Process) => void;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** One process: its status glyph (animated, coloured and tooltipped per state), elapsed time and terminate control. */
|
|
160
|
+
const ProcessRow = ({ node, onProcessTerminate }: ProcessRowProps) => {
|
|
161
|
+
const process = node.item?.process;
|
|
162
|
+
return (
|
|
163
|
+
<Tree.Item node={node}>
|
|
164
|
+
<Tree.ItemIndicator />
|
|
165
|
+
<Tree.ItemIcon>{process && <StatusIcon process={process} />}</Tree.ItemIcon>
|
|
166
|
+
<Tree.ItemText />
|
|
167
|
+
<span className='text-end ps-1 text-xs text-fg-muted tabular-nums whitespace-nowrap'>
|
|
168
|
+
{process &&
|
|
169
|
+
[Process.State.FAILED, Process.State.SUCCEEDED].includes(process.state) &&
|
|
170
|
+
Unit.Duration(process.metrics.wallTime).toString()}
|
|
171
|
+
</span>
|
|
172
|
+
<span>
|
|
173
|
+
{process && onProcessTerminate && process.state !== Process.State.TERMINATED && (
|
|
174
|
+
<Button.Root
|
|
175
|
+
icon='ph--x--regular'
|
|
176
|
+
iconOnly
|
|
177
|
+
size='sm'
|
|
178
|
+
variant='ghost'
|
|
179
|
+
label='Actions'
|
|
180
|
+
onClick={(event) => {
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
onProcessTerminate(process);
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</span>
|
|
187
|
+
</Tree.Item>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const StatusIcon = ({ process }: { process: Process.Process }) => (
|
|
192
|
+
<Tooltip.Trigger content={process.state.toString()}>
|
|
193
|
+
<Icon.Icon
|
|
194
|
+
size='md'
|
|
195
|
+
spin={process.state === Process.State.RUNNING}
|
|
196
|
+
valence={
|
|
197
|
+
process.state === Process.State.FAILED
|
|
198
|
+
? 'error'
|
|
199
|
+
: process.state === Process.State.SUCCEEDED
|
|
200
|
+
? 'success'
|
|
201
|
+
: undefined
|
|
202
|
+
}
|
|
203
|
+
icon={Match.value(process.state).pipe(
|
|
204
|
+
Match.when(Process.State.RUNNING, () => 'ph--spinner-gap--regular'),
|
|
205
|
+
Match.when(Process.State.SUCCEEDED, () => 'ph--check-circle--regular'),
|
|
206
|
+
Match.when(Process.State.FAILED, () => 'ph--warning--regular'),
|
|
207
|
+
Match.when(Process.State.HYBERNATING, () => 'ph--spinner--regular'),
|
|
208
|
+
Match.when(Process.State.IDLE, () => 'ph--moon-stars--regular'),
|
|
209
|
+
Match.when(Process.State.TERMINATING, () => 'ph--x-circle--regular'),
|
|
210
|
+
Match.when(Process.State.TERMINATED, () => 'ph--x-circle--regular'),
|
|
211
|
+
Match.orElse(() => 'ph--spinner-gap--regular'),
|
|
212
|
+
)}
|
|
213
|
+
/>
|
|
214
|
+
</Tooltip.Trigger>
|
|
215
|
+
);
|
|
207
216
|
|
|
208
|
-
const sortProcesses = (processes: readonly Process.
|
|
217
|
+
const sortProcesses = (processes: readonly Process.Process[]): Process.Process[] => {
|
|
209
218
|
return [
|
|
210
219
|
...processes.filter((process) => [Process.State.RUNNING, Process.State.HYBERNATING].includes(process.state)),
|
|
211
220
|
...processes.filter((process) => [Process.State.IDLE].includes(process.state)).slice(0, 3),
|
|
@@ -219,7 +228,7 @@ const sortProcesses = (processes: readonly Process.Info[]): Process.Info[] => {
|
|
|
219
228
|
});
|
|
220
229
|
};
|
|
221
230
|
|
|
222
|
-
const sortNestedActive = (processes: readonly Process.
|
|
231
|
+
const sortNestedActive = (processes: readonly Process.Process[]): Process.Process[] =>
|
|
223
232
|
processes
|
|
224
233
|
.filter((process) => NESTED_ACTIVE_STATES.has(process.state))
|
|
225
234
|
.sort((left, right) => {
|
|
@@ -232,10 +241,10 @@ const sortNestedActive = (processes: readonly Process.Info[]): Process.Info[] =>
|
|
|
232
241
|
* Builds the process forest, pruned to `maxDepth` from each root. Nested levels surface only still-
|
|
233
242
|
* active processes, so a deep tree stays readable while completed work collapses out of view.
|
|
234
243
|
*/
|
|
235
|
-
const buildProcessForest = (processes: readonly Process.
|
|
244
|
+
const buildProcessForest = (processes: readonly Process.Process[], maxDepth: number): ProcessNode => {
|
|
236
245
|
const pidSet = new Set(processes.map((process) => String(process.pid)));
|
|
237
|
-
const childrenByParent = new Map<string, Process.
|
|
238
|
-
const roots: Process.
|
|
246
|
+
const childrenByParent = new Map<string, Process.Process[]>();
|
|
247
|
+
const roots: Process.Process[] = [];
|
|
239
248
|
|
|
240
249
|
for (const process of processes) {
|
|
241
250
|
const parent = process.parentPid;
|
|
@@ -249,7 +258,7 @@ const buildProcessForest = (processes: readonly Process.Info[], maxDepth: number
|
|
|
249
258
|
childrenByParent.set(key, siblings);
|
|
250
259
|
}
|
|
251
260
|
|
|
252
|
-
const visit = (process: Process.
|
|
261
|
+
const visit = (process: Process.Process, level: number): ProcessNode => {
|
|
253
262
|
const children = level >= maxDepth ? [] : sortNestedActive(childrenByParent.get(String(process.pid)) ?? []);
|
|
254
263
|
return {
|
|
255
264
|
id: String(process.pid),
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
6
|
import React, { useRef, useState } from 'react';
|
|
7
|
+
import { expect, userEvent, waitFor } from 'storybook/test';
|
|
7
8
|
|
|
8
9
|
import { LogLevel } from '@dxos/log';
|
|
9
10
|
import { random } from '@dxos/random';
|
|
10
|
-
import
|
|
11
|
-
import
|
|
11
|
+
import * as Button from '@dxos/react-ui/Button';
|
|
12
|
+
import * as Hooks from '@dxos/react-ui/Hooks';
|
|
13
|
+
import * as Panel from '@dxos/react-ui/Panel';
|
|
14
|
+
import * as ScrollArea from '@dxos/react-ui/ScrollArea';
|
|
15
|
+
import * as ScrollContainer from '@dxos/react-ui/ScrollContainer';
|
|
12
16
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
17
|
+
import * as Toolbar from '@dxos/react-ui/Toolbar';
|
|
13
18
|
|
|
19
|
+
import { defaultOptions } from './timeline-options.ts';
|
|
14
20
|
import { type Commit, Timeline, TimelineProps } from './Timeline.tsx';
|
|
15
21
|
|
|
16
22
|
random.seed(1);
|
|
@@ -130,22 +136,23 @@ const generateCommits = (n: number): Pick<TimelineProps, 'commits' | 'branches'>
|
|
|
130
136
|
return { commits, branches };
|
|
131
137
|
};
|
|
132
138
|
|
|
133
|
-
const DefaultStory = (props: TimelineProps) => {
|
|
139
|
+
const DefaultStory = (props: Omit<TimelineProps, 'scroller'>) => {
|
|
140
|
+
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
|
141
|
+
|
|
134
142
|
return (
|
|
135
143
|
<ScrollArea.Root>
|
|
136
|
-
<ScrollArea.Viewport>
|
|
137
|
-
<Timeline {...props} />
|
|
144
|
+
<ScrollArea.Viewport ref={setViewport}>
|
|
145
|
+
<Timeline {...props} scroller={viewport} />
|
|
138
146
|
</ScrollArea.Viewport>
|
|
139
147
|
</ScrollArea.Root>
|
|
140
148
|
);
|
|
141
149
|
};
|
|
142
150
|
|
|
143
151
|
const meta = {
|
|
144
|
-
title: 'ui/react-ui-
|
|
145
|
-
component: Timeline,
|
|
152
|
+
title: 'ui/react-ui-trace/Timeline',
|
|
146
153
|
render: DefaultStory,
|
|
147
|
-
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
148
|
-
} satisfies Meta<typeof
|
|
154
|
+
decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-(--dx-complementary-sidebar-size)' })],
|
|
155
|
+
} satisfies Meta<typeof DefaultStory>;
|
|
149
156
|
|
|
150
157
|
export default meta;
|
|
151
158
|
|
|
@@ -250,6 +257,86 @@ export const Compact: Story = {
|
|
|
250
257
|
args: { ...generateCommits(100), compact: true },
|
|
251
258
|
};
|
|
252
259
|
|
|
260
|
+
/** A history far longer than any viewport; only the rows in view are in the DOM. */
|
|
261
|
+
const LARGE_HISTORY_LENGTH = 5_000;
|
|
262
|
+
|
|
263
|
+
const generateLargeHistory = (): Pick<TimelineProps, 'commits' | 'branches'> => {
|
|
264
|
+
const branches = ['main', 'worker'];
|
|
265
|
+
const commits: Commit[] = [];
|
|
266
|
+
for (let index = 0; index < LARGE_HISTORY_LENGTH; index++) {
|
|
267
|
+
const branch = index % 20 < 4 ? 'worker' : 'main';
|
|
268
|
+
commits.push({
|
|
269
|
+
id: `commit-${index}`,
|
|
270
|
+
branch,
|
|
271
|
+
message: `Commit ${index}`,
|
|
272
|
+
parents: index > 0 ? [`commit-${index - 1}`] : [],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { commits, branches };
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const mountedRows = (canvasElement: HTMLElement) => canvasElement.querySelectorAll<HTMLElement>('[role="listitem"]');
|
|
280
|
+
|
|
281
|
+
export const Large: Story = {
|
|
282
|
+
args: { ...generateLargeHistory(), showTimestamp: true },
|
|
283
|
+
play: async ({ canvasElement }) => {
|
|
284
|
+
await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
|
|
285
|
+
|
|
286
|
+
const rows = mountedRows(canvasElement);
|
|
287
|
+
// Windowed: a viewport's worth of rows plus the overscan, never the whole history.
|
|
288
|
+
await expect(rows.length).toBeLessThan(LARGE_HISTORY_LENGTH / 10);
|
|
289
|
+
// Rows are exactly `lineHeight` tall, which is what makes the declared extent exact.
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
await expect(row.getBoundingClientRect().height).toBe(defaultOptions.lineHeight);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Windowing must not cost the graph: a row deep in the history still draws every lane crossing it,
|
|
298
|
+
* because the spans it reads are computed over the whole history, not over the mounted rows.
|
|
299
|
+
*/
|
|
300
|
+
export const Branching: Story = {
|
|
301
|
+
args: generateLargeHistory(),
|
|
302
|
+
play: async ({ canvasElement }) => {
|
|
303
|
+
await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
|
|
304
|
+
|
|
305
|
+
// Jump to the end, so every mounted row was windowed in rather than rendered at mount.
|
|
306
|
+
const timeline = canvasElement.querySelector<HTMLElement>('[tabindex="0"]');
|
|
307
|
+
timeline?.focus();
|
|
308
|
+
await userEvent.keyboard('{Meta>}{ArrowDown}{/Meta}');
|
|
309
|
+
await waitFor(() =>
|
|
310
|
+
expect(canvasElement.querySelector(`[data-commit-index="${LARGE_HISTORY_LENGTH - 1}"]`)).not.toBeNull(),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const rows = [...mountedRows(canvasElement)];
|
|
314
|
+
// Each row carries its own node, and a connector for every lane that runs through it.
|
|
315
|
+
const nodesPerRow = rows.map((row) => row.querySelectorAll('svg circle').length);
|
|
316
|
+
await expect(Math.min(...nodesPerRow)).toBeGreaterThan(0);
|
|
317
|
+
// Somewhere in the window a second lane is open, so its through-line is drawn beside the node.
|
|
318
|
+
const connectorsPerRow = rows.map((row) => row.querySelectorAll('svg path').length);
|
|
319
|
+
await expect(Math.max(...connectorsPerRow)).toBeGreaterThan(1);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const Keyboard: Story = {
|
|
324
|
+
args: generateLargeHistory(),
|
|
325
|
+
play: async ({ canvasElement }) => {
|
|
326
|
+
await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
|
|
327
|
+
|
|
328
|
+
// The last row is thousands of rows down, so it is only reachable if the move scrolls to it.
|
|
329
|
+
const timeline = canvasElement.querySelector<HTMLElement>('[tabindex="0"]');
|
|
330
|
+
await expect(timeline).not.toBeNull();
|
|
331
|
+
timeline?.focus();
|
|
332
|
+
await userEvent.keyboard('{Meta>}{ArrowDown}{/Meta}');
|
|
333
|
+
|
|
334
|
+
const lastRow = () => canvasElement.querySelector(`[data-commit-index="${LARGE_HISTORY_LENGTH - 1}"]`);
|
|
335
|
+
await waitFor(() => expect(lastRow()).not.toBeNull());
|
|
336
|
+
await waitFor(() => expect(lastRow()?.getAttribute('aria-current')).toBe('true'));
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
253
340
|
export const Streaming: Story = {
|
|
254
341
|
render: () => {
|
|
255
342
|
const [branches, setBranches] = useState<string[]>(['main']);
|
|
@@ -265,7 +352,7 @@ export const Streaming: Story = {
|
|
|
265
352
|
const closedBranches = useRef<Set<string>>(new Set());
|
|
266
353
|
|
|
267
354
|
const [running, setRunning] = useState(true);
|
|
268
|
-
useInterval(
|
|
355
|
+
Hooks.useInterval(
|
|
269
356
|
() => {
|
|
270
357
|
if (!running) {
|
|
271
358
|
return;
|
|
@@ -294,28 +381,29 @@ export const Streaming: Story = {
|
|
|
294
381
|
[running],
|
|
295
382
|
);
|
|
296
383
|
|
|
297
|
-
const scrollerRef = useRef<ScrollController>(null);
|
|
384
|
+
const scrollerRef = useRef<ScrollContainer.ScrollController>(null);
|
|
385
|
+
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
|
298
386
|
|
|
299
387
|
return (
|
|
300
388
|
<Panel.Root>
|
|
301
|
-
<Panel.
|
|
389
|
+
<Panel.Header>
|
|
302
390
|
<Toolbar.Root>
|
|
303
|
-
<Button onClick={() => setRunning(true)}>Start</Button>
|
|
304
|
-
<Button onClick={() => setRunning(false)}>Stop</Button>
|
|
305
|
-
<Button onClick={() => scrollerRef.current?.scrollToTop()}>Top</Button>
|
|
306
|
-
<Button onClick={() => scrollerRef.current?.scrollToBottom()}>Bottom</Button>
|
|
391
|
+
<Button.Root onClick={() => setRunning(true)}>Start</Button.Root>
|
|
392
|
+
<Button.Root onClick={() => setRunning(false)}>Stop</Button.Root>
|
|
393
|
+
<Button.Root onClick={() => scrollerRef.current?.scrollToTop()}>Top</Button.Root>
|
|
394
|
+
<Button.Root onClick={() => scrollerRef.current?.scrollToBottom()}>Bottom</Button.Root>
|
|
307
395
|
</Toolbar.Root>
|
|
308
|
-
</Panel.
|
|
309
|
-
<Panel.
|
|
396
|
+
</Panel.Header>
|
|
397
|
+
<Panel.Body>
|
|
310
398
|
<ScrollContainer.Root pin ref={scrollerRef}>
|
|
311
|
-
<ScrollContainer.Content thin>
|
|
312
|
-
<ScrollContainer.Viewport>
|
|
313
|
-
<Timeline branches={branches} commits={commits} showTimestamp />
|
|
399
|
+
<ScrollContainer.Content width='thin'>
|
|
400
|
+
<ScrollContainer.Viewport ref={setViewport}>
|
|
401
|
+
<Timeline branches={branches} commits={commits} showTimestamp scroller={viewport} />
|
|
314
402
|
</ScrollContainer.Viewport>
|
|
315
403
|
<ScrollContainer.ScrollDownButton />
|
|
316
404
|
</ScrollContainer.Content>
|
|
317
405
|
</ScrollContainer.Root>
|
|
318
|
-
</Panel.
|
|
406
|
+
</Panel.Body>
|
|
319
407
|
</Panel.Root>
|
|
320
408
|
);
|
|
321
409
|
},
|