@@ -64,16 +64,19 @@ struct UiState {
6464 last_result : Option < RunResult > ,
6565 history : Vec < RunResult > ,
6666 history_selected : usize , // Index of selected history item (0 = most recent)
67+ history_scroll_offset : usize ,
68+ history_loaded_count : usize ,
69+ initial_history_load_size : usize , // Initial load size based on terminal height
6770 ip : Option < String > ,
6871 colo : Option < String > ,
6972 server : Option < String > ,
7073 asn : Option < String > ,
7174 as_org : Option < String > ,
7275 auto_save : bool ,
73- last_exported_path : Option < String > , // Full path of last exported file (for clipboard)
76+ last_exported_path : Option < String > ,
7477 // Network interface information
7578 interface_name : Option < String > ,
76- network_name : Option < String > , // SSID for wireless, or network name
79+ network_name : Option < String > ,
7780 is_wireless : Option < bool > ,
7881 interface_mac : Option < String > ,
7982 link_speed_mbps : Option < u64 > ,
@@ -117,6 +120,9 @@ impl Default for UiState {
117120 last_result : None ,
118121 history : Vec :: new ( ) ,
119122 history_selected : 0 ,
123+ history_scroll_offset : 0 ,
124+ history_loaded_count : 0 ,
125+ initial_history_load_size : 66 , // Default initial load size
120126 ip : None ,
121127 colo : None ,
122128 server : None ,
@@ -328,12 +334,21 @@ pub async fn run(args: Cli) -> Result<()> {
328334 let mut terminal = Terminal :: new ( backend) . context ( "create terminal" ) ?;
329335 terminal. clear ( ) . ok ( ) ;
330336
337+ // Get terminal size to determine initial history load
338+ // Load 3x the visible height initially (for smooth scrolling)
339+ // Default to 24 rows if we can't get terminal size
340+ let initial_load = terminal. size ( )
341+ . map ( |size| ( ( size. height as usize ) . saturating_sub ( 2 ) * 3 ) . max ( 20 ) )
342+ . unwrap_or ( 66 ) ; // Default: (24-2)*3 = 66 items
343+
331344 let mut state = UiState {
332345 phase : Phase :: IdleLatency ,
333346 auto_save : args. auto_save ,
334347 ..Default :: default ( )
335348 } ;
336- state. history = crate :: storage:: load_recent ( 20 ) . unwrap_or_default ( ) ;
349+ state. initial_history_load_size = initial_load;
350+ state. history = crate :: storage:: load_recent ( initial_load) . unwrap_or_default ( ) ;
351+ state. history_loaded_count = state. history . len ( ) ;
337352
338353 // Gather network interface information
339354 // If interface is specified via CLI, use that; otherwise auto-detect
@@ -503,6 +518,7 @@ pub async fn run(args: Cli) -> Result<()> {
503518 // Reset history selection when switching to history tab
504519 if new_tab == 1 {
505520 state. history_selected = 0 ;
521+ state. history_scroll_offset = 0 ;
506522 }
507523 }
508524 ( _, KeyCode :: Char ( '?' ) ) => {
@@ -514,6 +530,10 @@ pub async fn run(args: Cli) -> Result<()> {
514530 // Up/k goes to newer items (lower index, towards 0)
515531 if state. history_selected > 0 {
516532 state. history_selected -= 1 ;
533+ // Auto-scroll: if selected item is above visible area, scroll up
534+ if state. history_selected < state. history_scroll_offset {
535+ state. history_scroll_offset = state. history_selected;
536+ }
517537 }
518538 }
519539 }
@@ -523,6 +543,35 @@ pub async fn run(args: Cli) -> Result<()> {
523543 // Allow navigation through all items; display will show what fits
524544 if state. history_selected < state. history. len( ) . saturating_sub( 1 ) {
525545 state. history_selected += 1 ;
546+ // Auto-scroll: keep selected item visible
547+ // Use a reasonable estimate for max_items (will be recalculated in draw)
548+ let estimated_max_items = 30 ; // reasonable default
549+ if state. history_selected >= state. history_scroll_offset + estimated_max_items {
550+ state. history_scroll_offset = state. history_selected. saturating_sub( estimated_max_items - 1 ) ;
551+ }
552+
553+ // Lazy load: if we're near the end of loaded items, load more
554+ let load_threshold = state. history_loaded_count. saturating_sub( 10 ) ;
555+ if state. history_selected >= load_threshold && state. history_loaded_count == state. history. len( ) {
556+ // Load more items (another batch of the same size)
557+ let current_count = state. history. len( ) ;
558+ let load_more = current_count. max( 20 ) ; // Load at least as many as we have, or 20
559+ if let Ok ( more_history) = crate :: storage:: load_recent( load_more) {
560+ // Only add items we don't already have
561+ let existing_ids: std:: collections:: HashSet <_> = state. history
562+ . iter( )
563+ . map( |r| & r. meas_id)
564+ . collect( ) ;
565+ let new_items: Vec <_> = more_history
566+ . into_iter( )
567+ . filter( |r| !existing_ids. contains( & r. meas_id) )
568+ . collect( ) ;
569+ if !new_items. is_empty( ) {
570+ state. history. extend( new_items) ;
571+ state. history_loaded_count = state. history. len( ) ;
572+ }
573+ }
574+ }
526575 }
527576 }
528577 }
@@ -535,11 +584,16 @@ pub async fn run(args: Cli) -> Result<()> {
535584 state. info = format!( "Delete failed: {e:#}" ) ;
536585 } else {
537586 state. history. remove( state. history_selected) ;
587+ // Adjust scroll offset if needed
588+ if state. history_scroll_offset >= state. history. len( ) && !state. history. is_empty( ) {
589+ state. history_scroll_offset = state. history. len( ) . saturating_sub( 20 ) . max( 0 ) ;
590+ }
538591 // Adjust selection if needed
539592 if state. history_selected >= state. history. len( ) && !state. history. is_empty( ) {
540593 state. history_selected = state. history. len( ) - 1 ;
541594 } else if state. history. is_empty( ) {
542595 state. history_selected = 0 ;
596+ state. history_scroll_offset = 0 ;
543597 }
544598 state. info = "Deleted" . into( ) ;
545599 }
@@ -592,7 +646,16 @@ pub async fn run(args: Cli) -> Result<()> {
592646 // Enrich result with network info before storing
593647 let enriched = enrich_result_with_network_info( & r, & state) ;
594648 state. last_result = Some ( enriched) ;
595- state. history = crate :: storage:: load_recent( 20 ) . unwrap_or_default( ) ;
649+ // Reload history to include the new test
650+ // Load at least one more than we had before to ensure the new test is included
651+ let reload_size = ( state. history_loaded_count + 1 ) . max( state. initial_history_load_size) ;
652+ state. history = crate :: storage:: load_recent( reload_size) . unwrap_or_default( ) ;
653+ state. history_loaded_count = state. history. len( ) ;
654+ // Reset selection to show the new test (most recent) if on history tab
655+ if state. tab == 1 {
656+ state. history_selected = 0 ;
657+ state. history_scroll_offset = 0 ;
658+ }
596659 state. info = "Done. (r rerun, q quit)" . into( ) ;
597660 }
598661 Ok ( Err ( e) ) => state. info = format!( "Run failed: {e:#}" ) ,
@@ -1558,28 +1621,61 @@ fn copy_to_clipboard(text: &str) -> Result<()> {
15581621
15591622fn draw_history ( area : Rect , f : & mut ratatui:: Frame , state : & UiState ) {
15601623 let mut lines: Vec < Line > = Vec :: new ( ) ;
1624+
1625+ // Calculate how many items can fit in the available area
1626+ // Subtract 2 for header lines
1627+ let max_items = ( area. height as usize ) . saturating_sub ( 2 ) ;
1628+
1629+ // Show total count and current position
1630+ let total_count = state. history . len ( ) ;
1631+ let current_pos = if total_count > 0 {
1632+ state. history_selected + 1
1633+ } else {
1634+ 0
1635+ } ;
1636+
15611637 lines. push ( Line :: from ( vec ! [
1562- Span :: raw( "Most recent runs (" ) ,
1638+ Span :: raw( format!( "History ({}/{}" , current_pos, total_count) ) ,
1639+ if total_count > max_items {
1640+ Span :: raw( format!( ", showing {} items" , max_items) )
1641+ } else {
1642+ Span :: raw( "" )
1643+ } ,
1644+ Span :: raw( ") - " ) ,
15631645 Span :: styled( "↑/↓/j/k" , Style :: default ( ) . fg( Color :: Magenta ) ) ,
15641646 Span :: raw( ": navigate, " ) ,
15651647 Span :: styled( "d" , Style :: default ( ) . fg( Color :: Magenta ) ) ,
15661648 Span :: raw( ": delete, " ) ,
15671649 Span :: styled( "e" , Style :: default ( ) . fg( Color :: Magenta ) ) ,
15681650 Span :: raw( ": export JSON, " ) ,
15691651 Span :: styled( "c" , Style :: default ( ) . fg( Color :: Magenta ) ) ,
1570- Span :: raw( ": export CSV): " ) ,
1652+ Span :: raw( ": export CSV" ) ,
15711653 ] ) ) ;
15721654 lines. push ( Line :: from ( "" ) ) ;
15731655
1574- // Calculate how many items can fit in the available area
1575- // Subtract 2 for header lines, and 1 more for the "No history" message if needed
1576- let max_items = ( area. height as usize ) . saturating_sub ( 2 ) ;
1577-
1578- // History is already ordered newest first, so we display it directly
1579- let history_display: Vec < _ > = state. history . iter ( ) . take ( max_items) . collect ( ) ;
1580- for ( i, r) in history_display. iter ( ) . enumerate ( ) {
1581- // i directly maps to history index since we're not reversing
1582- let is_selected = state. tab == 1 && i == state. history_selected ;
1656+ // Apply scroll offset and take only visible items
1657+ // Auto-adjust scroll to keep selected item visible (this should have been done in event handler, but handle edge cases here)
1658+ let scroll_offset = {
1659+ let mut offset = state. history_scroll_offset . min ( state. history . len ( ) . saturating_sub ( 1 ) ) ;
1660+ // Ensure selected item is visible
1661+ if state. history_selected < offset {
1662+ offset = state. history_selected ;
1663+ } else if state. history_selected >= offset + max_items {
1664+ offset = state. history_selected . saturating_sub ( max_items - 1 ) ;
1665+ }
1666+ offset
1667+ } ;
1668+
1669+ let history_display: Vec < _ > = state. history
1670+ . iter ( )
1671+ . skip ( scroll_offset)
1672+ . take ( max_items)
1673+ . collect ( ) ;
1674+
1675+ for ( display_idx, r) in history_display. iter ( ) . enumerate ( ) {
1676+ // Calculate actual history index (accounting for scroll offset)
1677+ let history_idx = scroll_offset + display_idx;
1678+ let is_selected = state. tab == 1 && history_idx == state. history_selected ;
15831679
15841680 // Parse and format timestamp to human-readable format in local timezone
15851681 let timestamp_str: String = {
@@ -1675,7 +1771,7 @@ fn draw_history(area: Rect, f: &mut ratatui::Frame, state: &UiState) {
16751771 } ;
16761772
16771773 // Line number (1-indexed, newest = 1)
1678- let line_num = i + 1 ;
1774+ let line_num = history_idx + 1 ;
16791775
16801776 lines. push ( Line :: from ( vec ! [
16811777 Span :: styled(
@@ -1721,6 +1817,30 @@ fn draw_history(area: Rect, f: &mut ratatui::Frame, state: &UiState) {
17211817 ) ,
17221818 if is_selected { style } else { Style :: default ( ) } ,
17231819 ) ,
1820+ Span :: raw( " " ) ,
1821+ Span :: styled(
1822+ format!(
1823+ "{}" ,
1824+ r. interface_name. as_deref( ) . unwrap_or( "-" )
1825+ ) ,
1826+ if is_selected {
1827+ style
1828+ } else {
1829+ Style :: default ( ) . fg( Color :: Blue )
1830+ } ,
1831+ ) ,
1832+ Span :: raw( " " ) ,
1833+ Span :: styled(
1834+ format!(
1835+ "{}" ,
1836+ r. network_name. as_deref( ) . or_else( || r. interface_name. as_deref( ) ) . unwrap_or( "-" )
1837+ ) ,
1838+ if is_selected {
1839+ style
1840+ } else {
1841+ Style :: default ( ) . fg( Color :: Magenta )
1842+ } ,
1843+ ) ,
17241844 ] ) ) ;
17251845 }
17261846
0 commit comments