@workflow/web-shared 5.0.0-beta.25 → 5.0.0-beta.27
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/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
- package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
- package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +5 -2
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
- package/dist/components/new-trace-viewer/context.d.ts +2 -0
- package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/context.js +3 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +10 -90
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +38 -35
- package/dist/components/sidebar/attributes-block.d.ts +18 -7
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +76 -18
- package/dist/components/sidebar/events-list.d.ts +1 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +9 -10
- package/dist/components/trace-viewer/components/map.d.ts +2 -2
- package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/node.d.ts +7 -7
- package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
- package/dist/components/ui/collapsible.d.ts +46 -0
- package/dist/components/ui/collapsible.d.ts.map +1 -0
- package/dist/components/ui/collapsible.js +66 -0
- package/dist/components/ui/data-inspector.js +2 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/hydration.d.ts +12 -2
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +41 -18
- package/package.json +6 -6
- package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
- package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
- package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
- package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
- package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
- package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
- package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +4 -1
- package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
- package/src/components/new-trace-viewer/context.tsx +4 -1
- package/src/components/new-trace-viewer/trace-viewer.tsx +18 -199
- package/src/components/sidebar/attribute-panel.tsx +106 -105
- package/src/components/sidebar/attributes-block.tsx +170 -50
- package/src/components/sidebar/events-list.tsx +107 -92
- package/src/components/ui/collapsible.tsx +219 -0
- package/src/index.ts +8 -7
- package/src/lib/hydration.ts +48 -25
- package/dist/components/sidebar/detail-card.d.ts +0 -13
- package/dist/components/sidebar/detail-card.d.ts.map +0 -1
- package/dist/components/sidebar/detail-card.js +0 -36
- package/src/components/sidebar/detail-card.tsx +0 -143
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
|
|
4
|
+
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type RefObject,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { filterSpanRawEvents } from '../../../lib/trace-builder';
|
|
15
|
+
import { ErrorBoundary } from '../../error-boundary';
|
|
16
|
+
import {
|
|
17
|
+
EntityDetailPanel,
|
|
18
|
+
type SelectedSpanInfo,
|
|
19
|
+
} from '../../sidebar/entity-detail-panel';
|
|
20
|
+
import { useSidebarData } from '../../sidebar/sidebar-data-context';
|
|
21
|
+
import { IconButton } from '../../ui/icon-button';
|
|
22
|
+
import { Kbd } from '../../ui/kbd';
|
|
23
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
|
24
|
+
import { useActiveSpan } from '../context';
|
|
25
|
+
import {
|
|
26
|
+
clampPanelWidth,
|
|
27
|
+
computeMaxPanelWidth,
|
|
28
|
+
PANEL_DEFAULT_WIDTH,
|
|
29
|
+
PANEL_MIN_WIDTH,
|
|
30
|
+
readStoredPanelWidth,
|
|
31
|
+
writeStoredPanelWidth,
|
|
32
|
+
} from './detail-panel-width';
|
|
33
|
+
import { DraggableBorder } from './draggable-border';
|
|
34
|
+
import { useElementWidth } from './use-element-width';
|
|
35
|
+
|
|
36
|
+
const DETAIL_PANEL_ID = 'trace-detail-panel';
|
|
37
|
+
|
|
38
|
+
/** Bridge ActiveSpanContext + SidebarDataContext → SelectedSpanInfo. */
|
|
39
|
+
function useSelectedSpanInfo(): SelectedSpanInfo | null {
|
|
40
|
+
const { activeSpan } = useActiveSpan();
|
|
41
|
+
const sidebar = useSidebarData();
|
|
42
|
+
|
|
43
|
+
return useMemo(() => {
|
|
44
|
+
if (!activeSpan) return null;
|
|
45
|
+
|
|
46
|
+
const resource = activeSpan.attributes?.resource as string | undefined;
|
|
47
|
+
const rawEvents = filterSpanRawEvents(
|
|
48
|
+
sidebar.events,
|
|
49
|
+
resource,
|
|
50
|
+
activeSpan.spanId
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
data: activeSpan.attributes?.data,
|
|
55
|
+
resource,
|
|
56
|
+
spanId: activeSpan.spanId,
|
|
57
|
+
rawEvents,
|
|
58
|
+
};
|
|
59
|
+
}, [activeSpan, sidebar]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The span detail aside: content, prev/next/close header, J/K navigation, and
|
|
64
|
+
* the resizable left border with its width model (see detail-panel-width.ts).
|
|
65
|
+
*
|
|
66
|
+
* Always mounted — it renders null without a selection — so the panel width
|
|
67
|
+
* survives closing and reopening within a session.
|
|
68
|
+
*/
|
|
69
|
+
export function TraceDetailPanel({
|
|
70
|
+
containerRef,
|
|
71
|
+
onNavigateToSpan,
|
|
72
|
+
onClose,
|
|
73
|
+
}: {
|
|
74
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
75
|
+
onNavigateToSpan: (spanId: string) => void;
|
|
76
|
+
onClose: () => void;
|
|
77
|
+
}): ReactNode {
|
|
78
|
+
const { activeSpan, activeSpanId, spans } = useActiveSpan();
|
|
79
|
+
const sidebar = useSidebarData();
|
|
80
|
+
const selectedSpan = useSelectedSpanInfo();
|
|
81
|
+
const isOpen = Boolean(activeSpan);
|
|
82
|
+
|
|
83
|
+
const [storedWidth, setStoredWidth] = useState<number>(() =>
|
|
84
|
+
readStoredPanelWidth()
|
|
85
|
+
);
|
|
86
|
+
const containerWidth = useElementWidth(containerRef, isOpen);
|
|
87
|
+
const asideRef = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
|
|
89
|
+
const handleResize = useCallback(
|
|
90
|
+
(next: number) => {
|
|
91
|
+
const clamped = clampPanelWidth(next, containerWidth);
|
|
92
|
+
setStoredWidth(clamped);
|
|
93
|
+
writeStoredPanelWidth(clamped);
|
|
94
|
+
},
|
|
95
|
+
[containerWidth]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const panelWidth = clampPanelWidth(storedWidth, containerWidth);
|
|
99
|
+
const panelMaxWidth = Math.max(
|
|
100
|
+
PANEL_MIN_WIDTH,
|
|
101
|
+
computeMaxPanelWidth(containerWidth)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const { prevSpanId, nextSpanId } = useMemo(() => {
|
|
105
|
+
if (!activeSpanId) return { prevSpanId: null, nextSpanId: null };
|
|
106
|
+
const i = spans.findIndex((s) => s.spanId === activeSpanId);
|
|
107
|
+
if (i === -1) return { prevSpanId: null, nextSpanId: null };
|
|
108
|
+
return {
|
|
109
|
+
prevSpanId: spans[i - 1]?.spanId ?? null,
|
|
110
|
+
nextSpanId: spans[i + 1]?.spanId ?? null,
|
|
111
|
+
};
|
|
112
|
+
}, [activeSpanId, spans]);
|
|
113
|
+
|
|
114
|
+
const handleSelectPrev = useCallback(() => {
|
|
115
|
+
if (prevSpanId) onNavigateToSpan(prevSpanId);
|
|
116
|
+
}, [prevSpanId, onNavigateToSpan]);
|
|
117
|
+
|
|
118
|
+
const handleSelectNext = useCallback(() => {
|
|
119
|
+
if (nextSpanId) onNavigateToSpan(nextSpanId);
|
|
120
|
+
}, [nextSpanId, onNavigateToSpan]);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!isOpen) return;
|
|
124
|
+
|
|
125
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
126
|
+
if (
|
|
127
|
+
e.key !== 'j' &&
|
|
128
|
+
e.key !== 'k' &&
|
|
129
|
+
e.key !== 'ArrowDown' &&
|
|
130
|
+
e.key !== 'ArrowUp'
|
|
131
|
+
) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const target = e.target as HTMLElement | null;
|
|
135
|
+
if (
|
|
136
|
+
target instanceof HTMLInputElement ||
|
|
137
|
+
target instanceof HTMLTextAreaElement ||
|
|
138
|
+
target?.isContentEditable
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const targetId =
|
|
143
|
+
e.key === 'k' || e.key === 'ArrowUp' ? prevSpanId : nextSpanId;
|
|
144
|
+
if (targetId) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
onNavigateToSpan(targetId);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
window.addEventListener('keydown', onKeyDown);
|
|
151
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
152
|
+
}, [isOpen, prevSpanId, nextSpanId, onNavigateToSpan]);
|
|
153
|
+
|
|
154
|
+
// Derive the selected span name for the panel header
|
|
155
|
+
const selectedSpanName = useMemo(() => {
|
|
156
|
+
if (!selectedSpan?.data) return 'Details';
|
|
157
|
+
const data = selectedSpan.data as Record<string, unknown>;
|
|
158
|
+
if (selectedSpan.resource === 'hook') {
|
|
159
|
+
return (data.token as string | undefined) ?? (data.hookId as string);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stepName = data.stepName as string | undefined;
|
|
163
|
+
const workflowName = data.workflowName as string | undefined;
|
|
164
|
+
return (
|
|
165
|
+
(stepName ? parseStepName(stepName)?.shortName : undefined) ??
|
|
166
|
+
(workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ??
|
|
167
|
+
stepName ??
|
|
168
|
+
workflowName ??
|
|
169
|
+
(data.hookId as string) ??
|
|
170
|
+
'Details'
|
|
171
|
+
);
|
|
172
|
+
}, [selectedSpan?.data, selectedSpan?.resource]);
|
|
173
|
+
|
|
174
|
+
if (!activeSpan) return null;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<aside
|
|
178
|
+
ref={asideRef}
|
|
179
|
+
id={DETAIL_PANEL_ID}
|
|
180
|
+
className="relative flex flex-col h-full max-h-full shrink-0 bg-background-100 border-l border-gray-alpha-400"
|
|
181
|
+
style={{ width: panelWidth }}
|
|
182
|
+
>
|
|
183
|
+
<DraggableBorder
|
|
184
|
+
element={asideRef}
|
|
185
|
+
position="left"
|
|
186
|
+
onWidthChange={handleResize}
|
|
187
|
+
onReset={() => handleResize(PANEL_DEFAULT_WIDTH)}
|
|
188
|
+
aria-label="Resize span details panel"
|
|
189
|
+
aria-controls={DETAIL_PANEL_ID}
|
|
190
|
+
aria-valuemin={PANEL_MIN_WIDTH}
|
|
191
|
+
aria-valuemax={panelMaxWidth}
|
|
192
|
+
aria-valuenow={Math.min(
|
|
193
|
+
Math.max(Math.round(panelWidth), PANEL_MIN_WIDTH),
|
|
194
|
+
panelMaxWidth
|
|
195
|
+
)}
|
|
196
|
+
/>
|
|
197
|
+
{/* Panel header */}
|
|
198
|
+
<div className="flex items-center justify-between gap-2 shrink-0 px-4 py-[7.5px]">
|
|
199
|
+
<span className="text-label-14 font-medium text-gray-1000 truncate block">
|
|
200
|
+
{selectedSpanName}
|
|
201
|
+
</span>
|
|
202
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
203
|
+
<Tooltip>
|
|
204
|
+
<TooltipTrigger asChild>
|
|
205
|
+
<IconButton
|
|
206
|
+
aria-label="Navigate up"
|
|
207
|
+
aria-keyshortcuts="K"
|
|
208
|
+
onClick={handleSelectPrev}
|
|
209
|
+
disabled={!prevSpanId}
|
|
210
|
+
>
|
|
211
|
+
<ChevronUp className="w-4 h-4" />
|
|
212
|
+
</IconButton>
|
|
213
|
+
</TooltipTrigger>
|
|
214
|
+
{prevSpanId ? (
|
|
215
|
+
<TooltipContent>
|
|
216
|
+
Navigate up
|
|
217
|
+
<Kbd>K</Kbd>
|
|
218
|
+
</TooltipContent>
|
|
219
|
+
) : null}
|
|
220
|
+
</Tooltip>
|
|
221
|
+
<Tooltip>
|
|
222
|
+
<TooltipTrigger asChild>
|
|
223
|
+
<IconButton
|
|
224
|
+
aria-label="Navigate down"
|
|
225
|
+
aria-keyshortcuts="J"
|
|
226
|
+
onClick={handleSelectNext}
|
|
227
|
+
disabled={!nextSpanId}
|
|
228
|
+
>
|
|
229
|
+
<ChevronDown className="w-4 h-4" />
|
|
230
|
+
</IconButton>
|
|
231
|
+
</TooltipTrigger>
|
|
232
|
+
{nextSpanId ? (
|
|
233
|
+
<TooltipContent>
|
|
234
|
+
Navigate down
|
|
235
|
+
<Kbd>J</Kbd>
|
|
236
|
+
</TooltipContent>
|
|
237
|
+
) : null}
|
|
238
|
+
</Tooltip>
|
|
239
|
+
<div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
|
|
240
|
+
<IconButton
|
|
241
|
+
aria-label="Close span details"
|
|
242
|
+
aria-keyshortcuts="Escape"
|
|
243
|
+
onClick={onClose}
|
|
244
|
+
>
|
|
245
|
+
<X className="w-4 h-4" />
|
|
246
|
+
</IconButton>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
{/* Panel body */}
|
|
250
|
+
<div className="flex-1 overflow-y-auto">
|
|
251
|
+
<ErrorBoundary>
|
|
252
|
+
<EntityDetailPanel
|
|
253
|
+
run={sidebar.run}
|
|
254
|
+
onStreamClick={sidebar.onStreamClick}
|
|
255
|
+
onRunClick={sidebar.onRunClick}
|
|
256
|
+
fetchSpanDetail={sidebar.fetchSpanDetail}
|
|
257
|
+
onWakeUpSleep={sidebar.onWakeUpSleep}
|
|
258
|
+
onLoadEventData={sidebar.onLoadEventData}
|
|
259
|
+
onResolveHook={sidebar.onResolveHook}
|
|
260
|
+
encryptionKey={sidebar.encryptionKey}
|
|
261
|
+
onDecrypt={sidebar.onDecrypt}
|
|
262
|
+
isDecrypting={sidebar.isDecrypting}
|
|
263
|
+
selectedSpan={selectedSpan}
|
|
264
|
+
showSeparateEventOccurrenceTimestamps={
|
|
265
|
+
sidebar.showSeparateEventOccurrenceTimestamps
|
|
266
|
+
}
|
|
267
|
+
/>
|
|
268
|
+
</ErrorBoundary>
|
|
269
|
+
</div>
|
|
270
|
+
</aside>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
5
|
+
type PointerEvent as ReactPointerEvent,
|
|
6
|
+
type RefObject,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { cn } from '../../../lib/cn';
|
|
12
|
+
|
|
13
|
+
interface DraggableBorderProps {
|
|
14
|
+
/** The panel whose width this border adjusts (measured on interaction). */
|
|
15
|
+
element: RefObject<HTMLElement | null>;
|
|
16
|
+
/** Which edge of the panel the border sits on. */
|
|
17
|
+
position: 'left' | 'right';
|
|
18
|
+
/**
|
|
19
|
+
* Called with the next width (px) while dragging or keyboard-resizing.
|
|
20
|
+
* The owner is responsible for clamping.
|
|
21
|
+
*/
|
|
22
|
+
onWidthChange: (width: number) => void;
|
|
23
|
+
/** Double-click reset. */
|
|
24
|
+
onReset?: () => void;
|
|
25
|
+
'aria-label': string;
|
|
26
|
+
'aria-controls'?: string;
|
|
27
|
+
'aria-valuemin'?: number;
|
|
28
|
+
'aria-valuemax'?: number;
|
|
29
|
+
'aria-valuenow'?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Overlay drag handle straddling a panel edge: an invisible strip over the
|
|
34
|
+
* panel's border whose center line highlights on hover/drag, with
|
|
35
|
+
* double-click reset, pointer-capture dragging (works with touch/pen, no
|
|
36
|
+
* ghost image), and keyboard/ARIA window-splitter support.
|
|
37
|
+
*
|
|
38
|
+
* The panel's positioning ancestor must not clip overflow — the strip hangs
|
|
39
|
+
* ~8px past the panel edge.
|
|
40
|
+
*/
|
|
41
|
+
export function DraggableBorder({
|
|
42
|
+
element,
|
|
43
|
+
position,
|
|
44
|
+
onWidthChange,
|
|
45
|
+
onReset,
|
|
46
|
+
'aria-label': ariaLabel,
|
|
47
|
+
'aria-controls': ariaControls,
|
|
48
|
+
'aria-valuemin': ariaValueMin,
|
|
49
|
+
'aria-valuemax': ariaValueMax,
|
|
50
|
+
'aria-valuenow': ariaValueNow,
|
|
51
|
+
}: DraggableBorderProps) {
|
|
52
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
53
|
+
const pointerIdRef = useRef<number | null>(null);
|
|
54
|
+
const startXRef = useRef(0);
|
|
55
|
+
const startWidthRef = useRef(0);
|
|
56
|
+
const pendingWidthRef = useRef(0);
|
|
57
|
+
const rafRef = useRef<number | null>(null);
|
|
58
|
+
const movedRef = useRef(false);
|
|
59
|
+
const lastDragEndAtRef = useRef(0);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
return () => {
|
|
63
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// Suppress text selection for the duration of a drag. Effect cleanup also
|
|
68
|
+
// covers the handle unmounting mid-drag (e.g. Escape closing the panel).
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!isDragging) return;
|
|
71
|
+
const previous = document.body.style.userSelect;
|
|
72
|
+
document.body.style.userSelect = 'none';
|
|
73
|
+
return () => {
|
|
74
|
+
document.body.style.userSelect = previous;
|
|
75
|
+
};
|
|
76
|
+
}, [isDragging]);
|
|
77
|
+
|
|
78
|
+
const stopDrag = () => {
|
|
79
|
+
pointerIdRef.current = null;
|
|
80
|
+
setIsDragging(false);
|
|
81
|
+
if (rafRef.current) {
|
|
82
|
+
cancelAnimationFrame(rafRef.current);
|
|
83
|
+
rafRef.current = null;
|
|
84
|
+
// Flush the final position that was still waiting on the next frame.
|
|
85
|
+
if (movedRef.current) onWidthChange(pendingWidthRef.current);
|
|
86
|
+
}
|
|
87
|
+
if (movedRef.current) {
|
|
88
|
+
movedRef.current = false;
|
|
89
|
+
lastDragEndAtRef.current = performance.now();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
94
|
+
// Ignore secondary pointers (e.g. a second finger) while a drag is active
|
|
95
|
+
// so they can't re-baseline the gesture.
|
|
96
|
+
if (pointerIdRef.current !== null) return;
|
|
97
|
+
const el = element.current;
|
|
98
|
+
if (!el) return;
|
|
99
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
100
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
101
|
+
pointerIdRef.current = e.pointerId;
|
|
102
|
+
startXRef.current = e.clientX;
|
|
103
|
+
startWidthRef.current = el.offsetWidth;
|
|
104
|
+
movedRef.current = false;
|
|
105
|
+
setIsDragging(true);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Pointer capture routes move/up events to the strip itself, so no
|
|
109
|
+
// document-level listeners are needed.
|
|
110
|
+
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
111
|
+
if (pointerIdRef.current !== e.pointerId) return;
|
|
112
|
+
if (Math.abs(e.clientX - startXRef.current) > 4) {
|
|
113
|
+
movedRef.current = true;
|
|
114
|
+
}
|
|
115
|
+
const delta =
|
|
116
|
+
position === 'left'
|
|
117
|
+
? startXRef.current - e.clientX
|
|
118
|
+
: e.clientX - startXRef.current;
|
|
119
|
+
pendingWidthRef.current = startWidthRef.current + delta;
|
|
120
|
+
if (!rafRef.current) {
|
|
121
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
122
|
+
rafRef.current = null;
|
|
123
|
+
onWidthChange(pendingWidthRef.current);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handlePointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
129
|
+
if (pointerIdRef.current !== e.pointerId) return;
|
|
130
|
+
stopDrag();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleLostPointerCapture = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
134
|
+
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
stopDrag();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleDoubleClick = () => {
|
|
141
|
+
// Pointer-capture drags still emit click/dblclick (unlike HTML5 drag), so
|
|
142
|
+
// a dblclick during or right after a real drag is two resize nudges, not
|
|
143
|
+
// a reset request.
|
|
144
|
+
if (
|
|
145
|
+
movedRef.current ||
|
|
146
|
+
performance.now() - lastDragEndAtRef.current < 500
|
|
147
|
+
) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
onReset?.();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
154
|
+
const el = element.current;
|
|
155
|
+
if (!el) return;
|
|
156
|
+
const step = e.shiftKey ? 64 : 16;
|
|
157
|
+
// Arrows move the border in screen direction: for a left-edge border,
|
|
158
|
+
// ArrowLeft grows the panel.
|
|
159
|
+
const grow = position === 'left' ? 1 : -1;
|
|
160
|
+
let next: number | null = null;
|
|
161
|
+
if (e.key === 'ArrowLeft') {
|
|
162
|
+
next = el.offsetWidth + grow * step;
|
|
163
|
+
} else if (e.key === 'ArrowRight') {
|
|
164
|
+
next = el.offsetWidth - grow * step;
|
|
165
|
+
} else if (e.key === 'Home' && ariaValueMin !== undefined) {
|
|
166
|
+
next = ariaValueMin;
|
|
167
|
+
} else if (e.key === 'End' && ariaValueMax !== undefined) {
|
|
168
|
+
next = ariaValueMax;
|
|
169
|
+
}
|
|
170
|
+
if (next === null) return;
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
onWidthChange(next);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
// biome-ignore lint/a11y/useSemanticElements: the WAI-ARIA window-splitter pattern requires a focusable div with role=separator; <hr> cannot receive focus or drag interactions
|
|
177
|
+
<div
|
|
178
|
+
role="separator"
|
|
179
|
+
aria-orientation="vertical"
|
|
180
|
+
aria-label={ariaLabel}
|
|
181
|
+
aria-controls={ariaControls}
|
|
182
|
+
aria-valuemin={ariaValueMin}
|
|
183
|
+
aria-valuemax={ariaValueMax}
|
|
184
|
+
aria-valuenow={ariaValueNow}
|
|
185
|
+
tabIndex={0}
|
|
186
|
+
className={cn(
|
|
187
|
+
'group absolute inset-y-0 z-10 w-[17px] cursor-col-resize touch-none outline-none',
|
|
188
|
+
position === 'left' ? '-left-2' : '-right-2'
|
|
189
|
+
)}
|
|
190
|
+
onPointerDown={handlePointerDown}
|
|
191
|
+
onPointerMove={handlePointerMove}
|
|
192
|
+
onPointerUp={handlePointerEnd}
|
|
193
|
+
onPointerCancel={handlePointerEnd}
|
|
194
|
+
onLostPointerCapture={handleLostPointerCapture}
|
|
195
|
+
onDoubleClick={handleDoubleClick}
|
|
196
|
+
onKeyDown={handleKeyDown}
|
|
197
|
+
>
|
|
198
|
+
{/* Center line: sits exactly over the panel's 1px border and highlights
|
|
199
|
+
on hover/drag/focus (delayed so incidental mouse-overs don't flash).
|
|
200
|
+
pointer-events-none is load-bearing: without it this 1px line is a
|
|
201
|
+
hit target, so a double-click aimed at the line lands one click on
|
|
202
|
+
the line and (after sub-pixel jitter) the other on the strip. A
|
|
203
|
+
native dblclick only fires when both clicks share a target, so the
|
|
204
|
+
reset silently fails on the line but works just beside it. Keeping
|
|
205
|
+
the line inert makes the separator div the sole target everywhere. */}
|
|
206
|
+
<span
|
|
207
|
+
aria-hidden
|
|
208
|
+
className={cn(
|
|
209
|
+
'pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors delay-75 duration-100',
|
|
210
|
+
'group-hover:bg-gray-500 group-focus-visible:bg-[var(--ds-focus-color)]',
|
|
211
|
+
isDragging && 'bg-gray-500'
|
|
212
|
+
)}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout constants shared by SplitPane and TraceViewerSkeleton. Kept in a
|
|
3
|
+
* module without 'use client' so the skeleton's import chain stays
|
|
4
|
+
* server-component-safe for external consumers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Width (px) of the draggable gutter column between the list and timeline. */
|
|
8
|
+
export const GUTTER_PX = 1;
|
|
9
|
+
|
|
10
|
+
/** Minimum width (px) of either SplitPane pane. */
|
|
11
|
+
export const MIN_PX = 50;
|
|
12
|
+
|
|
13
|
+
/** Default width (px) of the start (event list) pane. */
|
|
14
|
+
export const DEFAULT_START_PX = 340;
|
|
15
|
+
|
|
16
|
+
/** Grid column template for the [list | gutter | timeline] split. */
|
|
17
|
+
export function paneColTemplate(startPx: number): string {
|
|
18
|
+
return `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
|
|
19
|
+
}
|
|
@@ -11,20 +11,17 @@ import {
|
|
|
11
11
|
useState,
|
|
12
12
|
} from 'react';
|
|
13
13
|
import { cn } from '../../../lib/cn';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_START_PX,
|
|
16
|
+
GUTTER_PX,
|
|
17
|
+
MIN_PX,
|
|
18
|
+
paneColTemplate,
|
|
19
|
+
} from './pane-constants';
|
|
20
|
+
import { useElementWidth } from './use-element-width';
|
|
22
21
|
|
|
23
22
|
export interface SplitPaneProps {
|
|
24
23
|
children: ReactNode;
|
|
25
24
|
className?: string;
|
|
26
|
-
/** Fixed pixel width for the start (left) pane. Default 220. */
|
|
27
|
-
defaultStartWidth?: number;
|
|
28
25
|
/** Fixed (non-scrolling) header rendered above the start pane. */
|
|
29
26
|
startHeader?: ReactNode;
|
|
30
27
|
/** Fixed (non-scrolling) header rendered above the end pane. */
|
|
@@ -35,7 +32,6 @@ export interface SplitPaneProps {
|
|
|
35
32
|
export function SplitPane({
|
|
36
33
|
children,
|
|
37
34
|
className,
|
|
38
|
-
defaultStartWidth = DEFAULT_START_PX,
|
|
39
35
|
startHeader,
|
|
40
36
|
endHeader,
|
|
41
37
|
scrollContainerRef,
|
|
@@ -46,13 +42,18 @@ export function SplitPane({
|
|
|
46
42
|
}
|
|
47
43
|
const [start, end] = parts;
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
// `startPx` is the user's preferred width; the rendered width is derived by
|
|
46
|
+
// clamping against the live container width (same model as the detail
|
|
47
|
+
// panel), so shrinking the container compresses the pane without destroying
|
|
48
|
+
// the preference, and re-growing restores it.
|
|
49
|
+
const [startPx, setStartPx] = useState(DEFAULT_START_PX);
|
|
50
50
|
const [isDragging, setIsDragging] = useState(false);
|
|
51
51
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
52
52
|
const gutterRef = useRef<HTMLDivElement>(null);
|
|
53
53
|
const rafRef = useRef<number | null>(null);
|
|
54
|
-
const pendingPx = useRef(
|
|
54
|
+
const pendingPx = useRef(DEFAULT_START_PX);
|
|
55
55
|
const pointerIdRef = useRef<number | null>(null);
|
|
56
|
+
const containerWidth = useElementWidth(containerRef);
|
|
56
57
|
|
|
57
58
|
useEffect(() => {
|
|
58
59
|
return () => {
|
|
@@ -134,8 +135,14 @@ export function SplitPane({
|
|
|
134
135
|
}
|
|
135
136
|
};
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
// Floor applied last so the start column can never collapse below MIN_PX
|
|
139
|
+
// (which would produce an invalid negative grid track on tiny containers).
|
|
140
|
+
const effectiveStartPx =
|
|
141
|
+
containerWidth > 0
|
|
142
|
+
? Math.max(MIN_PX, Math.min(containerWidth - MIN_PX - GUTTER_PX, startPx))
|
|
143
|
+
: startPx;
|
|
144
|
+
|
|
145
|
+
const colTemplate = paneColTemplate(effectiveStartPx);
|
|
139
146
|
|
|
140
147
|
const gutter = (
|
|
141
148
|
<div
|
|
@@ -151,48 +158,30 @@ export function SplitPane({
|
|
|
151
158
|
</div>
|
|
152
159
|
);
|
|
153
160
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<div
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
>
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
<span className="h-full w-px bg-gray-alpha-400" aria-hidden />
|
|
164
|
-
</div>
|
|
165
|
-
<div>{endHeader}</div>
|
|
166
|
-
</div>
|
|
167
|
-
<div
|
|
168
|
-
ref={setContainerRef}
|
|
169
|
-
className={cn(
|
|
170
|
-
'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
|
|
171
|
-
isDragging && 'select-none'
|
|
172
|
-
)}
|
|
173
|
-
style={{ gridTemplateColumns: colTemplate }}
|
|
174
|
-
>
|
|
175
|
-
{start}
|
|
176
|
-
{gutter}
|
|
177
|
-
{end}
|
|
161
|
+
return (
|
|
162
|
+
<div className={cn('flex flex-col h-full min-h-0', className)}>
|
|
163
|
+
<div
|
|
164
|
+
className="shrink-0 grid"
|
|
165
|
+
style={{ gridTemplateColumns: colTemplate }}
|
|
166
|
+
>
|
|
167
|
+
<div>{startHeader}</div>
|
|
168
|
+
<div className="flex justify-center">
|
|
169
|
+
<span className="h-full w-px bg-gray-alpha-400" aria-hidden />
|
|
178
170
|
</div>
|
|
171
|
+
<div>{endHeader}</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div
|
|
174
|
+
ref={setContainerRef}
|
|
175
|
+
className={cn(
|
|
176
|
+
'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
|
|
177
|
+
isDragging && 'select-none'
|
|
178
|
+
)}
|
|
179
|
+
style={{ gridTemplateColumns: colTemplate }}
|
|
180
|
+
>
|
|
181
|
+
{start}
|
|
182
|
+
{gutter}
|
|
183
|
+
{end}
|
|
179
184
|
</div>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
<div
|
|
185
|
-
ref={setContainerRef}
|
|
186
|
-
className={cn(
|
|
187
|
-
'grid h-full min-h-0 content-start overflow-x-hidden overflow-y-auto',
|
|
188
|
-
isDragging && 'select-none',
|
|
189
|
-
className
|
|
190
|
-
)}
|
|
191
|
-
style={{ gridTemplateColumns: colTemplate, height: '100%' }}
|
|
192
|
-
>
|
|
193
|
-
{start}
|
|
194
|
-
{gutter}
|
|
195
|
-
{end}
|
|
196
185
|
</div>
|
|
197
186
|
);
|
|
198
187
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Skeleton } from '../../ui/skeleton';
|
|
2
|
+
import { DEFAULT_START_PX, paneColTemplate } from './pane-constants';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
// Mirrors SplitPane's initial column template so the skeleton lines up with
|
|
5
|
+
// the real viewer's first paint.
|
|
6
|
+
const COL_TEMPLATE = paneColTemplate(DEFAULT_START_PX);
|
|
4
7
|
|
|
5
8
|
const ROWS: { id: string; name: number; off: number; bar: number }[] = [
|
|
6
9
|
{ id: 'r0', name: 62, off: 0, bar: 72 },
|