feat(ui): add job selection mode to TUI for running individual jobs

The CLI already has --job to run a single job, but the TUI had no
way to do this. You could only run entire workflows, which is the
kind of all-or-nothing approach that gets old fast when you have a
workflow with 12 jobs and you just want to re-run "lint".

Add a job selection sub-view to the Workflows tab. Press Enter on a
workflow to drill into its jobs (parsed via parse_workflow), then
Enter on a job to run just that one (with its transitive deps), or
'a' to run all, or Esc to go back. The selected job flows through
as target_job in ExecutionConfig, reusing the exact same filtering
logic the CLI --job flag already uses.

While at it, updated the status bar hints and help overlay to
document the new keybindings.
This commit is contained in:
bahdotsh
2026-04-02 15:07:36 +05:30
parent f05cbca3b9
commit 8406c60529
8 changed files with 301 additions and 22 deletions

1
Cargo.lock generated
View File

@@ -3542,6 +3542,7 @@ dependencies = [
"wrkflw-github",
"wrkflw-logging",
"wrkflw-models",
"wrkflw-parser",
"wrkflw-utils",
]

View File

@@ -19,6 +19,7 @@ tui = ["dep:crossterm", "dep:ratatui"]
wrkflw-models.workspace = true
wrkflw-evaluator.workspace = true
wrkflw-executor.workspace = true
wrkflw-parser.workspace = true
wrkflw-logging.workspace = true
wrkflw-utils.workspace = true
wrkflw-github.workspace = true

View File

