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

* 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.

* fix(ui): stop target_job from leaking across queued workflows

The job selection feature added in 8406c60 stored target_job as a
single global field on App, shared across all queued executions.
It turns out that when you use a global mutable field as a
communication channel between "user picked a job" and "executor
should run this job", *any* workflow queued after the first one
silently inherits whatever target_job was set. Confusion ensues.

While at it, the Enter key was repurposed from "run this workflow"
to "enter job selection mode", which means the most common operation
now takes two keypresses instead of one. That's not great.

Also, pressing 'r' during job selection mode would bypass it
entirely and start execution with target_job still set from the
previous selection, leaving the UI in a corrupt state. Please don't
ship unguarded key handlers in modal UIs.

The fix:

- Replace the global target_job with a per-entry QueuedExecution
  struct that carries its own target_job. Each queued workflow now
  owns its execution config. No more ambient mutable state.

- Cache parsed job names in Workflow at discovery time instead of
  re-parsing the YAML file on every Enter keypress.

- Restore Enter to its original "run the workflow" behavior. Job
  selection is now Shift+J, which doesn't steal the primary action
  key.

- Guard 'r' and other keys properly in job_selection_mode.

- Deduplicate select_job_and_run/run_all_jobs into a single
  run_from_job_selection(target: Option<String>) method.

* fix(ui): fix job selection bugs and add tests

The single-file CLI path (`wrkflw --tui <file>`) was hardcoding
job_names to Vec::new(), which means Shift+J would always report
"No jobs found" even when the workflow *obviously* has jobs. Not
great when the whole point of this feature is selecting jobs.

The dedup logic in run_from_job_selection() was checking
workflow_idx equality only, so queueing the same workflow with
different target jobs (run "build", then run "test") would silently
drop the second one. The user clicks a thing, nothing happens.
Confusion ensues.

While at it, extract the job name parsing into a shared
extract_job_names() helper — the same six lines were copy-pasted
in three places, which is exactly how you end up with three
slightly different bugs later.

Add 12 unit tests covering the job selection state machine:
enter/exit mode, navigation wrapping, target_job threading through
the execution queue, and the dedup fix.

* fix(ui): deduplicate job selection cleanup and add edge case tests

run_from_job_selection was manually inlining the same three lines
that exit_job_selection_mode already does. That's the kind of thing
that *will* drift the moment someone adds cleanup logic to one path
but not the other. Let's just call the existing method.

While at it, add tests for two untested edge cases: calling
run_from_job_selection when no workflow is selected (should be a
safe no-op), and entering job selection mode with an out-of-bounds
index (should also be a no-op). Also document the precondition that
callers must check !self.running before calling run_from_job_selection.
This commit is contained in:
Gokul
2026-04-02 15:38:58 +05:30
committed by GitHub
parent f05cbca3b9
commit 14d30b6b57
10 changed files with 527 additions and 35 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

