@@ -4,6 +4,7 @@ use crate::components::{
44 Footer , Header , JobDetail , JobList , LogViewer , LogViewerState , RuleSummary ,
55} ;
66use crate :: ui:: Theme ;
7+ use charmer_runs:: RunInfo ;
78use charmer_state:: { JobStatus , PipelineState , MAIN_PIPELINE_JOB_ID } ;
89use crossterm:: event:: { self , Event , KeyCode , KeyEvent , KeyModifiers } ;
910use ratatui:: {
@@ -110,10 +111,27 @@ pub struct App {
110111 rule_names : Vec < String > , // Cached rule names for rule view
111112 status_message : Option < ( String , Instant ) > , // Temporary status message with timestamp
112113 command_expanded : bool , // Whether command section is expanded in details
114+
115+ // Run management
116+ pub runs : Vec < RunInfo > , // Available runs
117+ pub selected_run : Option < String > , // Currently selected run UUID
118+ pub show_run_picker : bool , // Whether run picker modal is open
119+ pub run_picker_index : usize , // Selected index in run picker
120+ pub show_all_jobs : bool , // Whether to show all jobs or just snakemake jobs
113121}
114122
115123impl App {
116124 pub fn new ( state : PipelineState ) -> Self {
125+ Self :: with_options ( state, false , Vec :: new ( ) , None )
126+ }
127+
128+ /// Create a new App with run management options.
129+ pub fn with_options (
130+ state : PipelineState ,
131+ show_all_jobs : bool ,
132+ runs : Vec < RunInfo > ,
133+ selected_run : Option < String > ,
134+ ) -> Self {
117135 let job_ids = state. jobs . keys ( ) . cloned ( ) . collect ( ) ;
118136 let rule_names: Vec < String > = state. jobs_by_rule . keys ( ) . cloned ( ) . collect ( ) ;
119137 let mut app = Self {
@@ -132,6 +150,11 @@ impl App {
132150 rule_names,
133151 status_message : None ,
134152 command_expanded : false ,
153+ runs,
154+ selected_run,
155+ show_run_picker : false ,
156+ run_picker_index : 0 ,
157+ show_all_jobs,
135158 } ;
136159 // Update job list first to ensure MAIN_PIPELINE_JOB_ID is in the list
137160 app. update_job_list ( ) ;
@@ -146,7 +169,13 @@ impl App {
146169 . state
147170 . jobs
148171 . iter ( )
149- . filter ( |( _, job) | self . filter_mode . matches ( job. status ) )
172+ . filter ( |( _, job) | {
173+ // Filter by snakemake status if not showing all jobs
174+ if !self . show_all_jobs && !job. is_snakemake_job {
175+ return false ;
176+ }
177+ self . filter_mode . matches ( job. status )
178+ } )
150179 . collect ( ) ;
151180
152181 // Sort jobs
@@ -298,6 +327,31 @@ impl App {
298327 self . show_help = !self . show_help ;
299328 }
300329
330+ /// Toggle run picker modal.
331+ pub fn toggle_run_picker ( & mut self ) {
332+ self . show_run_picker = !self . show_run_picker ;
333+ if self . show_run_picker {
334+ self . run_picker_index = 0 ;
335+ }
336+ }
337+
338+ /// Toggle between snakemake-only and all-jobs view.
339+ pub fn toggle_all_jobs ( & mut self ) {
340+ self . show_all_jobs = !self . show_all_jobs ;
341+ self . update_job_list ( ) ;
342+ let msg = if self . show_all_jobs {
343+ "Showing all jobs"
344+ } else {
345+ "Showing snakemake jobs only"
346+ } ;
347+ self . status_message = Some ( ( msg. to_string ( ) , Instant :: now ( ) ) ) ;
348+ }
349+
350+ /// Update runs list.
351+ pub fn update_runs ( & mut self , runs : Vec < RunInfo > ) {
352+ self . runs = runs;
353+ }
354+
301355 /// Toggle between jobs and rules view.
302356 pub fn toggle_view_mode ( & mut self ) {
303357 self . view_mode = match self . view_mode {
@@ -552,6 +606,38 @@ impl App {
552606 return ;
553607 }
554608
609+ // If run picker is showing, handle picker navigation
610+ if self . show_run_picker {
611+ match key. code {
612+ KeyCode :: Esc | KeyCode :: Char ( 'q' ) => self . show_run_picker = false ,
613+ KeyCode :: Char ( 'j' ) | KeyCode :: Down => {
614+ if !self . runs . is_empty ( ) {
615+ self . run_picker_index = ( self . run_picker_index + 1 ) % self . runs . len ( ) ;
616+ }
617+ }
618+ KeyCode :: Char ( 'k' ) | KeyCode :: Up => {
619+ if !self . runs . is_empty ( ) {
620+ self . run_picker_index = self
621+ . run_picker_index
622+ . checked_sub ( 1 )
623+ . unwrap_or ( self . runs . len ( ) - 1 ) ;
624+ }
625+ }
626+ KeyCode :: Enter => {
627+ if let Some ( run) = self . runs . get ( self . run_picker_index ) {
628+ self . selected_run = Some ( run. run_uuid . clone ( ) ) ;
629+ self . status_message = Some ( (
630+ format ! ( "Selected run: {}" , & run. run_uuid[ ..8 . min( run. run_uuid. len( ) ) ] ) ,
631+ Instant :: now ( ) ,
632+ ) ) ;
633+ }
634+ self . show_run_picker = false ;
635+ }
636+ _ => { }
637+ }
638+ return ;
639+ }
640+
555641 match key. code {
556642 KeyCode :: Char ( 'q' ) => self . quit ( ) ,
557643 KeyCode :: Char ( 'c' ) if key. modifiers . contains ( KeyModifiers :: CONTROL ) => self . quit ( ) ,
@@ -574,6 +660,8 @@ impl App {
574660 KeyCode :: Char ( 'f' ) => self . cycle_filter ( ) ,
575661 KeyCode :: Char ( 's' ) => self . cycle_sort ( ) ,
576662 KeyCode :: Char ( 'r' ) => self . toggle_view_mode ( ) ,
663+ KeyCode :: Char ( 'R' ) => self . toggle_run_picker ( ) ,
664+ KeyCode :: Char ( 'a' ) => self . toggle_all_jobs ( ) ,
577665 KeyCode :: Char ( 'l' ) | KeyCode :: Enter => self . toggle_log_viewer ( ) ,
578666 KeyCode :: Char ( 'F' ) if self . show_log_viewer => {
579667 // Toggle follow mode when log panel is open
@@ -693,10 +781,101 @@ impl App {
693781 // Footer with optional status message
694782 Footer :: render ( frame, chunks[ 3 ] , status_msg) ;
695783
696- // Help overlay (on top of everything)
784+ // Overlays (on top of everything)
697785 if self . show_help {
698786 self . render_help_overlay ( frame) ;
699787 }
788+ if self . show_run_picker {
789+ self . render_run_picker ( frame) ;
790+ }
791+ }
792+
793+ /// Render run picker modal.
794+ fn render_run_picker ( & self , frame : & mut Frame ) {
795+ use ratatui:: style:: { Color , Modifier , Style } ;
796+ use ratatui:: text:: { Line , Span } ;
797+ use ratatui:: widgets:: { Block , Borders , List , ListItem , ListState , Paragraph } ;
798+
799+ let area = centered_rect ( 60 , 50 , frame. area ( ) ) ;
800+ frame. render_widget ( Clear , area) ;
801+
802+ if self . runs . is_empty ( ) {
803+ let paragraph = Paragraph :: new ( "No runs found" )
804+ . block (
805+ Block :: default ( )
806+ . borders ( Borders :: ALL )
807+ . title ( " Select Run (Esc to close) " )
808+ . style ( Style :: default ( ) . bg ( Color :: DarkGray ) ) ,
809+ )
810+ . style ( Style :: default ( ) . fg ( Color :: White ) . bg ( Color :: DarkGray ) ) ;
811+ frame. render_widget ( paragraph, area) ;
812+ return ;
813+ }
814+
815+ let items: Vec < ListItem > = self
816+ . runs
817+ . iter ( )
818+ . map ( |run| {
819+ let status_icon = match run. status {
820+ charmer_runs:: RunStatus :: Running => "●" ,
821+ charmer_runs:: RunStatus :: Completed => "✓" ,
822+ charmer_runs:: RunStatus :: Failed => "✗" ,
823+ charmer_runs:: RunStatus :: Unknown => "?" ,
824+ } ;
825+
826+ let status_color = match run. status {
827+ charmer_runs:: RunStatus :: Running => Color :: Yellow ,
828+ charmer_runs:: RunStatus :: Completed => Color :: Green ,
829+ charmer_runs:: RunStatus :: Failed => Color :: Red ,
830+ charmer_runs:: RunStatus :: Unknown => Color :: Gray ,
831+ } ;
832+
833+ let uuid_short = if run. run_uuid . len ( ) > 12 {
834+ & run. run_uuid [ ..12 ]
835+ } else {
836+ & run. run_uuid
837+ } ;
838+
839+ let jobs_str = format ! (
840+ "{}/{}" ,
841+ run. completed_jobs,
842+ run. total_jobs. unwrap_or( 0 )
843+ ) ;
844+
845+ let time_ago = format_time_ago ( run. last_updated ) ;
846+
847+ let line = Line :: from ( vec ! [
848+ Span :: styled( status_icon, Style :: default ( ) . fg( status_color) ) ,
849+ Span :: raw( " " ) ,
850+ Span :: styled( uuid_short, Style :: default ( ) . fg( Color :: Cyan ) ) ,
851+ Span :: raw( " - " ) ,
852+ Span :: styled( jobs_str, Style :: default ( ) . fg( Color :: White ) ) ,
853+ Span :: raw( " jobs - " ) ,
854+ Span :: styled( time_ago, Style :: default ( ) . fg( Color :: Gray ) ) ,
855+ ] ) ;
856+
857+ ListItem :: new ( line)
858+ } )
859+ . collect ( ) ;
860+
861+ let mut state = ListState :: default ( ) ;
862+ state. select ( Some ( self . run_picker_index ) ) ;
863+
864+ let list = List :: new ( items)
865+ . block (
866+ Block :: default ( )
867+ . borders ( Borders :: ALL )
868+ . title ( " Select Run (Enter to select, Esc to cancel) " )
869+ . style ( Style :: default ( ) . bg ( Color :: DarkGray ) ) ,
870+ )
871+ . highlight_style (
872+ Style :: default ( )
873+ . bg ( Color :: Rgb ( 60 , 60 , 80 ) )
874+ . add_modifier ( Modifier :: BOLD ) ,
875+ )
876+ . style ( Style :: default ( ) . bg ( Color :: DarkGray ) ) ;
877+
878+ frame. render_stateful_widget ( list, area, & mut state) ;
700879 }
701880
702881 /// Render detail panel for selected rule.
@@ -919,7 +1098,8 @@ impl App {
9191098 g / Home Go to first item
9201099 G / End Go to last item
9211100 r Toggle view (Jobs/Rules summary)
922- d Toggle DAG view
1101+ R Open run selector
1102+ a Toggle all jobs / snakemake only
9231103 f Cycle filter (All/Running/Failed/Pending/Completed)
9241104 s Cycle sort (Status/Rule/Time)
9251105 l / Enter Toggle log panel
@@ -981,3 +1161,19 @@ fn format_secs(secs: u64) -> String {
9811161 format ! ( "{}s" , secs)
9821162 }
9831163}
1164+
1165+ /// Format a timestamp as a human-readable "time ago" string.
1166+ fn format_time_ago ( dt : chrono:: DateTime < chrono:: Utc > ) -> String {
1167+ let now = chrono:: Utc :: now ( ) ;
1168+ let duration = now. signed_duration_since ( dt) ;
1169+
1170+ if duration. num_days ( ) > 0 {
1171+ format ! ( "{}d ago" , duration. num_days( ) )
1172+ } else if duration. num_hours ( ) > 0 {
1173+ format ! ( "{}h ago" , duration. num_hours( ) )
1174+ } else if duration. num_minutes ( ) > 0 {
1175+ format ! ( "{}m ago" , duration. num_minutes( ) )
1176+ } else {
1177+ "just now" . to_string ( )
1178+ }
1179+ }
0 commit comments