@@ -221,7 +221,9 @@ fn run_tui_event_loop(
break Ok(());
}
KeyCode::Esc => {
if app.detailed_view {
if app.job_selection_mode {
app.exit_job_selection_mode();
} else if app.detailed_view {
app.detailed_view = false;
} else if app.show_help {
app.show_help = false;
@@ -252,7 +254,11 @@ fn run_tui_event_loop(
} else if app.selected_tab == 3 {
app.scroll_help_up();
} else if app.selected_tab == 0 {
app.previous_workflow();
if app.job_selection_mode {
app.previous_available_job();
} else {
app.previous_workflow();
}
} else if app.selected_tab == 1 {
if app.detailed_view {
app.previous_step();
@@ -271,7 +277,11 @@ fn run_tui_event_loop(
} else if app.selected_tab == 3 {
app.scroll_help_down();
} else if app.selected_tab == 0 {
app.next_workflow();
if app.job_selection_mode {
app.next_available_job();
} else {
app.next_workflow();
}
} else if app.selected_tab == 1 {
if app.detailed_view {
app.next_step();
@@ -281,19 +291,20 @@ fn run_tui_event_loop(
}
}
KeyCode::Char(' ') => {
if app.selected_tab == 0 && !app.running {
if app.selected_tab == 0 && !app.running && !app.job_selection_mode {
app.toggle_selected();
}
}
KeyCode::Enter => {
match app.selected_tab {
0 => {
// In workflows tab, Enter runs the selected workflow
if !app.running {
if let Some(idx) = app.workflow_list_state.selected() {
app.workflows[idx].selected = true;
app.queue_selected_for_execution();
app.start_execution();
if app.job_selection_mode {
// In job selection mode, run the selected job
app.select_job_and_run();
} else {
// Enter job selection mode for the selected workflow
app.enter_job_selection_mode();
}
}
}
@@ -336,9 +347,14 @@ fn run_tui_event_loop(
}
KeyCode::Char('a') => {
if !app.running {
// Select all workflows
for workflow in &mut app.workflows {
workflow.selected = true;
if app.job_selection_mode {
// In job selection mode, run all jobs
app.run_all_jobs();
} else {
// Select all workflows
for workflow in &mut app.workflows {
workflow.selected = true;
}
}
}
}

View File

@@ -10,6 +10,7 @@ use ratatui::widgets::{ListState, TableState};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use wrkflw_executor::{JobStatus, RuntimeType, StepStatus};
use wrkflw_parser::workflow::parse_workflow;
/// Application state
pub struct App {
@@ -51,6 +52,12 @@ pub struct App {
pub processed_logs: Vec<ProcessedLogEntry>,
pub logs_need_update: bool, // Flag to trigger log processing
pub last_system_logs_count: usize, // Track system log changes
// Job selection mode
pub job_selection_mode: bool, // Are we viewing jobs of a workflow?
pub available_jobs: Vec<String>, // Job names parsed from selected workflow
pub selected_job_index: usize, // Cursor in job selection list
pub target_job: Option<String>, // Job to execute (None = all)
}
impl App {
@@ -220,6 +227,12 @@ impl App {
processed_logs: Vec::new(),
logs_need_update: true,
last_system_logs_count: 0,
// Job selection mode
job_selection_mode: false,
available_jobs: Vec::new(),
selected_job_index: 0,
target_job: None,
}
}
@@ -591,6 +604,9 @@ impl App {
self.current_execution = None;
}
}
// Reset target_job after execution completes
self.target_job = None;
}
// Get next workflow for execution
@@ -621,6 +637,110 @@ impl App {
Some(next)
}
// Enter job selection mode for the currently selected workflow
pub fn enter_job_selection_mode(&mut self) {
if let Some(idx) = self.workflow_list_state.selected() {
if idx < self.workflows.len() {
let workflow_path = &self.workflows[idx].path;
match parse_workflow(workflow_path) {
Ok(workflow_def) => {
let mut job_names: Vec<String> =
workflow_def.jobs.keys().cloned().collect();
job_names.sort();
if job_names.is_empty() {
self.add_timestamped_log(&format!(
"No jobs found in workflow '{}'",
self.workflows[idx].name
));
return;
}
self.available_jobs = job_names;
self.selected_job_index = 0;
self.job_selection_mode = true;
}
Err(e) => {
self.add_timestamped_log(&format!(
"Failed to parse workflow '{}': {}",
self.workflows[idx].name, e
));
}
}
}
}
}
// Exit job selection mode back to workflow list
pub fn exit_job_selection_mode(&mut self) {
self.job_selection_mode = false;
self.available_jobs.clear();
self.selected_job_index = 0;
self.target_job = None;
}
// Navigate to next job in selection list
pub fn next_available_job(&mut self) {
if !self.available_jobs.is_empty() {
self.selected_job_index = (self.selected_job_index + 1) % self.available_jobs.len();
}
}
// Navigate to previous job in selection list
pub fn previous_available_job(&mut self) {
if !self.available_jobs.is_empty() {
if self.selected_job_index == 0 {
self.selected_job_index = self.available_jobs.len() - 1;
} else {
self.selected_job_index -= 1;
}
}
}
// Select a specific job and run it
pub fn select_job_and_run(&mut self) {
if self.selected_job_index < self.available_jobs.len() {
let job_name = self.available_jobs[self.selected_job_index].clone();
self.target_job = Some(job_name.clone());
self.add_timestamped_log(&format!("Running job '{}'", job_name));
// Queue the workflow that was selected before entering job selection mode
if let Some(idx) = self.workflow_list_state.selected() {
if idx < self.workflows.len() {
self.workflows[idx].selected = true;
if !self.execution_queue.contains(&idx) {
self.execution_queue.push(idx);
}
}
}
self.job_selection_mode = false;
self.available_jobs.clear();
self.selected_job_index = 0;
self.start_execution();
}
}
// Run all jobs in the workflow (from job selection mode)
pub fn run_all_jobs(&mut self) {
self.target_job = None;
self.add_timestamped_log("Running all jobs");
if let Some(idx) = self.workflow_list_state.selected() {
if idx < self.workflows.len() {
self.workflows[idx].selected = true;
if !self.execution_queue.contains(&idx) {
self.execution_queue.push(idx);
}
}
}
self.job_selection_mode = false;
self.available_jobs.clear();
self.selected_job_index = 0;
self.start_execution();
}
// Toggle detailed view mode
pub fn toggle_detailed_view(&mut self) {
self.detailed_view = !self.detailed_view;

View File

@@ -470,6 +470,7 @@ pub fn start_next_workflow_execution(
let validation_mode = app.validation_mode;
let preserve_containers_on_failure = app.preserve_containers_on_failure;
let show_action_messages = app.show_action_messages;
let target_job = app.target_job.clone();
// Update workflow status and add execution details
app.workflows[next_idx].status = WorkflowStatus::Running;
@@ -544,7 +545,7 @@ pub fn start_next_workflow_execution(
preserve_containers_on_failure,
secrets_config: None, // Use default secrets configuration
show_action_messages,
target_job: None,
target_job,
};
let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| {

View File

@@ -138,6 +138,50 @@ pub fn render_help_content(
Span::raw(" - Trigger remote workflow"),
]),
Line::from(""),
Line::from(Span::styled(
"🎯 JOB SELECTION",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"Enter",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - View jobs in workflow"),
]),
Line::from(vec![
Span::styled(
"Enter (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Run selected job"),
]),
Line::from(vec![
Span::styled(
"a (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Run all jobs"),
]),
Line::from(vec![
Span::styled(
"Esc (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Back to workflow list"),
]),
Line::from(""),
Line::from(Span::styled(
"🔧 EXECUTION MODES",
Style::default()

View File

@@ -144,20 +144,22 @@ pub fn render_status_bar(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App,
status_items.push(Span::raw(" "));
let help_text: String = match app.selected_tab {
0 => {
if let Some(idx) = app.workflow_list_state.selected() {
if app.job_selection_mode {
"[Enter] Run job [a] Run all jobs [Esc] Back to workflows".to_string()
} else if let Some(idx) = app.workflow_list_state.selected() {
if idx < app.workflows.len() {
let workflow = &app.workflows[idx];
match workflow.status {
crate::models::WorkflowStatus::NotStarted => "[Space] Toggle selection [Enter] Run selected [r] Run all selected [t] Trigger Workflow [Shift+R] Reset workflow".to_string(),
crate::models::WorkflowStatus::Running => "[Space] Toggle selection [Enter] Run selected [r] Run all selected (Workflow running...)".to_string(),
crate::models::WorkflowStatus::Success | crate::models::WorkflowStatus::Failed | crate::models::WorkflowStatus::Skipped => "[Space] Toggle selection [Enter] Run selected [r] Run all selected [Shift+R] Reset workflow".to_string(),
crate::models::WorkflowStatus::NotStarted => "[Space] Toggle selection [Enter] Select jobs [r] Run all selected [t] Trigger Workflow [Shift+R] Reset workflow".to_string(),
crate::models::WorkflowStatus::Running => "[Space] Toggle selection [Enter] Select jobs [r] Run all selected (Workflow running...)".to_string(),
crate::models::WorkflowStatus::Success | crate::models::WorkflowStatus::Failed | crate::models::WorkflowStatus::Skipped => "[Space] Toggle selection [Enter] Select jobs [r] Run all selected [Shift+R] Reset workflow".to_string(),
}
} else {
"[Space] Toggle selection [Enter] Run selected [r] Run all selected"
"[Space] Toggle selection [Enter] Select jobs [r] Run all selected"
.to_string()
}
} else {
"[Space] Toggle selection [Enter] Run selected [r] Run all selected".to_string()
"[Space] Toggle selection [Enter] Select jobs [r] Run all selected".to_string()
}
}
1 => {

View File

@@ -17,6 +17,14 @@ pub fn render_workflows_tab(
app: &mut App,
area: Rect,
) {
if app.job_selection_mode {
render_job_selection(f, app, area);
} else {
render_workflow_list(f, app, area);
}
}
fn render_workflow_list(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, area: Rect) {
// Create a more structured layout for the workflow tab
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -42,7 +50,7 @@ pub fn render_workflows_tab(
Span::styled("Space", Style::default().fg(Color::Cyan)),
Span::raw(": Toggle selection "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Run "),
Span::raw(": Select jobs "),
Span::styled("t", Style::default().fg(Color::Cyan)),
Span::raw(": Trigger remotely"),
]),
@@ -63,8 +71,6 @@ pub fn render_workflows_tab(
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
// Normal style definition removed as it was unused
let header_cells = ["", "Status", "Workflow Name", "Path"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
@@ -135,3 +141,91 @@ pub fn render_workflows_tab(
// Update the app list state to match the table state
app.workflow_list_state.select(table_state.selected());
}
fn render_job_selection(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Header with instructions
Constraint::Min(5), // Job list
]
.as_ref(),
)
.margin(1)
.split(area);
// Get workflow name for the header
let workflow_name = app
.workflow_list_state
.selected()
.and_then(|idx| app.workflows.get(idx))
.map(|w| w.name.as_str())
.unwrap_or("Unknown");
let header_text = vec![
Line::from(vec![Span::styled(
format!("Jobs in '{}'", workflow_name),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Run job "),
Span::styled("a", Style::default().fg(Color::Cyan)),
Span::raw(": Run all "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(": Back"),
]),
];
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.alignment(Alignment::Center);
f.render_widget(header, chunks[0]);
let selected_style = Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
let header_cells = ["#", "Job Name"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
let header = Row::new(header_cells)
.style(Style::default().add_modifier(Modifier::BOLD))
.height(1);
let rows = app.available_jobs.iter().enumerate().map(|(i, job_name)| {
Row::new(vec![
Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray)),
Cell::from(job_name.clone()),
])
});
let jobs_table = Table::new(rows)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Jobs ", Style::default().fg(Color::Yellow))),
)
.highlight_style(selected_style)
.highlight_symbol("» ")
.widths(&[
Constraint::Length(4), // Number column
Constraint::Percentage(90), // Job name column
]);
let mut table_state = TableState::default();
table_state.select(Some(app.selected_job_index));
f.render_stateful_widget(jobs_table, chunks[1], &mut table_state);
}