ExplainVisualizer.NodeRow.tsx145 lines · main
1import { ChevronDown, ChevronRight } from 'lucide-react'
2import { useState } from 'react'
3import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
4
5import { parseDetailLines } from './ExplainVisualizer.parser'
6import { RowCountIndicator } from './ExplainVisualizer.RowCountIndicator'
7import type { ExplainNode } from './ExplainVisualizer.types'
8import { formatNodeDuration, getScanBarColor, getScanBorderColor } from './ExplainVisualizer.utils'
9
10interface ExplainNodeRowProps {
11 node: ExplainNode
12 depth: number
13 /** Maximum duration across all nodes, used to calculate bar width as % */
14 maxDuration: number
15}
16
17export function ExplainNodeRow({ node, depth, maxDuration }: ExplainNodeRowProps) {
18 const [isExpanded, setIsExpanded] = useState(false)
19 const hasChildren = node.children.length > 0
20 const hasDetails = Boolean(node.details?.trim())
21 const canExpand = hasDetails
22
23 const detailLines = parseDetailLines(node.details)
24 const indentPx = depth * 24
25
26 // Calculate duration and bar width as % of max duration
27 const duration = node.actualTime ? node.actualTime.end - node.actualTime.start : 0
28 const hasTimingData = node.actualTime && duration > 0
29 const barWidthPercent = maxDuration > 0 ? (duration / maxDuration) * 100 : 0
30 const barColorClass = getScanBarColor(node.operation)
31 const borderColorClass = getScanBorderColor(node.operation)
32
33 return (
34 <>
35 {/* Wrapper for group hover */}
36 <div className="group">
37 {/* Main row */}
38 <div
39 className={cn(
40 'flex items-stretch border-l-4 transition-colors bg-studio group-hover:bg-surface-100/50',
41 borderColorClass
42 )}
43 >
44 {/* Left section: expand button + operation info */}
45 <div
46 className="flex items-center gap-3 px-4 py-3 shrink-0 min-w-[400px]"
47 style={{ paddingLeft: `${16 + indentPx}px` }}
48 >
49 {/* Expand/collapse button */}
50 <button
51 type="button"
52 onClick={() => canExpand && setIsExpanded(!isExpanded)}
53 disabled={!canExpand}
54 className={cn(
55 'flex items-center justify-center w-5 h-5 rounded-sm border border-border-muted shrink-0',
56 canExpand ? 'hover:bg-surface-200 cursor-pointer' : 'opacity-30 cursor-default'
57 )}
58 aria-label={isExpanded ? 'Collapse details' : 'Expand details'}
59 >
60 {isExpanded ? (
61 <ChevronDown size={12} className="text-foreground-light" />
62 ) : (
63 <ChevronRight size={12} className="text-foreground-light" />
64 )}
65 </button>
66
67 {/* Operation name and cost info */}
68 <div className="flex items-center gap-2 font-mono text-xs min-w-0">
69 <span className="text-foreground uppercase font-medium whitespace-nowrap">
70 {node.operation}
71 </span>
72 <span className="text-foreground-muted whitespace-nowrap">
73 (cost {node.cost?.end?.toFixed(1) ?? '-'}, estimated{' '}
74 {node.rows?.toLocaleString() ?? '?'} {node.rows === 1 ? 'row' : 'rows'})
75 </span>
76 </div>
77 </div>
78
79 {/* Right section: duration bar visualization */}
80 <div className="flex-1 relative min-h-[43px] flex items-center">
81 {hasTimingData && (
82 <>
83 {/* Duration bar - width represents % of slowest operation */}
84 <div
85 className={cn('absolute left-0 top-0 h-full', barColorClass)}
86 style={{ width: `${barWidthPercent}%` }}
87 />
88 {/* Duration and row count info */}
89 <div className="relative flex items-center gap-2 font-mono text-xs whitespace-nowrap px-3">
90 <Tooltip>
91 <TooltipTrigger asChild>
92 <span className="text-foreground-light cursor-help">
93 {formatNodeDuration(duration)}
94 </span>
95 </TooltipTrigger>
96 <TooltipContent side="top" className="max-w-xs font-sans">
97 <p className="font-medium">Execution time: {formatNodeDuration(duration)}</p>
98 <p className="text-foreground-lighter text-xs mt-1">
99 This is how long this operation took to execute. The bar width shows this as
100 a percentage of the slowest operation ({Math.round(barWidthPercent)}%) —
101 wider bars indicate where more time is spent.
102 </p>
103 </TooltipContent>
104 </Tooltip>
105 <span className="text-foreground-muted">/</span>
106 <RowCountIndicator
107 actualRows={node.actualRows}
108 estimatedRows={node.rows}
109 rowsRemovedByFilter={node.rowsRemovedByFilter}
110 />
111 </div>
112 </>
113 )}
114 </div>
115 </div>
116
117 {/* Expanded details section */}
118 {isExpanded && detailLines.length > 0 && (
119 <div
120 className={cn(
121 'border-t-border-muted border-t border-l-4 bg-studio group-hover:bg-surface-100/50',
122 borderColorClass
123 )}
124 style={{ paddingLeft: `${16 + indentPx + 32}px` }}
125 >
126 <div className="px-0 py-3 space-y-2 font-mono text-xs">
127 {detailLines.map((line, idx) => (
128 <div key={idx} className="flex items-start gap-1">
129 {line.label && <span className="text-foreground-muted">{line.label}</span>}
130 <span className="text-foreground-light break-all">{line.value}</span>
131 </div>
132 ))}
133 </div>
134 </div>
135 )}
136 </div>
137
138 {/* Render children recursively */}
139 {hasChildren &&
140 node.children.map((child, idx) => (
141 <ExplainNodeRow key={idx} node={child} depth={depth + 1} maxDuration={maxDuration} />
142 ))}
143 </>
144 )
145}