mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3542,6 +3542,7 @@ dependencies = [
|
||||
"wrkflw-github",
|
||||
"wrkflw-logging",
|
||||
"wrkflw-models",
|
||||
"wrkflw-parser",
|
||||
"wrkflw-utils",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user