1- import { useMemo , useState } from 'react'
1+ import { Fragment , useMemo , useState } from 'react'
22import { Link } from '@tanstack/react-router'
33import clsx from 'clsx'
44import { ChevronUp } from 'lucide-react'
@@ -64,6 +64,36 @@ function formatMGasCompact(mgas: number): string {
6464 return mgas . toFixed ( 1 )
6565}
6666
67+ function splitByMatch ( name : string , search : string , isRegex : boolean ) : { text : string ; highlight : boolean } [ ] {
68+ if ( ! search ) return [ { text : name , highlight : false } ]
69+ try {
70+ const pattern = isRegex ? search : search . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
71+ const re = new RegExp ( `(${ pattern } )` , 'gi' )
72+ const parts = name . split ( re )
73+ if ( parts . length === 1 ) return [ { text : name , highlight : false } ]
74+ return parts . filter ( Boolean ) . map ( ( part ) => ( { text : part , highlight : re . test ( part ) } ) )
75+ } catch {
76+ return [ { text : name , highlight : false } ]
77+ }
78+ }
79+
80+ function HighlightedName ( { name, search, useRegex } : { name : string ; search : string ; useRegex : boolean } ) {
81+ const parts = splitByMatch ( name , search , useRegex )
82+ return (
83+ < >
84+ { parts . map ( ( part , i ) =>
85+ part . highlight ? (
86+ < mark key = { i } className = "rounded-xs bg-yellow-200 text-yellow-900 dark:bg-yellow-700/50 dark:text-yellow-200" >
87+ { part . text }
88+ </ mark >
89+ ) : (
90+ < span key = { i } > { part . text } </ span >
91+ ) ,
92+ ) }
93+ </ >
94+ )
95+ }
96+
6797interface RunData {
6898 runId : string
6999 mgas : number
@@ -101,12 +131,16 @@ interface TestHeatmapProps {
101131 testFiles ?: SuiteTest [ ]
102132 isDark : boolean
103133 stepFilter ?: IndexStepType [ ]
134+ searchQuery ?: string
135+ onSearchChange ?: ( query : string | undefined ) => void
136+ showTestName ?: boolean
137+ onShowTestNameChange ?: ( show : boolean ) => void
104138}
105139
106140type SortDirection = 'asc' | 'desc'
107141type SortField = 'testNumber' | 'avgMgas'
108142
109- export function TestHeatmap ( { stats, testFiles, isDark, stepFilter = ALL_INDEX_STEP_TYPES } : TestHeatmapProps ) {
143+ export function TestHeatmap ( { stats, testFiles, isDark, stepFilter = ALL_INDEX_STEP_TYPES , searchQuery , onSearchChange , showTestName : showTestNameProp , onShowTestNameChange } : TestHeatmapProps ) {
110144 const [ tooltip , setTooltip ] = useState < TooltipData | null > ( null )
111145 const [ currentPage , setCurrentPage ] = useState ( 1 )
112146 const [ pageSize , setPageSize ] = useState ( DEFAULT_PAGE_SIZE )
@@ -116,6 +150,8 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
116150 const [ runsPerClient , setRunsPerClient ] = useState ( DEFAULT_RUNS_PER_CLIENT )
117151 const [ statDisplay , setStatDisplay ] = useState < StatDisplayType > ( 'Avg' )
118152 const [ showClientStat , setShowClientStat ] = useState ( true )
153+ const showTestName = showTestNameProp ?? false
154+ const [ useRegex , setUseRegex ] = useState ( false )
119155 const [ statColumnType , setStatColumnType ] = useState < DistributionStatType > ( 'Avg' )
120156
121157 const { allTests, clients } = useMemo ( ( ) => {
@@ -219,9 +255,26 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
219255 return { allTests : processedTests , clients }
220256 } , [ stats , testFiles , runsPerClient , stepFilter ] )
221257
258+ const search = searchQuery ?? ''
259+
260+ // Filter by search query
261+ const filteredTests = useMemo ( ( ) => {
262+ if ( ! search ) return allTests
263+ if ( useRegex ) {
264+ try {
265+ const re = new RegExp ( search , 'i' )
266+ return allTests . filter ( ( t ) => re . test ( t . name ) )
267+ } catch {
268+ return allTests
269+ }
270+ }
271+ const lower = search . toLowerCase ( )
272+ return allTests . filter ( ( t ) => t . name . toLowerCase ( ) . includes ( lower ) )
273+ } , [ allTests , search , useRegex ] )
274+
222275 // Sort and paginate
223276 const sortedTests = useMemo ( ( ) => {
224- const sorted = [ ...allTests ]
277+ const sorted = [ ...filteredTests ]
225278 sorted . sort ( ( a , b ) => {
226279 if ( sortField === 'testNumber' ) {
227280 // Tests without a number go to the end
@@ -238,7 +291,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
238291 return bVal - aVal // Highest first (fastest)
239292 } )
240293 return sorted
241- } , [ allTests , sortField , sortDirection , statColumnType ] )
294+ } , [ filteredTests , sortField , sortDirection , statColumnType ] )
242295
243296 const totalPages = Math . ceil ( sortedTests . length / pageSize )
244297 const paginatedTests = sortedTests . slice ( ( currentPage - 1 ) * pageSize , currentPage * pageSize )
@@ -310,6 +363,11 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
310363 }
311364 }
312365
366+ const handleSearchChange = ( value : string ) => {
367+ setCurrentPage ( 1 )
368+ onSearchChange ?.( value || undefined )
369+ }
370+
313371 const handleMouseEnter = ( test : ProcessedTest , client : string , run : RunData , event : React . MouseEvent ) => {
314372 const rect = event . currentTarget . getBoundingClientRect ( )
315373 setTooltip ( {
@@ -336,7 +394,8 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
336394 return (
337395 < div className = "relative flex flex-col gap-4" >
338396 { /* Controls */ }
339- < div className = "flex flex-wrap items-center gap-x-6 gap-y-2" >
397+ < div className = "flex items-start gap-x-6 gap-y-2" >
398+ < div className = "flex flex-wrap items-center gap-x-6 gap-y-2" >
340399 { /* Threshold control */ }
341400 < div className = "flex items-center gap-2" >
342401 < span className = "text-xs/5 text-gray-500 dark:text-gray-400" > Slow threshold:</ span >
@@ -419,6 +478,17 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
419478 </ div >
420479 </ div >
421480
481+ { /* Show test name toggle */ }
482+ < label className = "flex cursor-pointer items-center gap-1.5" >
483+ < input
484+ type = "checkbox"
485+ checked = { showTestName }
486+ onChange = { ( e ) => onShowTestNameChange ?.( e . target . checked ) }
487+ className = "size-3.5 cursor-pointer rounded-xs border-gray-300 text-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
488+ />
489+ < span className = "text-xs/5 text-gray-500 dark:text-gray-400" > Test name</ span >
490+ </ label >
491+
422492 { /* Page size selector */ }
423493 < div className = "flex items-center gap-2" >
424494 < span className = "text-xs/5 text-gray-500 dark:text-gray-400" > Per page:</ span >
@@ -439,6 +509,36 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
439509 ) ) }
440510 </ div >
441511 </ div >
512+
513+ </ div >
514+
515+ { /* Search filter */ }
516+ < div className = "flex shrink-0 items-center gap-1.5" >
517+ < input
518+ type = "text"
519+ value = { search }
520+ onChange = { ( e ) => handleSearchChange ( e . target . value ) }
521+ placeholder = { useRegex ? 'Regex pattern...' : 'Filter tests...' }
522+ className = { clsx (
523+ 'w-48 rounded-sm border bg-white px-2 py-0.5 text-xs/5 text-gray-900 placeholder:text-gray-400 focus:outline-hidden focus:ring-1 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500' ,
524+ useRegex && search && ( ( ) => { try { new RegExp ( search ) ; return false } catch { return true } } ) ( )
525+ ? 'border-red-400 focus:border-red-500 focus:ring-red-500 dark:border-red-500'
526+ : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600' ,
527+ ) }
528+ />
529+ < button
530+ onClick = { ( ) => setUseRegex ( ! useRegex ) }
531+ title = { useRegex ? 'Regex mode (click to switch to text)' : 'Text mode (click to switch to regex)' }
532+ className = { clsx (
533+ 'rounded-sm px-1.5 py-0.5 font-mono text-xs/5 transition-colors' ,
534+ useRegex
535+ ? 'bg-blue-500 text-white'
536+ : 'bg-white text-gray-500 ring-1 ring-gray-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-600 dark:hover:bg-gray-700' ,
537+ ) }
538+ >
539+ .*
540+ </ button >
541+ </ div >
442542 </ div >
443543
444544 < div className = "overflow-x-auto" >
@@ -494,7 +594,19 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
494594 </ thead >
495595 < tbody >
496596 { paginatedTests . map ( ( test ) => (
497- < tr key = { test . name } className = "border-t border-gray-200 dark:border-gray-700" >
597+ < Fragment key = { test . name } >
598+ { showTestName && (
599+ < tr className = "border-t border-gray-200 dark:border-gray-700" >
600+ < td
601+ colSpan = { clients . length + 2 }
602+ className = "truncate px-2 py-0.5 font-mono text-xs/5 text-gray-500 dark:text-gray-400"
603+ title = { test . name }
604+ >
605+ < HighlightedName name = { test . name } search = { search } useRegex = { useRegex } />
606+ </ td >
607+ </ tr >
608+ ) }
609+ < tr className = { clsx ( 'border-t border-gray-200 dark:border-gray-700' , showTestName && 'border-t-0' ) } >
498610 < td
499611 className = "sticky left-0 z-10 bg-white px-2 py-1.5 text-right font-mono text-xs/5 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
500612 title = { test . name }
@@ -585,6 +697,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
585697 { formatMGasCompact ( statColumnType === 'Avg' ? test . avgMgas : test . minMgas ) }
586698 </ td >
587699 </ tr >
700+ </ Fragment >
588701 ) ) }
589702 </ tbody >
590703 </ table >
@@ -650,7 +763,7 @@ export function TestHeatmap({ stats, testFiles, isDark, stepFilter = ALL_INDEX_S
650763 No data
651764 </ span >
652765 < span className = "text-gray-400 dark:text-gray-500" >
653- { allTests . length } tests · { runsPerClient } most recent runs per client
766+ { search ? ` ${ filteredTests . length } / ${ allTests . length } ` : allTests . length } tests · { runsPerClient } most recent runs per client
654767 </ span >
655768 </ div >
656769 < Pagination currentPage = { currentPage } totalPages = { totalPages } onPageChange = { handlePageChange } />
0 commit comments