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 (#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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3542,6 +3542,7 @@ dependencies = [
|
|||||||
"wrkflw-github",
|
"wrkflw-github",
|
||||||
"wrkflw-logging",
|
"wrkflw-logging",
|
||||||
"wrkflw-models",
|
"wrkflw-models",
|
||||||
|
"wrkflw-parser",
|
||||||
"wrkflw-utils",
|
"wrkflw-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ tui = ["dep:crossterm", "dep:ratatui"]
|
|||||||
wrkflw-models.workspace = true
|
wrkflw-models.workspace = true
|
||||||
wrkflw-evaluator.workspace = true
|
wrkflw-evaluator.workspace = true
|
||||||
wrkflw-executor.workspace = true
|
wrkflw-executor.workspace = true
|
||||||
|
wrkflw-parser.workspace = true
|
||||||
wrkflw-logging.workspace = true
|
wrkflw-logging.workspace = true
|
||||||
wrkflw-utils.workspace = true
|
wrkflw-utils.workspace = true
|
||||||
wrkflw-github.workspace = true
|
wrkflw-github.workspace = true
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use crate::handlers::workflow::start_next_workflow_execution;
|
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::utils::load_workflows;
|
||||||
use crate::views::render_ui;
|
use crate::views::render_ui;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
@@ -66,16 +66,22 @@ pub async fn run_wrkflw_tui(
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
|
|
||||||
|
let job_names = crate::utils::extract_job_names(path);
|
||||||
|
|
||||||
app.workflows = vec![Workflow {
|
app.workflows = vec![Workflow {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
selected: true,
|
selected: true,
|
||||||
status: WorkflowStatus::NotStarted,
|
status: WorkflowStatus::NotStarted,
|
||||||
execution_details: None,
|
execution_details: None,
|
||||||
|
job_names,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
// Queue the single workflow for execution
|
// 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();
|
app.start_execution();
|
||||||
|
|
||||||
// Return parent dir or current dir if no parent
|
// Return parent dir or current dir if no parent
|
||||||
@@ -221,7 +227,9 @@ fn run_tui_event_loop(
|
|||||||
break Ok(());
|
break Ok(());
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
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;
|
app.detailed_view = false;
|
||||||
} else if app.show_help {
|
} else if app.show_help {
|
||||||
app.show_help = false;
|
app.show_help = false;
|
||||||
@@ -252,7 +260,11 @@ fn run_tui_event_loop(
|
|||||||
} else if app.selected_tab == 3 {
|
} else if app.selected_tab == 3 {
|
||||||
app.scroll_help_up();
|
app.scroll_help_up();
|
||||||
} else if app.selected_tab == 0 {
|
} 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 {
|
} else if app.selected_tab == 1 {
|
||||||
if app.detailed_view {
|
if app.detailed_view {
|
||||||
app.previous_step();
|
app.previous_step();
|
||||||
@@ -271,7 +283,11 @@ fn run_tui_event_loop(
|
|||||||
} else if app.selected_tab == 3 {
|
} else if app.selected_tab == 3 {
|
||||||
app.scroll_help_down();
|
app.scroll_help_down();
|
||||||
} else if app.selected_tab == 0 {
|
} 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 {
|
} else if app.selected_tab == 1 {
|
||||||
if app.detailed_view {
|
if app.detailed_view {
|
||||||
app.next_step();
|
app.next_step();
|
||||||
@@ -281,19 +297,30 @@ fn run_tui_event_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
if app.selected_tab == 0 && !app.running {
|
if app.selected_tab == 0 && !app.running && !app.job_selection_mode {
|
||||||
app.toggle_selected();
|
app.toggle_selected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
match app.selected_tab {
|
match app.selected_tab {
|
||||||
0 => {
|
0 => {
|
||||||
// In workflows tab, Enter runs the selected workflow
|
|
||||||
if !app.running {
|
if !app.running {
|
||||||
if let Some(idx) = app.workflow_list_state.selected() {
|
if app.job_selection_mode {
|
||||||
app.workflows[idx].selected = true;
|
// In job selection mode, run the selected job
|
||||||
app.queue_selected_for_execution();
|
if app.selected_job_index < app.available_jobs.len() {
|
||||||
app.start_execution();
|
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);
|
render_ui(f, app);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
} else if !app.running {
|
} else if !app.running && !app.job_selection_mode {
|
||||||
app.queue_selected_for_execution();
|
app.queue_selected_for_execution();
|
||||||
app.start_execution();
|
app.start_execution();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('a') => {
|
KeyCode::Char('a') => {
|
||||||
if !app.running {
|
if !app.running {
|
||||||
// Select all workflows
|
if app.job_selection_mode {
|
||||||
for workflow in &mut app.workflows {
|
// In job selection mode, run all jobs
|
||||||
workflow.selected = true;
|
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') => {
|
KeyCode::Char('e') => {
|
||||||
if !app.running {
|
if !app.running {
|
||||||
app.toggle_emulation_mode();
|
app.toggle_emulation_mode();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// App state for the UI
|
// App state for the UI
|
||||||
use crate::log_processor::{LogProcessingRequest, LogProcessor, ProcessedLogEntry};
|
use crate::log_processor::{LogProcessingRequest, LogProcessor, ProcessedLogEntry};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
ExecutionResultMsg, JobExecution, LogFilterLevel, StepExecution, Workflow, WorkflowExecution,
|
ExecutionResultMsg, JobExecution, LogFilterLevel, QueuedExecution, StepExecution, Workflow,
|
||||||
WorkflowStatus,
|
WorkflowExecution, WorkflowStatus,
|
||||||
};
|
};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -22,7 +22,7 @@ pub struct App {
|
|||||||
pub validation_mode: bool,
|
pub validation_mode: bool,
|
||||||
pub preserve_containers_on_failure: bool,
|
pub preserve_containers_on_failure: bool,
|
||||||
pub show_action_messages: 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 current_execution: Option<usize>,
|
||||||
pub logs: Vec<String>, // Overall execution logs
|
pub logs: Vec<String>, // Overall execution logs
|
||||||
pub log_scroll: usize, // Scrolling position for logs
|
pub log_scroll: usize, // Scrolling position for logs
|
||||||
@@ -51,6 +51,11 @@ pub struct App {
|
|||||||
pub processed_logs: Vec<ProcessedLogEntry>,
|
pub processed_logs: Vec<ProcessedLogEntry>,
|
||||||
pub logs_need_update: bool, // Flag to trigger log processing
|
pub logs_need_update: bool, // Flag to trigger log processing
|
||||||
pub last_system_logs_count: usize, // Track system log changes
|
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 {
|
impl App {
|
||||||
@@ -220,6 +225,11 @@ impl App {
|
|||||||
processed_logs: Vec::new(),
|
processed_logs: Vec::new(),
|
||||||
logs_need_update: true,
|
logs_need_update: true,
|
||||||
last_system_logs_count: 0,
|
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
|
// Queue selected workflows for execution
|
||||||
pub fn queue_selected_for_execution(&mut self) {
|
pub fn queue_selected_for_execution(&mut self) {
|
||||||
if let Some(idx) = self.workflow_list_state.selected() {
|
if let Some(idx) = self.workflow_list_state.selected() {
|
||||||
if idx < self.workflows.len() && !self.execution_queue.contains(&idx) {
|
if idx < self.workflows.len()
|
||||||
self.execution_queue.push(idx);
|
&& !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!(
|
self.add_timestamped_log(&format!(
|
||||||
"Added '{}' to execution queue. Press 'Enter' to start.",
|
"Added '{}' to execution queue. Press 'Enter' to start.",
|
||||||
self.workflows[idx].name
|
self.workflows[idx].name
|
||||||
@@ -594,12 +609,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get next workflow for execution
|
// 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() {
|
if self.execution_queue.is_empty() {
|
||||||
return None;
|
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.workflows[next].status = WorkflowStatus::Running;
|
||||||
self.current_execution = Some(next);
|
self.current_execution = Some(next);
|
||||||
self.logs
|
self.logs
|
||||||
@@ -618,7 +635,76 @@ impl App {
|
|||||||
progress: 0.0, // Just started
|
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
|
// Toggle detailed view mode
|
||||||
@@ -1063,3 +1149,203 @@ impl App {
|
|||||||
self.add_log(formatted_message);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ pub fn start_next_workflow_execution(
|
|||||||
tx_clone: &mpsc::Sender<ExecutionResultMsg>,
|
tx_clone: &mpsc::Sender<ExecutionResultMsg>,
|
||||||
verbose: bool,
|
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);
|
app.current_execution = Some(next_idx);
|
||||||
let tx_clone_inner = tx_clone.clone();
|
let tx_clone_inner = tx_clone.clone();
|
||||||
let workflow_path = app.workflows[next_idx].path.clone();
|
let workflow_path = app.workflows[next_idx].path.clone();
|
||||||
@@ -544,7 +544,7 @@ pub fn start_next_workflow_execution(
|
|||||||
preserve_containers_on_failure,
|
preserve_containers_on_failure,
|
||||||
secrets_config: None, // Use default secrets configuration
|
secrets_config: None, // Use default secrets configuration
|
||||||
show_action_messages,
|
show_action_messages,
|
||||||
target_job: None,
|
target_job,
|
||||||
};
|
};
|
||||||
|
|
||||||
let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| {
|
let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ pub struct Workflow {
|
|||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
pub status: WorkflowStatus,
|
pub status: WorkflowStatus,
|
||||||
pub execution_details: Option<WorkflowExecution>,
|
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
|
/// Status of a workflow
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
// UI utilities
|
// UI utilities
|
||||||
use crate::models::{Workflow, WorkflowStatus};
|
use crate::models::{Workflow, WorkflowStatus};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use wrkflw_parser::workflow::parse_workflow;
|
||||||
use wrkflw_utils::is_workflow_file;
|
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
|
/// Find and load all workflow files in a directory
|
||||||
pub fn load_workflows(dir_path: &Path) -> Vec<Workflow> {
|
pub fn load_workflows(dir_path: &Path) -> Vec<Workflow> {
|
||||||
let mut workflows = Vec::new();
|
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(),
|
|fname| fname.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let job_names = extract_job_names(&path);
|
||||||
|
|
||||||
workflows.push(Workflow {
|
workflows.push(Workflow {
|
||||||
name,
|
name,
|
||||||
path,
|
path,
|
||||||
selected: false,
|
selected: false,
|
||||||
status: WorkflowStatus::NotStarted,
|
status: WorkflowStatus::NotStarted,
|
||||||
execution_details: None,
|
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
|
// Look for .gitlab-ci.yml in the repository root
|
||||||
let gitlab_ci_path = PathBuf::from(".gitlab-ci.yml");
|
let gitlab_ci_path = PathBuf::from(".gitlab-ci.yml");
|
||||||
if gitlab_ci_path.exists() && gitlab_ci_path.is_file() {
|
if gitlab_ci_path.exists() && gitlab_ci_path.is_file() {
|
||||||
|
let job_names = extract_job_names(&gitlab_ci_path);
|
||||||
|
|
||||||
workflows.push(Workflow {
|
workflows.push(Workflow {
|
||||||
name: "gitlab-ci".to_string(),
|
name: "gitlab-ci".to_string(),
|
||||||
path: gitlab_ci_path,
|
path: gitlab_ci_path,
|
||||||
selected: false,
|
selected: false,
|
||||||
status: WorkflowStatus::NotStarted,
|
status: WorkflowStatus::NotStarted,
|
||||||
execution_details: None,
|
execution_details: None,
|
||||||
|
job_names,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,50 @@ pub fn render_help_content(
|
|||||||
Span::raw(" - Trigger remote workflow"),
|
Span::raw(" - Trigger remote workflow"),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
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(
|
Line::from(Span::styled(
|
||||||
"🔧 EXECUTION MODES",
|
"🔧 EXECUTION MODES",
|
||||||
Style::default()
|
Style::default()
|
||||||
|
|||||||
@@ -144,20 +144,21 @@ pub fn render_status_bar(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App,
|
|||||||
status_items.push(Span::raw(" "));
|
status_items.push(Span::raw(" "));
|
||||||
let help_text: String = match app.selected_tab {
|
let help_text: String = match app.selected_tab {
|
||||||
0 => {
|
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() {
|
if idx < app.workflows.len() {
|
||||||
let workflow = &app.workflows[idx];
|
let workflow = &app.workflows[idx];
|
||||||
match workflow.status {
|
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::NotStarted => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected [t] Trigger [Shift+R] Reset".to_string(),
|
||||||
crate::models::WorkflowStatus::Running => "[Space] Toggle selection [Enter] Run selected [r] Run all selected (Workflow running...)".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 selection [Enter] Run selected [r] Run all selected [Shift+R] Reset workflow".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 {
|
} else {
|
||||||
"[Space] Toggle selection [Enter] Run selected [r] Run all selected"
|
"[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected".to_string()
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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 => {
|
1 => {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ pub fn render_workflows_tab(
|
|||||||
app: &mut App,
|
app: &mut App,
|
||||||
area: Rect,
|
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
|
// Create a more structured layout for the workflow tab
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -40,9 +48,11 @@ pub fn render_workflows_tab(
|
|||||||
)]),
|
)]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Space", Style::default().fg(Color::Cyan)),
|
Span::styled("Space", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw(": Toggle selection "),
|
Span::raw(": Toggle "),
|
||||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw(": Run "),
|
Span::raw(": Run "),
|
||||||
|
Span::styled("J", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(": Select jobs "),
|
||||||
Span::styled("t", Style::default().fg(Color::Cyan)),
|
Span::styled("t", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw(": Trigger remotely"),
|
Span::raw(": Trigger remotely"),
|
||||||
]),
|
]),
|
||||||
@@ -63,8 +73,6 @@ pub fn render_workflows_tab(
|
|||||||
.bg(Color::DarkGray)
|
.bg(Color::DarkGray)
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
// Normal style definition removed as it was unused
|
|
||||||
|
|
||||||
let header_cells = ["", "Status", "Workflow Name", "Path"]
|
let header_cells = ["", "Status", "Workflow Name", "Path"]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
|
.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
|
// Update the app list state to match the table state
|
||||||
app.workflow_list_state.select(table_state.selected());
|
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