@@ -2,7 +2,7 @@
mod state;
use crate::handlers::workflow::start_next_workflow_execution;
use crate::models::{ExecutionResultMsg, Workflow, WorkflowStatus};
use crate::models::{ExecutionResultMsg, QueuedExecution, Workflow, WorkflowStatus};
use crate::utils::load_workflows;
use crate::views::render_ui;
use chrono::Local;
@@ -66,16 +66,22 @@ pub async fn run_wrkflw_tui(
.to_string_lossy()
.into_owned();
let job_names = crate::utils::extract_job_names(path);
app.workflows = vec![Workflow {
name: name.clone(),
path: path.clone(),
selected: true,
status: WorkflowStatus::NotStarted,
execution_details: None,
job_names,
}];
// Queue the single workflow for execution
app.execution_queue = vec![0];
app.execution_queue = vec![QueuedExecution {
workflow_idx: 0,
target_job: None,
}];
app.start_execution();
// Return parent dir or current dir if no parent
@@ -221,7 +227,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 +260,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 +283,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 +297,30 @@ 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
if app.selected_job_index < app.available_jobs.len() {
let job_name =
app.available_jobs[app.selected_job_index].clone();
app.run_from_job_selection(Some(job_name));
}
} else {
// Run the selected workflow directly
if let Some(idx) = app.workflow_list_state.selected() {
if idx < app.workflows.len() {
app.workflows[idx].selected = true;
app.queue_selected_for_execution();
app.start_execution();
}
}
}
}
}
@@ -329,19 +356,30 @@ fn run_tui_event_loop(
render_ui(f, app);
})?;
}
} else if !app.running {
} else if !app.running && !app.job_selection_mode {
app.queue_selected_for_execution();
app.start_execution();
}
}
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_from_job_selection(None);
} else {
// Select all workflows
for workflow in &mut app.workflows {
workflow.selected = true;
}
}
}
}
KeyCode::Char('J') => {
// Enter job selection mode for selected workflow
if !app.running && app.selected_tab == 0 && !app.job_selection_mode {
app.enter_job_selection_mode();
}
}
KeyCode::Char('e') => {
if !app.running {
app.toggle_emulation_mode();

View File

@@ -1,8 +1,8 @@
// App state for the UI
use crate::log_processor::{LogProcessingRequest, LogProcessor, ProcessedLogEntry};
use crate::models::{
ExecutionResultMsg, JobExecution, LogFilterLevel, StepExecution, Workflow, WorkflowExecution,
WorkflowStatus,
ExecutionResultMsg, JobExecution, LogFilterLevel, QueuedExecution, StepExecution, Workflow,
WorkflowExecution, WorkflowStatus,
};
use chrono::Local;
use crossterm::event::KeyCode;
@@ -22,7 +22,7 @@ pub struct App {
pub validation_mode: bool,
pub preserve_containers_on_failure: bool,
pub show_action_messages: bool,
pub execution_queue: Vec<usize>, // Indices of workflows to execute
pub execution_queue: Vec<QueuedExecution>, // Workflows queued for execution
pub current_execution: Option<usize>,
pub logs: Vec<String>, // Overall execution logs
pub log_scroll: usize, // Scrolling position for logs
@@ -51,6 +51,11 @@ 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 from selected workflow
pub selected_job_index: usize, // Cursor in job selection list
}
impl App {
@@ -220,6 +225,11 @@ 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,
}
}
@@ -443,8 +453,13 @@ impl App {
// Queue selected workflows for execution
pub fn queue_selected_for_execution(&mut self) {
if let Some(idx) = self.workflow_list_state.selected() {
if idx < self.workflows.len() && !self.execution_queue.contains(&idx) {
self.execution_queue.push(idx);
if idx < self.workflows.len()
&& !self.execution_queue.iter().any(|e| e.workflow_idx == idx)
{
self.execution_queue.push(QueuedExecution {
workflow_idx: idx,
target_job: None,
});
self.add_timestamped_log(&format!(
"Added '{}' to execution queue. Press 'Enter' to start.",
self.workflows[idx].name
@@ -594,12 +609,14 @@ impl App {
}
// Get next workflow for execution
pub fn get_next_workflow_to_execute(&mut self) -> Option<usize> {
pub fn get_next_workflow_to_execute(&mut self) -> Option<(usize, Option<String>)> {
if self.execution_queue.is_empty() {
return None;
}
let next = self.execution_queue.remove(0);
let entry = self.execution_queue.remove(0);
let next = entry.workflow_idx;
let target_job = entry.target_job;
self.workflows[next].status = WorkflowStatus::Running;
self.current_execution = Some(next);
self.logs
@@ -618,7 +635,76 @@ impl App {
progress: 0.0, // Just started
});
Some(next)
Some((next, target_job))
}
// 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 job_names = &self.workflows[idx].job_names;
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.clone();
self.selected_job_index = 0;
self.job_selection_mode = true;
}
}
}
// 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;
}
// 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;
}
}
}
// Run from job selection mode with an optional target job.
// Callers must ensure `!self.running` before calling.
pub fn run_from_job_selection(&mut self, target_job: Option<String>) {
if let Some(ref name) = target_job {
self.add_timestamped_log(&format!("Running job '{}'", name));
} else {
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;
self.execution_queue.push(QueuedExecution {
workflow_idx: idx,
target_job,
});
}
}
self.exit_job_selection_mode();
self.start_execution();
}
// Toggle detailed view mode
@@ -1063,3 +1149,203 @@ impl App {
self.add_log(formatted_message);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_app() -> App {
let (tx, _rx) = mpsc::channel();
let mut app = App::new(RuntimeType::Emulation, tx, false, false);
app.workflows = vec![
Workflow {
name: "ci".to_string(),
path: PathBuf::from("ci.yml"),
selected: false,
status: WorkflowStatus::NotStarted,
execution_details: None,
job_names: vec!["build".to_string(), "lint".to_string(), "test".to_string()],
},
Workflow {
name: "deploy".to_string(),
path: PathBuf::from("deploy.yml"),
selected: false,
status: WorkflowStatus::NotStarted,
execution_details: None,
job_names: vec![],
},
];
app.workflow_list_state.select(Some(0));
app
}
#[test]
fn enter_job_selection_mode_populates_jobs() {
let mut app = make_app();
app.enter_job_selection_mode();
assert!(app.job_selection_mode);
assert_eq!(app.available_jobs, vec!["build", "lint", "test"]);
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn enter_job_selection_mode_no_jobs_stays_in_normal_mode() {
let mut app = make_app();
app.workflow_list_state.select(Some(1)); // deploy has no jobs
app.enter_job_selection_mode();
assert!(!app.job_selection_mode);
assert!(app.available_jobs.is_empty());
}
#[test]
fn exit_job_selection_mode_clears_state() {
let mut app = make_app();
app.enter_job_selection_mode();
app.selected_job_index = 2;
app.exit_job_selection_mode();
assert!(!app.job_selection_mode);
assert!(app.available_jobs.is_empty());
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn next_available_job_wraps_around() {
let mut app = make_app();
app.enter_job_selection_mode();
app.next_available_job(); // 0 -> 1
assert_eq!(app.selected_job_index, 1);
app.next_available_job(); // 1 -> 2
assert_eq!(app.selected_job_index, 2);
app.next_available_job(); // 2 -> 0 (wrap)
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn previous_available_job_wraps_around() {
let mut app = make_app();
app.enter_job_selection_mode();
app.previous_available_job(); // 0 -> 2 (wrap)
assert_eq!(app.selected_job_index, 2);
app.previous_available_job(); // 2 -> 1
assert_eq!(app.selected_job_index, 1);
app.previous_available_job(); // 1 -> 0
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn navigate_jobs_noop_when_empty() {
let mut app = make_app();
// Don't enter job selection mode — available_jobs is empty
app.next_available_job();
assert_eq!(app.selected_job_index, 0);
app.previous_available_job();
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn run_from_job_selection_queues_with_target_job() {
let mut app = make_app();
app.enter_job_selection_mode();
app.run_from_job_selection(Some("build".to_string()));
assert!(!app.job_selection_mode);
assert!(app.available_jobs.is_empty());
assert_eq!(app.execution_queue.len(), 1);
assert_eq!(app.execution_queue[0].workflow_idx, 0);
assert_eq!(app.execution_queue[0].target_job, Some("build".to_string()));
}
#[test]
fn run_from_job_selection_none_queues_all_jobs() {
let mut app = make_app();
app.enter_job_selection_mode();
app.run_from_job_selection(None);
assert_eq!(app.execution_queue.len(), 1);
assert_eq!(app.execution_queue[0].target_job, None);
}
#[test]
fn run_from_job_selection_allows_same_workflow_different_jobs() {
let mut app = make_app();
app.enter_job_selection_mode();
app.run_from_job_selection(Some("build".to_string()));
// Drain the queue to simulate the executor consuming it
app.execution_queue.clear();
app.current_execution = None;
app.running = false;
app.enter_job_selection_mode();
app.run_from_job_selection(Some("test".to_string()));
assert_eq!(app.execution_queue.len(), 1);
assert_eq!(app.execution_queue[0].target_job, Some("test".to_string()));
}
#[test]
fn get_next_workflow_to_execute_threads_target_job() {
let mut app = make_app();
app.execution_queue.push(QueuedExecution {
workflow_idx: 0,
target_job: Some("lint".to_string()),
});
let result = app.get_next_workflow_to_execute();
assert!(result.is_some());
let (idx, target) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(target, Some("lint".to_string()));
assert!(app.execution_queue.is_empty());
}
#[test]
fn get_next_workflow_to_execute_returns_none_when_empty() {
let mut app = make_app();
assert!(app.get_next_workflow_to_execute().is_none());
}
#[test]
fn single_job_navigation_wraps_correctly() {
let mut app = make_app();
app.available_jobs = vec!["only-job".to_string()];
app.selected_job_index = 0;
app.next_available_job(); // 0 -> 0 (only one item)
assert_eq!(app.selected_job_index, 0);
app.previous_available_job(); // 0 -> 0
assert_eq!(app.selected_job_index, 0);
}
#[test]
fn run_from_job_selection_noop_when_no_workflow_selected() {
let mut app = make_app();
app.workflow_list_state.select(None);
app.job_selection_mode = true;
app.available_jobs = vec!["build".to_string()];
app.run_from_job_selection(Some("build".to_string()));
assert!(app.execution_queue.is_empty());
assert!(!app.job_selection_mode);
assert!(app.available_jobs.is_empty());
}
#[test]
fn enter_job_selection_mode_noop_when_index_out_of_bounds() {
let mut app = make_app();
app.workflow_list_state.select(Some(99)); // out of bounds
app.enter_job_selection_mode();
assert!(!app.job_selection_mode);
assert!(app.available_jobs.is_empty());
}
}

View File

@@ -391,7 +391,7 @@ pub fn start_next_workflow_execution(
tx_clone: &mpsc::Sender<ExecutionResultMsg>,
verbose: bool,
) {
if let Some(next_idx) = app.get_next_workflow_to_execute() {
if let Some((next_idx, target_job)) = app.get_next_workflow_to_execute() {
app.current_execution = Some(next_idx);
let tx_clone_inner = tx_clone.clone();
let workflow_path = app.workflows[next_idx].path.clone();
@@ -544,7 +544,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

@@ -13,6 +13,13 @@ pub struct Workflow {
pub selected: bool,
pub status: WorkflowStatus,
pub execution_details: Option<WorkflowExecution>,
pub job_names: Vec<String>,
}
/// A workflow queued for execution, with its own target job
pub struct QueuedExecution {
pub workflow_idx: usize,
pub target_job: Option<String>,
}
/// Status of a workflow

View File

@@ -1,8 +1,20 @@
// UI utilities
use crate::models::{Workflow, WorkflowStatus};
use std::path::{Path, PathBuf};
use wrkflw_parser::workflow::parse_workflow;
use wrkflw_utils::is_workflow_file;
/// Parse a workflow file and return sorted job names, or an empty vec on failure.
pub fn extract_job_names(path: &Path) -> Vec<String> {
parse_workflow(path)
.map(|wf| {
let mut names: Vec<String> = wf.jobs.keys().cloned().collect();
names.sort();
names
})
.unwrap_or_default()
}
/// Find and load all workflow files in a directory
pub fn load_workflows(dir_path: &Path) -> Vec<Workflow> {
let mut workflows = Vec::new();
@@ -21,12 +33,15 @@ pub fn load_workflows(dir_path: &Path) -> Vec<Workflow> {
|fname| fname.to_string_lossy().into_owned(),
);
let job_names = extract_job_names(&path);
workflows.push(Workflow {
name,
path,
selected: false,
status: WorkflowStatus::NotStarted,
execution_details: None,
job_names,
});
}
}
@@ -37,12 +52,15 @@ pub fn load_workflows(dir_path: &Path) -> Vec<Workflow> {
// Look for .gitlab-ci.yml in the repository root
let gitlab_ci_path = PathBuf::from(".gitlab-ci.yml");
if gitlab_ci_path.exists() && gitlab_ci_path.is_file() {
let job_names = extract_job_names(&gitlab_ci_path);
workflows.push(Workflow {
name: "gitlab-ci".to_string(),
path: gitlab_ci_path,
selected: false,
status: WorkflowStatus::NotStarted,
execution_details: None,
job_names,
});
}
}

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(
"Shift+J",
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,21 @@ 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 [Enter] Run [J] Select jobs [r] Run selected [t] Trigger [Shift+R] Reset".to_string(),
crate::models::WorkflowStatus::Running => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected (Running...)".to_string(),
crate::models::WorkflowStatus::Success | crate::models::WorkflowStatus::Failed | crate::models::WorkflowStatus::Skipped => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected [Shift+R] Reset".to_string(),
}
} else {
"[Space] Toggle selection [Enter] Run selected [r] Run all selected"
.to_string()
"[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected".to_string()
}
} else {
"[Space] Toggle selection [Enter] Run selected [r] Run all selected".to_string()
"[Space] Toggle [Enter] Run [J] Select jobs [r] Run 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)
@@ -40,9 +48,11 @@ pub fn render_workflows_tab(
)]),
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Cyan)),
Span::raw(": Toggle selection "),
Span::raw(": Toggle "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Run "),
Span::styled("J", Style::default().fg(Color::Cyan)),
Span::raw(": Select jobs "),
Span::styled("t", Style::default().fg(Color::Cyan)),
Span::raw(": Trigger remotely"),
]),
@@ -63,8 +73,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 +143,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);
}