Files
sanderling/replay-ui/src/panels/Timeline.tsx
T
pj b44077afde replay ui fix (#56)
* refactor: rename inspect to replay across the codebase

Renames inspect-ui/ to replay-ui/, internal/inspect/ to internal/replay/,
the CLI subcommand from `sanderling inspect` to `sanderling replay`, and
updates all references in docs, Makefile, README, and Go comments.

* feat(replay-ui): show spec filename with full path on hover

RunList and RunDetail now render the basename of spec_path (e.g.
login.spec.ts) with the full path available as a title tooltip.
2026-06-03 16:17:26 +05:30

143 lines
4.3 KiB
TypeScript

import type { KeyboardEvent } from "react";
import type { StepSummary } from "../types";
import "./Timeline.css";
export type LaneStatus = "violated" | "pending" | "holds";
export interface PropertyLane {
name: string;
statuses: LaneStatus[];
}
export interface TimelineProps {
steps: StepSummary[];
lanes: PropertyLane[];
selectedIndex: number;
onSelect: (index: number) => void;
}
const LANE_HEIGHT = 22;
const LABEL_WIDTH = 140;
const CELL_GAP = 2;
const CHART_WIDTH = 1000;
const STATUS_FILL: Record<LaneStatus, string> = {
violated: "var(--accent-violation)",
pending: "color-mix(in srgb, var(--text-primary) 30%, transparent)",
holds: "color-mix(in srgb, var(--text-primary) 10%, transparent)",
};
export default function Timeline({ steps, lanes, selectedIndex, onSelect }: TimelineProps) {
if (steps.length === 0 || lanes.length === 0) {
return <div className="status-block">no timeline data</div>;
}
const cellAreaWidth = CHART_WIDTH - LABEL_WIDTH;
const cellWidth = cellAreaWidth / steps.length;
const totalRows = lanes.length + 1;
const height = totalRows * LANE_HEIGHT;
const handleKeyDown = (event: KeyboardEvent<SVGRectElement>, stepIndex: number) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(stepIndex);
}
};
const selectedColumn = steps.findIndex((step) => step.index === selectedIndex);
const highlightX =
selectedColumn >= 0 ? LABEL_WIDTH + selectedColumn * cellWidth + cellWidth / 2 : null;
return (
<svg
className="timeline"
viewBox={`0 0 ${CHART_WIDTH} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label="property timeline"
>
{lanes.map((lane, laneIndex) => {
const rowY = laneIndex * LANE_HEIGHT;
return (
<g key={lane.name} className="timeline-lane" data-lane={lane.name}>
<text
className="timeline-label"
x={LABEL_WIDTH - 8}
y={rowY + LANE_HEIGHT / 2}
dominantBaseline="middle"
textAnchor="end"
>
{lane.name}
</text>
{steps.map((step, stepCol) => {
const status = lane.statuses[stepCol] ?? "pending";
const x = LABEL_WIDTH + stepCol * cellWidth + CELL_GAP / 2;
const w = Math.max(cellWidth - CELL_GAP, 1);
const y = rowY + 3;
const h = LANE_HEIGHT - 6;
return (
<rect
key={step.index}
className="timeline-cell"
data-status={status}
data-step={step.index}
x={x}
y={y}
width={w}
height={h}
fill={STATUS_FILL[status]}
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => handleKeyDown(event, step.index)}
>
<title>{`step ${step.index}: ${status}`}</title>
</rect>
);
})}
</g>
);
})}
<g className="timeline-actions" data-row="actions">
{steps.map((step, stepCol) => {
const cx = LABEL_WIDTH + stepCol * cellWidth + cellWidth / 2;
const cy = lanes.length * LANE_HEIGHT + LANE_HEIGHT / 2;
return (
<circle
key={step.index}
className="timeline-action"
data-step={step.index}
cx={cx}
cy={cy}
r={3}
fill="var(--text-muted)"
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(step.index);
}
}}
>
<title>{`step ${step.index}`}</title>
</circle>
);
})}
</g>
{highlightX !== null ? (
<line
className="timeline-highlight"
data-selected="true"
x1={highlightX}
x2={highlightX}
y1={0}
y2={height}
stroke="var(--text-primary)"
strokeWidth={2}
pointerEvents="none"
/>
) : null}
</svg>
);
}