diff --git a/Cargo.lock b/Cargo.lock index 85c2179..747e220 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3596,6 +3596,7 @@ dependencies = [ "serde_json", "serde_yaml", "thiserror", + "wrkflw-logging", "wrkflw-models", ] @@ -3611,6 +3612,7 @@ dependencies = [ "serde_yaml", "thiserror", "urlencoding", + "wrkflw-logging", "wrkflw-models", ] @@ -3737,12 +3739,16 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", + "urlencoding", "wrkflw-evaluator", "wrkflw-executor", "wrkflw-github", + "wrkflw-gitlab", "wrkflw-logging", + "wrkflw-matrix", "wrkflw-models", "wrkflw-parser", + "wrkflw-secrets", "wrkflw-trigger-filter", "wrkflw-utils", ] diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 3c73e1e..edfda2f 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -13,6 +13,7 @@ categories.workspace = true [dependencies] # Internal crates wrkflw-models.workspace = true +wrkflw-logging.workspace = true # External dependencies from workspace serde.workspace = true diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 1842208..edff888 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -113,6 +113,33 @@ pub fn get_repo_info() -> Result { } } +/// Normalize a user-facing workflow identifier into the path segment +/// GitHub's `workflow_dispatch` endpoint expects as `{workflow_file_name}` +/// in `/repos/{owner}/{repo}/actions/workflows/{workflow_file_name}/dispatches`. +/// +/// - Drops any directory prefix: `"release/prod.yml"` → `"prod.yml"`. +/// - Preserves an existing `.yml` or `.yaml` suffix so workflows stored +/// as `.yaml` don't have `.yml` tacked on. +/// - Appends `.yml` when no extension is present so the result is +/// always a valid filename reference. +/// - Returns `None` for inputs with no extractable basename (empty +/// string, bare path separator, trailing slash). +/// +/// Used both by [`trigger_workflow`] to build the real dispatch URL +/// and by the TUI's Trigger-tab curl preview via this crate's public +/// API, so the preview and the actual POST land on the same endpoint. +pub fn workflow_dispatch_path_segment(name: &str) -> Option { + let basename = name.rsplit(['/', '\\']).next()?; + if basename.is_empty() { + return None; + } + if basename.ends_with(".yml") || basename.ends_with(".yaml") { + Some(basename.to_string()) + } else { + Some(format!("{basename}.yml")) + } +} + /// Get the list of available workflows in the repository pub async fn list_workflows(_repo_info: &RepoInfo) -> Result, GithubError> { let workflows_dir = Path::new(".github/workflows"); @@ -164,23 +191,25 @@ pub async fn trigger_workflow( // Get repository information let repo_info = get_repo_info()?; - println!("Repository: {}/{}", repo_info.owner, repo_info.repo); + wrkflw_logging::info(&format!( + "Repository: {}/{}", + repo_info.owner, repo_info.repo + )); // Prepare the request payload let branch_ref = branch.unwrap_or(&repo_info.default_branch); - println!("Using branch: {}", branch_ref); + wrkflw_logging::info(&format!("Using branch: {}", branch_ref)); - // Extract just the workflow name from the path if it's a full path - let workflow_name = if workflow_name.contains('/') { - Path::new(workflow_name) - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))? - } else { - workflow_name - }; + // Normalize the user-facing identifier into the dispatch URL + // segment. Handles subdir prefixes (drop) and missing extensions + // (append `.yml`) so `"ci"`, `"ci.yml"`, `"ci.yaml"`, and + // `"release/prod.yml"` all produce the same URL shape the REST + // API expects. The TUI preview goes through the same helper so + // a copy-pasted curl lands on the same endpoint. + let workflow_segment = workflow_dispatch_path_segment(workflow_name) + .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?; - println!("Using workflow name: {}", workflow_name); + wrkflw_logging::info(&format!("Using workflow file: {}", workflow_segment)); // Create simplified payload let mut payload = serde_json::json!({ @@ -190,16 +219,16 @@ pub async fn trigger_workflow( // Add inputs if provided if let Some(input_map) = inputs { payload["inputs"] = serde_json::json!(input_map); - println!("With inputs: {:?}", input_map); + wrkflw_logging::info(&format!("With inputs: {:?}", input_map)); } // Send the workflow_dispatch event let url = format!( - "https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/dispatches", - repo_info.owner, repo_info.repo, workflow_name + "https://api.github.com/repos/{}/{}/actions/workflows/{}/dispatches", + repo_info.owner, repo_info.repo, workflow_segment ); - println!("Triggering workflow at URL: {}", url); + wrkflw_logging::info(&format!("Triggering workflow at URL: {}", url)); // Create a reqwest client let client = reqwest::Client::new(); @@ -243,65 +272,62 @@ pub async fn trigger_workflow( }); } - println!("Workflow triggered successfully!"); - println!( - "View runs at: https://github.com/{}/{}/actions/workflows/{}.yml", - repo_info.owner, repo_info.repo, workflow_name - ); + wrkflw_logging::info("Workflow triggered successfully!"); + wrkflw_logging::info(&format!( + "View runs at: https://github.com/{}/{}/actions/workflows/{}", + repo_info.owner, repo_info.repo, workflow_segment + )); // Attempt to verify the workflow was actually triggered - match list_recent_workflow_runs(&repo_info, workflow_name, &token).await { + match list_recent_workflow_runs(&repo_info, &workflow_segment, &token).await { Ok(runs) => { if !runs.is_empty() { - println!("\nRecent runs of this workflow:"); + wrkflw_logging::info("Recent runs of this workflow:"); for run in runs.iter().take(3) { - println!( + wrkflw_logging::info(&format!( "- Run #{} ({}): {}", run.get("id").and_then(|id| id.as_u64()).unwrap_or(0), run.get("status") .and_then(|s| s.as_str()) .unwrap_or("unknown"), run.get("html_url").and_then(|u| u.as_str()).unwrap_or("") - ); + )); } } else { - println!("\nNo recent runs found. The workflow might still be initializing."); - println!( + wrkflw_logging::info( + "No recent runs found. The workflow might still be initializing.", + ); + wrkflw_logging::info(&format!( "Check GitHub UI in a few moments: https://github.com/{}/{}/actions", repo_info.owner, repo_info.repo - ); + )); } } Err(e) => { - println!("\nCould not fetch recent workflow runs: {}", e); - println!("This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions", - repo_info.owner, repo_info.repo); + wrkflw_logging::warning(&format!("Could not fetch recent workflow runs: {}", e)); + wrkflw_logging::info(&format!( + "This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions", + repo_info.owner, repo_info.repo + )); } } Ok(()) } -/// List recent workflow runs for a specific workflow +/// List recent workflow runs for a specific workflow. `workflow_segment` +/// must already be the basename-form produced by +/// [`workflow_dispatch_path_segment`] (e.g. `"ci.yml"`), not the raw +/// user-facing identifier — the caller has already normalized it. async fn list_recent_workflow_runs( repo_info: &RepoInfo, - workflow_name: &str, + workflow_segment: &str, token: &str, ) -> Result, GithubError> { - // Extract just the workflow name from the path if it's a full path - let workflow_name = if workflow_name.contains('/') { - Path::new(workflow_name) - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))? - } else { - workflow_name - }; - // Get recent workflow runs via GitHub API let url = format!( - "https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/runs?per_page=5", - repo_info.owner, repo_info.repo, workflow_name + "https://api.github.com/repos/{}/{}/actions/workflows/{}/runs?per_page=5", + repo_info.owner, repo_info.repo, workflow_segment ); let client = reqwest::Client::new(); @@ -338,3 +364,89 @@ async fn list_recent_workflow_runs( Ok(Vec::new()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workflow_dispatch_path_segment_keeps_yml_extension() { + assert_eq!( + workflow_dispatch_path_segment("ci.yml"), + Some("ci.yml".into()) + ); + } + + #[test] + fn workflow_dispatch_path_segment_keeps_yaml_extension() { + // Regression: the old dispatcher unconditionally appended + // `.yml`, turning `ci.yaml` into `ci.yaml.yml` which was a + // guaranteed 404. The helper must round-trip the `.yaml` + // form untouched. + assert_eq!( + workflow_dispatch_path_segment("ci.yaml"), + Some("ci.yaml".into()) + ); + } + + #[test] + fn workflow_dispatch_path_segment_appends_yml_when_missing() { + assert_eq!(workflow_dispatch_path_segment("ci"), Some("ci.yml".into())); + } + + #[test] + fn workflow_dispatch_path_segment_strips_subdir_prefix() { + // GitHub does not support subdirs under `.github/workflows/`, + // but the caller may pass a filesystem-like path. The helper + // drops everything before the final path separator so the + // segment always addresses the workflow by basename. + assert_eq!( + workflow_dispatch_path_segment("release/prod.yml"), + Some("prod.yml".into()) + ); + assert_eq!( + workflow_dispatch_path_segment("deep/nested/ci.yaml"), + Some("ci.yaml".into()) + ); + } + + #[test] + fn workflow_dispatch_path_segment_rejects_inputs_with_no_basename() { + assert_eq!(workflow_dispatch_path_segment(""), None); + assert_eq!(workflow_dispatch_path_segment("/"), None); + assert_eq!(workflow_dispatch_path_segment("foo/"), None); + } + + #[test] + fn workflow_dispatch_path_segment_matches_across_dispatcher_and_preview() { + // The whole point of the helper is that the preview and the + // real dispatcher produce the same URL segment for the same + // input. Pin the identities the Trigger-tab curl preview + // depends on so a refactor in either place can't silently + // drift them apart. + for input in [ + "ci", + "ci.yml", + "ci.yaml", + "release/prod.yml", + "deep/nested/ci.yaml", + "has spaces.yml", + "weird;name.yml", + ] { + let segment = workflow_dispatch_path_segment(input) + .unwrap_or_else(|| panic!("helper produced None for {:?}", input)); + assert!( + !segment.contains('/') && !segment.contains('\\'), + "segment {:?} for input {:?} must be a bare basename", + segment, + input + ); + assert!( + segment.ends_with(".yml") || segment.ends_with(".yaml"), + "segment {:?} for input {:?} must carry an extension", + segment, + input + ); + } + } +} diff --git a/crates/gitlab/Cargo.toml b/crates/gitlab/Cargo.toml index 1bb01a8..a128d56 100644 --- a/crates/gitlab/Cargo.toml +++ b/crates/gitlab/Cargo.toml @@ -13,6 +13,7 @@ categories.workspace = true [dependencies] # Internal crates wrkflw-models.workspace = true +wrkflw-logging.workspace = true # External dependencies lazy_static.workspace = true diff --git a/crates/gitlab/src/lib.rs b/crates/gitlab/src/lib.rs index d1e3a0a..e024637 100644 --- a/crates/gitlab/src/lib.rs +++ b/crates/gitlab/src/lib.rs @@ -143,14 +143,14 @@ pub async fn trigger_pipeline( // Get repository information let repo_info = get_repo_info()?; - println!( + wrkflw_logging::info(&format!( "GitLab Repository: {}/{}", repo_info.namespace, repo_info.project - ); + )); // Prepare the request payload let branch_ref = branch.unwrap_or(&repo_info.default_branch); - println!("Using branch: {}", branch_ref); + wrkflw_logging::info(&format!("Using branch: {}", branch_ref)); // Create simplified payload let mut payload = serde_json::json!({ @@ -171,7 +171,7 @@ pub async fn trigger_pipeline( .collect(); payload["variables"] = serde_json::json!(formatted_vars); - println!("With variables: {:?}", vars_map); + wrkflw_logging::info(&format!("With variables: {:?}", vars_map)); } // URL encode the namespace and project for use in URL @@ -185,7 +185,7 @@ pub async fn trigger_pipeline( encoded_project = encoded_project, ); - println!("Triggering pipeline at URL: {}", url); + wrkflw_logging::info(&format!("Triggering pipeline at URL: {}", url)); // Create a reqwest client let client = reqwest::Client::new(); @@ -236,8 +236,8 @@ pub async fn trigger_pipeline( repo_info.namespace, repo_info.project, pipeline_id ); - println!("Pipeline triggered successfully!"); - println!("View pipeline at: {}", pipeline_url); + wrkflw_logging::info("Pipeline triggered successfully!"); + wrkflw_logging::info(&format!("View pipeline at: {}", pipeline_url)); Ok(()) } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 82b8082..07745a1 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -24,6 +24,9 @@ wrkflw-logging.workspace = true wrkflw-trigger-filter.workspace = true wrkflw-utils.workspace = true wrkflw-github.workspace = true +wrkflw-gitlab.workspace = true +wrkflw-matrix.workspace = true +wrkflw-secrets.workspace = true # External dependencies chrono.workspace = true @@ -37,3 +40,4 @@ serde_json.workspace = true reqwest = { workspace = true, features = ["json"] } regex.workspace = true futures.workspace = true +urlencoding.workspace = true diff --git a/crates/ui/src/app/mod.rs b/crates/ui/src/app/mod.rs index 72de3cd..a41a4b0 100644 --- a/crates/ui/src/app/mod.rs +++ b/crates/ui/src/app/mod.rs @@ -4,7 +4,10 @@ mod state; use crate::handlers::workflow::start_next_workflow_execution; use crate::models::{ExecutionResultMsg, QueuedExecution, Workflow, WorkflowStatus}; use crate::utils::load_workflows; -use crate::views::render_ui; +use crate::views::{ + render_ui, TAB_COUNT, TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER, + TAB_WORKFLOWS, +}; use chrono::Local; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, @@ -18,7 +21,7 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use wrkflw_executor::RuntimeType; -pub use state::App; +pub use state::{Accent, App, TriggerPlatform}; // Main entry point for the TUI interface #[allow(clippy::ptr_arg)] @@ -209,6 +212,11 @@ fn run_tui_event_loop( start_next_workflow_execution(app, tx_clone, verbose); } + // Surface any completed Trigger-tab dispatches on the status bar + // so the user sees the outcome where they fired it, rather than + // having to switch tabs to Logs. + app.drain_trigger_outcomes(); + // Start execution if we have a queued workflow and nothing is currently running if app.running && app.current_execution.is_none() && !app.execution_queue.is_empty() { start_next_workflow_execution(app, tx_clone, verbose); @@ -218,11 +226,47 @@ fn run_tui_event_loop( if event::poll(event_poll_timeout)? { if let Event::Key(key) = event::read()? { // Handle search input first if we're in search mode and logs tab - if app.selected_tab == 2 && app.log_search_active { + if app.selected_tab == TAB_LOGS && app.log_search_active { app.handle_log_search_input(key.code); continue; } + // When the Tweaks overlay is open it is modal: only + // its own shortcuts are honoured, everything else is + // swallowed so keys like `d` or a tab number can't + // silently fire the global handler while the user is + // in edit-mode. `q` is the one exception — quit is + // universally modal-safe in this TUI and swallowing + // it silently was a discoverability trap. + if app.tweaks_open { + match key.code { + KeyCode::Char('q') => { + break Ok(()); + } + KeyCode::Esc | KeyCode::Char(',') => { + app.tweaks_open = false; + } + KeyCode::Char('a') | KeyCode::Char('A') => { + app.tweaks_accent = app.tweaks_accent.next(); + } + _ => {} + } + continue; + } + + // Trigger tab: if a text field (branch or an input + // row) is focused, route printable characters and + // edit keys straight to that field before the global + // key-map fires. Otherwise editing a value like + // "notify=slack" would trip the `s` logs shortcut + // below and jump tabs. + if app.selected_tab == TAB_TRIGGER + && app.trigger_editing() + && app.trigger_handle_input_key(key.code) + { + continue; + } + // Handle help overlay scrolling if app.show_help { match key.code { @@ -263,77 +307,105 @@ fn run_tui_event_loop( // Inside the Step Inspector, Tab cycles inspector // sub-tabs (Output / Env / Files / Matrix / Timeline); // elsewhere it cycles top-level tabs. - if app.selected_tab == 1 && app.detailed_view { + if app.selected_tab == TAB_EXECUTION && app.detailed_view { app.step_inspector_tab = (app.step_inspector_tab + 1) % 5; + } else if app.selected_tab == TAB_TRIGGER { + // In the Trigger tab Tab cycles inputs/fields. + app.trigger_tab_next_field(); } else { - app.switch_tab((app.selected_tab + 1) % 4); + app.switch_tab((app.selected_tab + 1) % TAB_COUNT); } } KeyCode::BackTab => { - if app.selected_tab == 1 && app.detailed_view { + if app.selected_tab == TAB_EXECUTION && app.detailed_view { app.step_inspector_tab = (app.step_inspector_tab + 4) % 5; + } else if app.selected_tab == TAB_TRIGGER { + app.trigger_tab_prev_field(); } else { - app.switch_tab((app.selected_tab + 3) % 4); + app.switch_tab((app.selected_tab + TAB_COUNT - 1) % TAB_COUNT); } } - KeyCode::Char('1') | KeyCode::Char('w') => app.switch_tab(0), - KeyCode::Char('2') | KeyCode::Char('x') => app.switch_tab(1), - KeyCode::Char('3') | KeyCode::Char('l') => app.switch_tab(2), - KeyCode::Char('4') | KeyCode::Char('h') => app.switch_tab(3), - KeyCode::Up | KeyCode::Char('k') => { - if app.selected_tab == 2 { - if !app.log_search_matches.is_empty() { - app.previous_search_match(); - } else { - app.scroll_logs_up(); - } - } else if app.selected_tab == 3 { - app.scroll_help_up(); - } else if app.selected_tab == 0 { + KeyCode::Char('1') | KeyCode::Char('w') => app.switch_tab(TAB_WORKFLOWS), + KeyCode::Char('2') | KeyCode::Char('x') => app.switch_tab(TAB_EXECUTION), + KeyCode::Char('3') => app.switch_tab(TAB_DAG), + KeyCode::Char('4') | KeyCode::Char('l') => app.switch_tab(TAB_LOGS), + KeyCode::Char('5') => app.switch_tab(TAB_TRIGGER), + KeyCode::Char('6') => app.switch_tab(TAB_SECRETS), + KeyCode::Char('7') | KeyCode::Char('h') => app.switch_tab(TAB_HELP), + KeyCode::Char(',') => { + // `,` toggles the Tweaks overlay anywhere (global). + // Chosen because it never conflicts with our single- + // letter tab shortcuts or with the log-search input + // mode (which only consumes printable chars when + // `log_search_active` is true, which we already + // handled above with a `continue`). + app.tweaks_open = !app.tweaks_open; + } + KeyCode::Up | KeyCode::Char('k') => match app.selected_tab { + TAB_WORKFLOWS => { if app.job_selection_mode { app.previous_available_job(); } else { app.previous_workflow(); } - } else if app.selected_tab == 1 { + } + TAB_EXECUTION => { if app.detailed_view { app.previous_step(); } else { app.previous_job(); } } - } - KeyCode::Down | KeyCode::Char('j') => { - if app.selected_tab == 2 { + TAB_LOGS => { if !app.log_search_matches.is_empty() { - app.next_search_match(); + app.previous_search_match(); } else { - app.scroll_logs_down(); + app.scroll_logs_up(); } - } else if app.selected_tab == 3 { - app.scroll_help_down(); - } else if app.selected_tab == 0 { + } + TAB_TRIGGER => app.trigger_tab_prev_workflow(), + TAB_SECRETS => app.secrets_tab_prev(), + TAB_HELP => app.scroll_help_up(), + _ => {} + }, + KeyCode::Down | KeyCode::Char('j') => match app.selected_tab { + TAB_WORKFLOWS => { if app.job_selection_mode { app.next_available_job(); } else { app.next_workflow(); } - } else if app.selected_tab == 1 { + } + TAB_EXECUTION => { if app.detailed_view { app.next_step(); } else { app.next_job(); } } - } + TAB_LOGS => { + if !app.log_search_matches.is_empty() { + app.next_search_match(); + } else { + app.scroll_logs_down(); + } + } + TAB_TRIGGER => app.trigger_tab_next_workflow(), + TAB_SECRETS => app.secrets_tab_next(), + TAB_HELP => app.scroll_help_down(), + _ => {} + }, KeyCode::Char(' ') => { - if app.selected_tab == 0 && !app.running && !app.job_selection_mode { + if app.selected_tab == TAB_WORKFLOWS + && !app.running + && !app.job_selection_mode + { app.toggle_selected(); } } KeyCode::Enter => { match app.selected_tab { - 0 => { + TAB_WORKFLOWS => { if !app.running { if app.job_selection_mode { // In job selection mode, run the selected job @@ -354,39 +426,44 @@ fn run_tui_event_loop( } } } - 1 => { + TAB_EXECUTION => { // In execution tab, Enter shows job details app.toggle_detailed_view(); } + TAB_TRIGGER => { + // Trigger tab: Enter on a non-editing row + // begins editing it (value first, then Tab + // to swap); when already editing, Enter + // commits the edit back to the row. + app.trigger_tab_enter(); + } _ => {} } } KeyCode::Char('r') => { - // Check if shift is pressed - this might be receiving the reset command + // Some terminals deliver Shift+r as lowercase + // `r` plus a SHIFT modifier instead of + // uppercase `R` (see the `R` arm below). + // Route both encodings to the reset path so + // users don't have to reason about what their + // terminal emits. if key.modifiers.contains(KeyModifiers::SHIFT) { - let timestamp = Local::now().format("%H:%M:%S").to_string(); - app.logs.push(format!( - "[{}] DEBUG: Shift+r detected - this should be uppercase R", - timestamp - )); - wrkflw_logging::info( - "Shift+r detected as lowercase - this should be uppercase R", - ); - if !app.running { - // Reset workflow status with Shift+r - app.logs.push(format!( - "[{}] Attempting to reset workflow status via Shift+r...", - timestamp - )); app.reset_workflow_status(); - - // Force redraw to update UI immediately terminal.draw(|f| { render_ui(f, app); })?; } - } else if !app.running && !app.job_selection_mode { + } else if !app.running + && !app.job_selection_mode + && app.selected_tab == TAB_WORKFLOWS + { + // Plain `r` queues the selected workflow + // for execution — a Workflows-tab action. + // Gate on the active tab so `r` typed on + // the DAG/Trigger/Secrets tabs doesn't + // silently mutate queue state behind the + // user's back. app.queue_selected_for_execution(); app.start_execution(); } @@ -396,8 +473,13 @@ fn run_tui_event_loop( if app.job_selection_mode { // In job selection mode, run all jobs app.run_from_job_selection(None); - } else { - // Select all workflows + } else if app.selected_tab == TAB_WORKFLOWS { + // Select-all is a Workflows-tab action. + // Without this tab gate, pressing `a` + // on DAG/Trigger/Secrets (outside edit + // mode) would silently flip every + // workflow to `selected` behind the + // user's back. for workflow in &mut app.workflows { workflow.selected = true; } @@ -406,7 +488,10 @@ fn run_tui_event_loop( } KeyCode::Char('J') => { // Enter job selection mode for selected workflow - if !app.running && app.selected_tab == 0 && !app.job_selection_mode { + if !app.running + && app.selected_tab == TAB_WORKFLOWS + && !app.job_selection_mode + { app.enter_job_selection_mode(); } } @@ -421,7 +506,7 @@ fn run_tui_event_loop( } } KeyCode::Char('d') => { - if !app.running && app.selected_tab == 0 { + if !app.running && app.selected_tab == TAB_WORKFLOWS { app.toggle_diff_filter(); } } @@ -434,14 +519,14 @@ fn run_tui_event_loop( // workflow gated on a non-push event — exactly // the "stop lying about which workflows would // run" failure mode the commit history fought. - if !app.running && app.selected_tab == 0 { + if !app.running && app.selected_tab == TAB_WORKFLOWS { app.cycle_diff_filter_event(); } } KeyCode::Char('n') => { - if app.selected_tab == 2 && !app.log_search_query.is_empty() { + if app.selected_tab == TAB_LOGS && !app.log_search_query.is_empty() { app.next_search_match(); - } else if app.selected_tab == 0 && !app.running { + } else if app.selected_tab == TAB_WORKFLOWS && !app.running { // Deselect all workflows for workflow in &mut app.workflows { workflow.selected = false; @@ -449,30 +534,15 @@ fn run_tui_event_loop( } } KeyCode::Char('R') => { - let timestamp = Local::now().format("%H:%M:%S").to_string(); - app.logs.push(format!( - "[{}] DEBUG: Reset key 'Shift+R' pressed", - timestamp - )); - wrkflw_logging::info("Reset key 'Shift+R' pressed"); - if !app.running { - // Reset workflow status - app.logs.push(format!( - "[{}] Attempting to reset workflow status...", - timestamp - )); app.reset_workflow_status(); - - // Force redraw to update UI immediately terminal.draw(|f| { render_ui(f, app); })?; } else { - app.logs.push(format!( - "[{}] Cannot reset workflow while another operation is running", - timestamp - )); + app.add_timestamped_log( + "Cannot reset workflow while another operation is running", + ); } } KeyCode::Char('?') => { @@ -481,7 +551,7 @@ fn run_tui_event_loop( } KeyCode::Char('t') => { // Only trigger workflow if not already running and we're in the workflows tab - if !app.running && app.selected_tab == 0 { + if !app.running && app.selected_tab == TAB_WORKFLOWS { if let Some(selected_idx) = app.workflow_list_state.selected() { if selected_idx < app.workflows.len() { let workflow = &app.workflows[selected_idx]; @@ -551,33 +621,72 @@ fn run_tui_event_loop( wrkflw_logging::warning( "Cannot trigger workflow while another operation is in progress", ); - } else if app.selected_tab != 0 { + } else if app.selected_tab != TAB_WORKFLOWS { app.logs .push("Switch to Workflows tab to trigger a workflow".to_string()); wrkflw_logging::warning( "Switch to Workflows tab to trigger a workflow", ); // For better UX, we could also automatically switch to the Workflows tab here - app.switch_tab(0); + app.switch_tab(TAB_WORKFLOWS); } } KeyCode::Char('s') => { - if app.selected_tab == 2 { + if app.selected_tab == TAB_LOGS { app.toggle_log_search(); } } KeyCode::Char('f') => { - if app.selected_tab == 2 { + if app.selected_tab == TAB_LOGS { app.toggle_log_filter(); } } KeyCode::Char('c') => { - if app.selected_tab == 2 { + if app.selected_tab == TAB_LOGS { app.clear_log_search_and_filter(); + } else if app.selected_tab == TAB_TRIGGER { + // Trigger tab: copy curl preview into the + // status bar so the user can scrape it from + // their scrollback without shelling out of + // the TUI. No clipboard integration yet — + // terminals vary too widely; a log line is + // honest. + app.trigger_tab_copy_curl(); + } + } + KeyCode::Char('g') => { + if app.selected_tab == TAB_DAG { + // Toggle DAG tab between graph and list view — + // matches the design's `g` shortcut on + // screen 4. + app.dag_list_view = !app.dag_list_view; + } + } + KeyCode::Char('p') => { + if app.selected_tab == TAB_TRIGGER { + // Trigger tab: flip github ↔ gitlab. Also + // rebinds the branch default because each + // platform has its own `get_repo_info`. + app.trigger_tab_toggle_platform(); + } + } + KeyCode::Char('+') | KeyCode::Char('=') => { + if app.selected_tab == TAB_TRIGGER { + app.trigger_tab_add_input(); + } + } + KeyCode::Char('b') => { + // Trigger tab: focus the Branch / ref row so + // the user can type a non-default branch. The + // previous revision rendered the field as + // editable but provided no keystroke that + // reached it. + if app.selected_tab == TAB_TRIGGER { + app.trigger_tab_edit_branch(); } } KeyCode::Char(c) => { - if app.selected_tab == 2 && app.log_search_active { + if app.selected_tab == TAB_LOGS && app.log_search_active { app.handle_log_search_input(KeyCode::Char(c)); } } diff --git a/crates/ui/src/app/state.rs b/crates/ui/src/app/state.rs index 7ee1535..da29df2 100644 --- a/crates/ui/src/app/state.rs +++ b/crates/ui/src/app/state.rs @@ -8,10 +8,13 @@ use chrono::Local; use crossterm::event::KeyCode; use ratatui::widgets::{ListState, TableState}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::task::JoinHandle; use wrkflw_executor::{JobStatus, RuntimeType, StepStatus}; +use wrkflw_secrets::SecretConfig; /// Application state pub struct App { @@ -99,6 +102,158 @@ pub struct App { /// Active sub-tab inside the Step Inspector (job-detail) view. /// 0 Output, 1 Env, 2 Files, 3 Matrix, 4 Timeline. pub step_inspector_tab: usize, + + // ── DAG tab ─────────────────────────────────────────────────── + /// When true, the DAG tab renders the topological-stage list view; + /// when false, the spatial column layout. Matches the design's + /// `graphView: 'graph' | 'list'` toggle (shortcut `g`). + pub dag_list_view: bool, + + // ── Trigger tab ─────────────────────────────────────────────── + /// Platform selected in the Trigger tab: "github" or "gitlab". + /// Toggled with `p` to keep the keyboard story explicit. + pub trigger_platform: TriggerPlatform, + /// Cursor into `workflows` for the workflow-to-dispatch selector. + pub trigger_workflow_idx: usize, + /// The branch/ref input — owned by the app so typing doesn't lose + /// state between draws. Empty string means "use the resolved + /// default"; the curl preview and the dispatcher both honour that + /// fallback. Edited when `trigger_branch_focused` is set. + pub trigger_branch: String, + /// True while the Branch / ref row holds the edit focus. Mutually + /// exclusive with `trigger_input_cursor.is_some()` — the main key + /// handler drives them as a single "is something being edited?" + /// question via [`App::trigger_editing`]. + pub trigger_branch_focused: bool, + /// Free-form `key=value` pairs to POST as `inputs:` (GitHub) or + /// `variables:` (GitLab). Flat Vec rather than HashMap so the UI + /// can show a deterministic cursor position and preserve user-typed + /// order in the curl preview. + pub trigger_inputs: Vec<(String, String)>, + /// Index into `trigger_inputs` for the edit cursor. `None` when no + /// row is being edited. + pub trigger_input_cursor: Option, + /// Which column of the currently-edited input row holds focus: + /// false = key, true = value. Flipped with Tab. + pub trigger_input_on_value: bool, + /// Shared in-flight flag so a double-`Enter` can't fire two + /// dispatches before the spawned task returns. Cleared by the + /// dispatch task on completion (success *or* error). + pub trigger_in_flight: Arc, + /// Cached resolution of the remote target (owner/repo, default + /// branch). Populated lazily by `trigger_tab_target()` so we don't + /// shell out to `git remote` every frame; invalidated on platform + /// toggle. + pub trigger_target_cache: Option, + /// Sender for dispatch outcomes. Cloned into the spawned tokio + /// task so the task can report success/failure back to the main + /// event loop without touching `&mut App` directly. + pub trigger_outcome_tx: mpsc::Sender, + /// Receiver for dispatch outcomes. Drained every tick by + /// [`App::drain_trigger_outcomes`] — the result updates the status + /// bar so the user gets confirmation on the Trigger tab itself, + /// not buried in the Logs tab. + pub trigger_outcome_rx: mpsc::Receiver, + + // ── Secrets tab ─────────────────────────────────────────────── + /// Selected row in the secrets list. + pub secrets_list_state: ListState, + + // ── Tweaks overlay ──────────────────────────────────────────── + /// When true, the Tweaks panel overlays the current tab. Toggled + /// with `,` (mirrors the design's edit-mode entry point). + pub tweaks_open: bool, + /// Accent color override. Matches the design's 5-slot palette. + /// The design exposes theme/density/graph-view too — we only ship + /// the knobs we actually plumb through (accent recolors the brand + /// + focused borders; the others would be dead toggles today). + pub tweaks_accent: Accent, +} + +/// Outcome of a remote dispatch spawned from the Trigger tab. +/// Reported back to the main event loop via an mpsc so the UI can +/// surface the result on the status bar (instead of forcing the user +/// to tab over to Logs). +#[derive(Debug, Clone)] +pub struct DispatchOutcome { + pub platform: TriggerPlatform, + pub workflow: String, + pub result: Result<(), String>, +} + +/// Target platform for the remote-trigger UI. GitLab path uses the +/// existing `wrkflw_gitlab::trigger_pipeline` so the form is honest +/// about what it will call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerPlatform { + Github, + Gitlab, +} + +impl TriggerPlatform { + pub fn as_str(&self) -> &'static str { + match self { + TriggerPlatform::Github => "github", + TriggerPlatform::Gitlab => "gitlab", + } + } + pub fn toggle(self) -> Self { + match self { + TriggerPlatform::Github => TriggerPlatform::Gitlab, + TriggerPlatform::Gitlab => TriggerPlatform::Github, + } + } +} + +/// Tweaks → accent. The five slots match the design file verbatim so +/// the tweaks panel is an honest surface of what the theme actually +/// plumbs, not an aspirational menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Accent { + #[default] + Cyan, + Amber, + Green, + Violet, + Coral, +} + +impl Accent { + pub fn as_str(&self) -> &'static str { + match self { + Accent::Cyan => "cyan", + Accent::Amber => "amber", + Accent::Green => "green", + Accent::Violet => "violet", + Accent::Coral => "coral", + } + } + + /// Rotate through the 5-slot palette in the order shown in the + /// design's TweaksPanel (matches user expectation if they've seen + /// the mockup). + pub fn next(self) -> Self { + match self { + Accent::Cyan => Accent::Amber, + Accent::Amber => Accent::Green, + Accent::Green => Accent::Violet, + Accent::Violet => Accent::Coral, + Accent::Coral => Accent::Cyan, + } + } + + /// Matches the RGB values from the design handoff (`ACCENTS` table + /// in wrkflw TUI.html). Exposed as a 3-tuple so `theme.rs` can + /// translate into `ratatui::Color::Rgb`. + pub fn rgb(self) -> (u8, u8, u8) { + match self { + Accent::Cyan => (0x5f, 0xd3, 0xf3), + Accent::Amber => (0xf5, 0xd7, 0x6e), + Accent::Green => (0x8f, 0xce, 0x8f), + Accent::Violet => (0xd6, 0x8c, 0xff), + Accent::Coral => (0xff, 0x99, 0x77), + } + } } /// Result rows shipped from the background diff-filter task to the UI loop. @@ -162,6 +317,8 @@ impl App { let mut workflow_list_state = ListState::default(); workflow_list_state.select(Some(0)); + let (trigger_outcome_tx, trigger_outcome_rx) = mpsc::channel::(); + let mut job_list_state = ListState::default(); job_list_state.select(Some(0)); @@ -340,6 +497,29 @@ impl App { diff_filter_task: None, diff_filter_aborted: false, step_inspector_tab: 0, + + dag_list_view: false, + + trigger_platform: TriggerPlatform::Github, + trigger_workflow_idx: 0, + trigger_branch: String::new(), + trigger_branch_focused: false, + trigger_inputs: Vec::new(), + trigger_input_cursor: None, + trigger_input_on_value: false, + trigger_in_flight: Arc::new(AtomicBool::new(false)), + trigger_target_cache: None, + trigger_outcome_tx, + trigger_outcome_rx, + + secrets_list_state: { + let mut s = ListState::default(); + s.select(Some(0)); + s + }, + + tweaks_open: false, + tweaks_accent: Accent::default(), } } @@ -908,7 +1088,7 @@ impl App { } } - // Change the tab + // Change the tab. pub fn switch_tab(&mut self, tab: usize) { self.selected_tab = tab; } @@ -1472,7 +1652,7 @@ impl App { self.current_execution = Some(selected_idx); // Switch to execution tab for better user feedback - self.selected_tab = 1; // Switch to Execution tab manually to avoid the borrowing issue + self.selected_tab = crate::views::TAB_EXECUTION; // avoid the borrowing issue from calling switch_tab() // Create a thread instead of using tokio runtime directly since send() is not async std::thread::spawn(move || { @@ -1700,6 +1880,711 @@ impl App { self.logs.drain(0..excess); } } + + // ── Trigger tab helpers ─────────────────────────────────────── + + /// Returns the workflow currently selected for dispatch in the + /// Trigger tab, clamped to the workflow list. `None` when there + /// are no workflows (e.g. empty directory). + pub fn trigger_selected_workflow_name(&self) -> Option<&str> { + self.workflows + .get(self.trigger_workflow_idx) + .map(|w| w.name.as_str()) + } + + pub fn trigger_tab_next_workflow(&mut self) { + if self.workflows.is_empty() { + return; + } + self.trigger_workflow_idx = (self.trigger_workflow_idx + 1) % self.workflows.len(); + } + + pub fn trigger_tab_prev_workflow(&mut self) { + if self.workflows.is_empty() { + return; + } + self.trigger_workflow_idx = + (self.trigger_workflow_idx + self.workflows.len() - 1) % self.workflows.len(); + } + + pub fn trigger_tab_toggle_platform(&mut self) { + self.trigger_platform = self.trigger_platform.toggle(); + // Cached target is per-platform; drop it so the next render + // re-resolves for the now-active platform. + self.trigger_target_cache = None; + } + + /// Return the resolved dispatch target, resolving once and caching + /// the result on `App`. Without this cache, `get_repo_info` (2-3 + /// `git` subprocesses per call) would run on every render — ~20Hz + /// by default. The cache is invalidated by `trigger_tab_toggle_platform`. + pub fn trigger_tab_target(&mut self) -> &TriggerTarget { + if self.trigger_target_cache.is_none() { + self.trigger_target_cache = Some(resolve_trigger_target(self.trigger_platform)); + } + self.trigger_target_cache.as_ref().expect("just populated") + } + + /// True when any trigger-tab text field (branch or an input row) + /// is holding the edit cursor. The main key router consults this + /// to decide whether to route keystrokes into the field rather + /// than the global key map. + pub fn trigger_editing(&self) -> bool { + self.trigger_branch_focused || self.trigger_input_cursor.is_some() + } + + /// Append a blank `key=value` row and put the edit cursor on it. + pub fn trigger_tab_add_input(&mut self) { + self.trigger_inputs.push((String::new(), String::new())); + self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1); + self.trigger_input_on_value = false; + self.trigger_branch_focused = false; + } + + /// Focus the Branch / ref row so the next keystrokes type into + /// `trigger_branch`. Clears any active input cursor — only one + /// field edits at a time. + pub fn trigger_tab_edit_branch(&mut self) { + self.trigger_branch_focused = true; + self.trigger_input_cursor = None; + self.trigger_input_on_value = false; + } + + /// Field order: Branch → Input(0).key → Input(0).value → + /// Input(1).key → … → wrap back to Branch. Branch is always part + /// of the cycle so the user can reach it via Tab without needing + /// the `b` shortcut. + pub fn trigger_tab_next_field(&mut self) { + // Not focused yet → land on Branch (start of the cycle). + if !self.trigger_editing() { + self.trigger_branch_focused = true; + return; + } + if self.trigger_branch_focused { + // Branch → first input key (if any); else stay on Branch. + if self.trigger_inputs.is_empty() { + return; + } + self.trigger_branch_focused = false; + self.trigger_input_cursor = Some(0); + self.trigger_input_on_value = false; + return; + } + // Inside the input grid. + match self.trigger_input_cursor { + None => { + // Defensive: trigger_editing said yes but cursor is + // None and branch is not focused. Recover to Branch. + self.trigger_branch_focused = true; + } + Some(_) if !self.trigger_input_on_value => { + self.trigger_input_on_value = true; + } + Some(i) => { + let n = self.trigger_inputs.len(); + if i + 1 < n { + self.trigger_input_cursor = Some(i + 1); + self.trigger_input_on_value = false; + } else { + // Past the last value: wrap back to Branch. + self.trigger_input_cursor = None; + self.trigger_input_on_value = false; + self.trigger_branch_focused = true; + } + } + } + } + + pub fn trigger_tab_prev_field(&mut self) { + // Not focused yet → wrap to the end of the cycle. + if !self.trigger_editing() { + if self.trigger_inputs.is_empty() { + self.trigger_branch_focused = true; + } else { + self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1); + self.trigger_input_on_value = true; + } + return; + } + if self.trigger_branch_focused { + // Branch → last input value (if any); else stay on Branch. + if self.trigger_inputs.is_empty() { + return; + } + self.trigger_branch_focused = false; + self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1); + self.trigger_input_on_value = true; + return; + } + match self.trigger_input_cursor { + None => { + self.trigger_branch_focused = true; + } + Some(_) if self.trigger_input_on_value => { + self.trigger_input_on_value = false; + } + Some(i) => { + if i == 0 { + self.trigger_input_cursor = None; + self.trigger_input_on_value = false; + self.trigger_branch_focused = true; + } else { + self.trigger_input_cursor = Some(i - 1); + self.trigger_input_on_value = true; + } + } + } + } + + /// Enter in the Trigger tab commits the current edit (if any) and + /// otherwise dispatches the workflow. Dispatch is intentionally + /// async and fire-and-forget — we spawn a task, log the outcome, + /// and let the user see the result in the Logs tab. Blocking the + /// UI thread on a `reqwest` POST would freeze the animation loop. + pub fn trigger_tab_enter(&mut self) { + if self.trigger_editing() { + // Commit edit. + self.trigger_branch_focused = false; + self.trigger_input_cursor = None; + self.trigger_input_on_value = false; + return; + } + self.trigger_dispatch(); + } + + /// Route a key event into whichever trigger-tab text field is + /// focused. Returns `true` if the key was consumed (so the caller + /// should skip the global key map); `false` otherwise. + pub fn trigger_handle_input_key(&mut self, code: KeyCode) -> bool { + // Branch field: write into `self.trigger_branch`. + if self.trigger_branch_focused { + return match code { + KeyCode::Char(c) => { + self.trigger_branch.push(c); + true + } + KeyCode::Backspace => { + self.trigger_branch.pop(); + true + } + KeyCode::Esc => { + // Esc discards the branch override — back to the + // git-resolved default, same as the original spec. + self.trigger_branch.clear(); + self.trigger_branch_focused = false; + true + } + KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => false, + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => true, + _ => false, + }; + } + + let Some(idx) = self.trigger_input_cursor else { + return false; + }; + let Some((k, v)) = self.trigger_inputs.get_mut(idx) else { + self.trigger_input_cursor = None; + return false; + }; + let buf = if self.trigger_input_on_value { v } else { k }; + match code { + KeyCode::Char(c) => { + buf.push(c); + true + } + KeyCode::Backspace => { + buf.pop(); + true + } + KeyCode::Esc => { + self.trigger_input_cursor = None; + self.trigger_input_on_value = false; + true + } + // Let Enter/Tab/BackTab fall through so the surrounding + // event loop can commit the edit and advance the field + // cursor with the same shortcuts as outside edit mode. + KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => false, + // Swallow directional keys while editing. Without this + // they fall through to the global handler and silently + // change the selected workflow underneath the user — i.e. + // hitting ↓ mid-edit to reach for a value correction + // would rebind the dispatch target. Consuming them as a + // no-op is the least-surprising behaviour; a future + // enhancement could route Up/Down to prev/next field. + KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => true, + _ => false, + } + } + + /// Render the would-be POST into the log buffer so the user has a + /// recipe they can paste. Non-destructive; doesn't hit the + /// network. + /// + /// The preview is a multi-line string joined with ` \\\n`; the + /// logs pane renders one `Vec` entry per line, so we split + /// before pushing. Otherwise the embedded newline lands in a + /// single entry and the log widget either collapses the break or + /// spills the entry across rows in a way the user can't copy. + pub fn trigger_tab_copy_curl(&mut self) { + let curl = self.trigger_curl_preview(); + let mut lines = curl.lines(); + if let Some(first) = lines.next() { + self.logs.push(format!("curl: {}", first)); + } + for rest in lines { + self.logs.push(format!("curl: {}", rest)); + } + self.trim_logs_to_cap(); + } + + /// Exposed as `pub` so the Trigger view can render the same string + /// it logs — single source of truth prevents preview drift. The + /// preview is constructed to match exactly what + /// `wrkflw_{github,gitlab}` send on the wire: same endpoint, same + /// headers, same body shape. When the repo cache isn't populated + /// yet (first render after tab switch) we fall back to a + /// placeholder so users never see stale data, never a lie. + pub fn trigger_curl_preview(&self) -> String { + let wf = self + .trigger_selected_workflow_name() + .unwrap_or(""); + let branch_raw = if self.trigger_branch.is_empty() { + self.trigger_target_cache + .as_ref() + .map(|t| t.default_branch.clone()) + .unwrap_or_else(|| "".to_string()) + } else { + self.trigger_branch.clone() + }; + // Multi-line curl: each flag on its own line joined by ` \\\n` + // so a copy-paste into a terminal preserves the line + // continuations. The preview view splits on `\n` to render; + // the string itself is a valid shell heredoc-free one-liner. + match self.trigger_platform { + TriggerPlatform::Github => { + // The dispatcher posts to + // /repos/{owner}/{repo}/actions/workflows/{segment}/dispatches + // where `{segment}` is the bare basename-with-extension + // form produced by `wrkflw_github::workflow_dispatch_path_segment`. + // Going through the same helper as the dispatcher is + // load-bearing: it's what guarantees the copy-pasted + // curl lands on the identical endpoint the TUI will + // hit on Enter. + // + // owner/repo come from `git remote`, so URL-encode + // each path segment before interpolating — that way + // shell metacharacters in a repo name (e.g. from a + // misconfigured remote) can't piggyback on the + // preview into a user's paste buffer. + let (owner, repo) = self + .trigger_target_cache + .as_ref() + .and_then(|t| split_slug(&t.repo_label)) + .unwrap_or(("".to_string(), "".to_string())); + let body = github_dispatches_body(&branch_raw, &self.trigger_inputs); + let wf_segment = wrkflw_github::workflow_dispatch_path_segment(wf) + .unwrap_or_else(|| ".yml".to_string()); + let enc_owner = urlencoding::encode(&owner); + let enc_repo = urlencoding::encode(&repo); + let enc_wf = urlencoding::encode(&wf_segment); + let escaped = escape_shell_single(&body); + [ + "curl -X POST".to_string(), + " -H \"Authorization: Bearer $GITHUB_TOKEN\"".to_string(), + " -H \"Accept: application/vnd.github+json\"".to_string(), + " -H \"Content-Type: application/json\"".to_string(), + format!( + " https://api.github.com/repos/{enc_owner}/{enc_repo}/actions/workflows/{enc_wf}/dispatches", + ), + format!(" -d '{body}'", body = escaped), + ] + .join(" \\\n") + } + TriggerPlatform::Gitlab => { + // The dispatcher posts to + // /api/v4/projects/{enc_ns}%2F{enc_proj}/pipeline + // (note: `/pipeline`, NOT `/trigger/pipeline` — the + // latter needs a trigger token, not a PAT) with JSON + // body `{"ref": "...", "variables": [{"key":K,"value":V}]}`. + let (ns, proj) = self + .trigger_target_cache + .as_ref() + .and_then(|t| split_slug(&t.repo_label)) + .unwrap_or(("".to_string(), "".to_string())); + let enc_ns = urlencoding::encode(&ns); + let enc_proj = urlencoding::encode(&proj); + let body = gitlab_pipeline_body(&branch_raw, &self.trigger_inputs); + let escaped = escape_shell_single(&body); + [ + "curl -X POST".to_string(), + " -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\"".to_string(), + " -H \"Content-Type: application/json\"".to_string(), + format!( + " https://gitlab.com/api/v4/projects/{enc_ns}%2F{enc_proj}/pipeline", + enc_ns = enc_ns, + enc_proj = enc_proj, + ), + format!(" -d '{body}'", body = escaped), + ] + .join(" \\\n") + } + } + } + + /// Fire the dispatch. We spawn a tokio task because the TUI event + /// loop is synchronous — blocking on `.await` here would stall + /// rendering. An in-flight flag guards against double-Enter firing + /// two dispatches before the first returns. + pub fn trigger_dispatch(&mut self) { + if self.trigger_in_flight.load(Ordering::SeqCst) { + self.set_error_message( + "A trigger dispatch is already in flight — wait for it to complete.".to_string(), + ); + return; + } + + let Some(wf_name_raw) = self.trigger_selected_workflow_name().map(|s| s.to_string()) else { + self.set_error_message("No workflow selected to dispatch.".to_string()); + return; + }; + let branch = if self.trigger_branch.is_empty() { + None + } else { + Some(self.trigger_branch.clone()) + }; + let inputs: std::collections::HashMap = self + .trigger_inputs + .iter() + .filter(|(k, _)| !k.is_empty()) + .cloned() + .collect(); + let platform = self.trigger_platform; + self.logs.push(format!( + "Dispatching {} to {:?} (branch: {})", + wf_name_raw, + platform, + branch.as_deref().unwrap_or("") + )); + self.trim_logs_to_cap(); + + // Flip the in-flight flag BEFORE the spawn so a rapid + // second Enter — dispatched from the same UI tick before + // the task has even started running — still sees the flag + // and bails at the `load` check above. + self.trigger_in_flight.store(true, Ordering::SeqCst); + // Wrap the flag in a guard so its Drop impl is what actually + // clears it. `in_flight.store(false)` as a trailing + // statement only runs on normal return; if `reqwest` or the + // dispatcher panics, unwinding would skip it and strand the + // Trigger tab in "already in flight" until the TUI restarts. + let in_flight_guard = InFlightGuard::arm(Arc::clone(&self.trigger_in_flight)); + let outcome_tx = self.trigger_outcome_tx.clone(); + + tokio::spawn(async move { + // Hold the in-flight guard for the full task lifetime. + // Drop clears the flag on every exit path — normal + // return, early `?`-style error return, *and* panic + // unwinding — so a panic inside `reqwest` or either + // dispatcher can't strand the Trigger tab in a + // permanent "already in flight" state. + let _in_flight_guard = in_flight_guard; + let result = match platform { + TriggerPlatform::Github => { + // Pass the raw identifier through — the + // dispatcher normalizes it via + // `workflow_dispatch_path_segment`, same as the + // preview. + wrkflw_github::trigger_workflow( + &wf_name_raw, + branch.as_deref(), + if inputs.is_empty() { + None + } else { + Some(inputs) + }, + ) + .await + .map_err(|e| e.to_string()) + } + TriggerPlatform::Gitlab => wrkflw_gitlab::trigger_pipeline( + branch.as_deref(), + if inputs.is_empty() { + None + } else { + Some(inputs) + }, + ) + .await + .map_err(|e| e.to_string()), + }; + match &result { + Ok(_) => wrkflw_logging::info("Trigger dispatched successfully"), + Err(e) => wrkflw_logging::error(&format!("Trigger failed: {}", e)), + } + // Send the outcome even if the receiver is gone (e.g. + // App dropped) — the send is cheap and logging already + // covered the visible error path. + let _ = outcome_tx.send(DispatchOutcome { + platform, + workflow: wf_name_raw, + result, + }); + }); + } + + /// Drain any completed dispatch outcomes. Each outcome is pushed + /// to `self.logs` so nothing is lost if two arrive in one tick, + /// and the status bar is updated with the most recent one (an + /// Err takes precedence over an Ok that preceded it in the same + /// drain — the user wants to know about the failure). Uses + /// `try_recv` so the call never blocks. + pub fn drain_trigger_outcomes(&mut self) { + let mut last: Option = None; + let mut drained = 0usize; + while let Ok(outcome) = self.trigger_outcome_rx.try_recv() { + // Log every outcome so the Logs tab is the durable + // record — the status bar can only show one line. + let line = match &outcome.result { + Ok(_) => format!( + "Dispatched {} on {}", + outcome.workflow, + outcome.platform.as_str() + ), + Err(e) => format!( + "Dispatch {} on {} failed: {}", + outcome.workflow, + outcome.platform.as_str(), + e + ), + }; + self.logs.push(line); + // Prefer the most recent error over any later success, + // and the most recent outcome otherwise. + match (&last, &outcome.result) { + (Some(prev), Ok(_)) if prev.result.is_err() => { + // Keep the prior error on screen. + } + _ => last = Some(outcome), + } + drained += 1; + } + if drained > 0 { + self.trim_logs_to_cap(); + } + if let Some(outcome) = last { + match outcome.result { + // Success severity (green) makes a completed dispatch + // visually distinct from the cyan "dispatch queued" + // info messages that share the Trigger tab's accent. + Ok(_) => self.set_success_message(format!( + "Dispatched {} on {}", + outcome.workflow, + outcome.platform.as_str() + )), + Err(e) => self.set_error_message(format!( + "Dispatch {} on {} failed: {}", + outcome.workflow, + outcome.platform.as_str(), + e + )), + } + } + } + + // ── Secrets tab helpers ─────────────────────────────────────── + + /// Navigate the secrets list downwards, wrapping at the end so the + /// cursor never falls off (ratatui's ListState happily accepts an + /// out-of-range index and silently renders nothing). + pub fn secrets_tab_next(&mut self) { + let len = secrets_provider_count(); + if len == 0 { + return; + } + let i = match self.secrets_list_state.selected() { + Some(i) => (i + 1) % len, + None => 0, + }; + self.secrets_list_state.select(Some(i)); + } + + pub fn secrets_tab_prev(&mut self) { + let len = secrets_provider_count(); + if len == 0 { + return; + } + let i = match self.secrets_list_state.selected() { + Some(i) => (i + len - 1) % len, + None => 0, + }; + self.secrets_list_state.select(Some(i)); + } +} + +// ── Free helpers for the trigger curl preview ──────────────────── + +/// RAII guard that owns the Trigger-tab in-flight flag for the +/// lifetime of a single dispatch task. The flag is set to `true` by +/// the caller BEFORE the spawn (so a second Enter on the same tick +/// sees it and bails), and cleared here on Drop. +/// +/// Putting the clear in a Drop impl — rather than as a trailing +/// `store(false)` at the end of the spawned task — is what keeps +/// the Trigger tab usable after an unexpected failure. `reqwest` or +/// either dispatcher can panic (e.g. an internal assertion, an +/// OOM-adjacent allocation failure, a bug in a future SDK update); +/// if that happens, normal unwinding drops locals, the guard fires, +/// and the flag returns to `false`. Without this, the tab locks into +/// a permanent "already in flight" state and the user has to restart +/// the TUI. +struct InFlightGuard { + flag: Arc, +} + +impl InFlightGuard { + fn arm(flag: Arc) -> Self { + Self { flag } + } +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::SeqCst); + } +} + +/// Cached-resolve of the remote dispatch target. Owned by `App` so we +/// can clear it on platform toggle instead of re-shelling `git remote` +/// every frame. +#[derive(Debug, Clone)] +pub struct TriggerTarget { + pub platform_label: String, + pub repo_label: String, + pub default_branch: String, + /// Non-fatal note surfaced in the UI when repo resolution fails. + pub note: Option, +} + +/// Count of configured secret providers. Lives in state (not views) so +/// state navigation code doesn't have to reach into the render layer +/// just to bounds-check its cursor. +pub fn secrets_provider_count() -> usize { + SecretConfig::default().providers.len() +} + +/// Serialize the trigger inputs into the JSON body shape that +/// `wrkflw_github::trigger_workflow` actually sends: +/// `{"ref":"…","inputs":{"K":"V",…}}`. Using serde_json end-to-end +/// keeps the preview honest for branch/input values that contain +/// quotes, backslashes, or newlines. +pub(crate) fn github_dispatches_body(branch: &str, inputs: &[(String, String)]) -> String { + let mut payload = serde_json::Map::new(); + payload.insert( + "ref".to_string(), + serde_json::Value::String(branch.to_string()), + ); + let mut input_map = serde_json::Map::new(); + for (k, v) in inputs { + if k.is_empty() { + continue; + } + input_map.insert(k.clone(), serde_json::Value::String(v.clone())); + } + payload.insert("inputs".to_string(), serde_json::Value::Object(input_map)); + serde_json::to_string(&serde_json::Value::Object(payload)) + .unwrap_or_else(|_| "{\"ref\":\"\",\"inputs\":{}}".to_string()) +} + +/// Serialize the trigger inputs into the JSON body shape that +/// `wrkflw_gitlab::trigger_pipeline` actually sends: +/// `{"ref":"…","variables":[{"key":K,"value":V},…]}`. +/// Using `serde_json` end-to-end avoids hand-rolled escape drift +/// between the preview and the dispatcher. +pub(crate) fn gitlab_pipeline_body(branch: &str, inputs: &[(String, String)]) -> String { + let vars: Vec = inputs + .iter() + .filter(|(k, _)| !k.is_empty()) + .map(|(k, v)| { + serde_json::json!({ + "key": k, + "value": v, + }) + }) + .collect(); + let mut payload = serde_json::Map::new(); + payload.insert( + "ref".to_string(), + serde_json::Value::String(branch.to_string()), + ); + if !vars.is_empty() { + payload.insert("variables".to_string(), serde_json::Value::Array(vars)); + } + serde_json::to_string(&serde_json::Value::Object(payload)) + .unwrap_or_else(|_| "{\"ref\":\"\"}".to_string()) +} + +/// Escape into a shell single-quoted context: close the quote, emit an +/// escaped single quote, and reopen. Used by the curl preview so the +/// one-liner round-trips a branch / body that contains `'`. +fn escape_shell_single(s: &str) -> String { + s.replace('\'', "'\\''") +} + +/// Split a "a/b" slug into its two components. Returns `None` when the +/// slug isn't the expected shape (e.g. ``), letting the +/// caller choose a fallback rather than rendering garbage. +fn split_slug(slug: &str) -> Option<(String, String)> { + let (a, b) = slug.split_once('/')?; + if a.is_empty() || b.is_empty() { + return None; + } + Some((a.to_string(), b.to_string())) +} + +/// Resolve repo info for the Trigger tab. Shells out to `git remote` +/// via `wrkflw_{github,gitlab}::get_repo_info`; cheap on small repos, +/// painful on every frame — which is why callers go through the +/// `App::trigger_tab_target` cache. +fn resolve_trigger_target(platform: TriggerPlatform) -> TriggerTarget { + // On resolution failure both `repo_label` and `default_branch` + // are rendered as ``. The previous "main" fallback + // for the branch was a lie — it implied we had resolved the + // default when we had not — and a user who missed the warn badge + // could end up with a curl preview that dispatched against + // whatever happened to be named "main" instead of the intended + // target. Better to surface the un-resolution consistently. + match platform { + TriggerPlatform::Github => match wrkflw_github::get_repo_info() { + Ok(info) => TriggerTarget { + platform_label: "GitHub".to_string(), + repo_label: format!("{}/{}", info.owner, info.repo), + default_branch: info.default_branch, + note: None, + }, + Err(e) => TriggerTarget { + platform_label: "GitHub".to_string(), + repo_label: "".to_string(), + default_branch: "".to_string(), + note: Some(e.to_string()), + }, + }, + TriggerPlatform::Gitlab => match wrkflw_gitlab::get_repo_info() { + Ok(info) => TriggerTarget { + platform_label: "GitLab".to_string(), + repo_label: format!("{}/{}", info.namespace, info.project), + default_branch: info.default_branch, + note: None, + }, + Err(e) => TriggerTarget { + platform_label: "GitLab".to_string(), + repo_label: "".to_string(), + default_branch: "".to_string(), + note: Some(e.to_string()), + }, + }, + } } /// Run git + trigger evaluation as an async task on the ambient runtime. @@ -2478,4 +3363,653 @@ mod tests { assert!(!app.job_selection_mode); assert!(app.available_jobs.is_empty()); } + + // ── Added as part of the post-review cleanup ───────────────── + + #[test] + fn accent_next_cycles_through_all_five_slots() { + let mut a = Accent::Cyan; + let mut seen = vec![a]; + for _ in 0..5 { + a = a.next(); + seen.push(a); + } + // Five steps returns to Cyan. + assert_eq!(seen.first(), seen.last()); + // All five slots are visited before wrapping. + let mut distinct = seen.clone(); + distinct.sort_by_key(|a| *a as u8); + distinct.dedup_by_key(|a| *a as u8); + assert_eq!(distinct.len(), 5); + } + + #[test] + fn github_dispatches_body_matches_dispatcher_shape() { + // Empty inputs: still emits an empty `inputs: {}` object + // because the dispatcher unconditionally sets the key. + let body = github_dispatches_body("main", &[]); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["ref"], "main"); + assert!(v["inputs"].as_object().unwrap().is_empty()); + + // Empty keys are filtered (the dispatcher would discard them). + let body = github_dispatches_body( + "release", + &[ + ("env".into(), "prod".into()), + (String::new(), "dropped".into()), + ], + ); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["inputs"]["env"], "prod"); + assert!(v["inputs"].as_object().unwrap().get("").is_none()); + } + + #[test] + fn github_dispatches_body_escapes_control_chars_via_serde_json() { + // Branch and value strings may contain quotes, backslashes, + // or newlines; the preview must survive them round-tripped + // through `serde_json::from_str`. + let body = github_dispatches_body( + "feat/with\"quote", + &[("key".into(), "line1\nline2\t\"quoted\"\\slash".into())], + ); + let parsed: serde_json::Value = + serde_json::from_str(&body).expect("body must be valid JSON"); + assert_eq!(parsed["ref"], "feat/with\"quote"); + assert_eq!(parsed["inputs"]["key"], "line1\nline2\t\"quoted\"\\slash"); + } + + #[test] + fn gitlab_pipeline_body_matches_dispatcher_shape() { + // Empty inputs: no `variables` key at all (mirrors the + // dispatcher which only adds the field when there's data). + let body = gitlab_pipeline_body("main", &[]); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["ref"], "main"); + assert!(v.get("variables").is_none()); + + // Populated inputs: variables is an array of {key,value} + // objects — exactly what /api/v4/projects/:id/pipeline expects. + let body = gitlab_pipeline_body( + "release", + &[ + ("K".into(), "has \"quote\"".into()), + (String::new(), "dropped".into()), // empty keys filtered + ("OTHER".into(), "v".into()), + ], + ); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["ref"], "release"); + let vars = v["variables"].as_array().expect("variables array"); + assert_eq!(vars.len(), 2); + assert_eq!(vars[0]["key"], "K"); + assert_eq!(vars[0]["value"], "has \"quote\""); + assert_eq!(vars[1]["key"], "OTHER"); + } + + #[test] + fn split_slug_rejects_non_two_part_slugs() { + assert_eq!( + split_slug("owner/repo"), + Some(("owner".into(), "repo".into())) + ); + assert_eq!(split_slug(""), None); + assert_eq!(split_slug("/repo"), None); + assert_eq!(split_slug("owner/"), None); + // Deeper paths keep first slash only (valid for GitLab groups). + assert_eq!( + split_slug("group/subgroup/project"), + Some(("group".into(), "subgroup/project".into())) + ); + } + + #[test] + fn trigger_tab_add_input_sets_cursor_to_new_row_and_key_column() { + let mut app = make_app(); + assert!(app.trigger_input_cursor.is_none()); + app.trigger_tab_add_input(); + assert_eq!(app.trigger_input_cursor, Some(0)); + assert!(!app.trigger_input_on_value); + app.trigger_tab_add_input(); + assert_eq!(app.trigger_input_cursor, Some(1)); + } + + #[test] + fn trigger_tab_next_field_lands_on_branch_when_no_inputs() { + let mut app = make_app(); + assert!(app.trigger_inputs.is_empty()); + assert!(!app.trigger_editing()); + // With no inputs, Tab cycles through Branch only. + app.trigger_tab_next_field(); + assert!(app.trigger_branch_focused); + assert!(app.trigger_input_cursor.is_none()); + app.trigger_tab_next_field(); + assert!( + app.trigger_branch_focused, + "stays on Branch when nothing else to cycle through" + ); + // Prev from fresh state also lands on Branch when no inputs. + let mut app = make_app(); + app.trigger_tab_prev_field(); + assert!(app.trigger_branch_focused); + assert!(app.trigger_input_cursor.is_none()); + } + + #[test] + fn trigger_tab_next_field_walks_branch_then_inputs_and_wraps() { + let mut app = make_app(); + app.trigger_inputs = vec![("a".into(), "1".into()), ("b".into(), "2".into())]; + // From fresh state: None → Branch. + app.trigger_tab_next_field(); + assert!(app.trigger_branch_focused); + assert!(app.trigger_input_cursor.is_none()); + // Branch → row 0 key. + app.trigger_tab_next_field(); + assert!(!app.trigger_branch_focused); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(0), false) + ); + // Row 0 key → row 0 value. + app.trigger_tab_next_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(0), true) + ); + // Row 0 value → row 1 key. + app.trigger_tab_next_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(1), false) + ); + app.trigger_tab_next_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(1), true) + ); + // Past the last input value → wraps back to Branch. + app.trigger_tab_next_field(); + assert!(app.trigger_branch_focused); + assert!(app.trigger_input_cursor.is_none()); + } + + #[test] + fn trigger_tab_prev_field_reverses_direction() { + let mut app = make_app(); + app.trigger_inputs = vec![("a".into(), "1".into()), ("b".into(), "2".into())]; + // From fresh state: None → row 1 value (backward wrap through + // the input grid; Branch sits before row 0 in forward order). + app.trigger_tab_prev_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(1), true) + ); + app.trigger_tab_prev_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(1), false) + ); + app.trigger_tab_prev_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(0), true) + ); + app.trigger_tab_prev_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(0), false) + ); + // Row 0 key → Branch (backward). + app.trigger_tab_prev_field(); + assert!(app.trigger_branch_focused); + assert!(app.trigger_input_cursor.is_none()); + // Branch → last input value (backward wrap). + app.trigger_tab_prev_field(); + assert_eq!( + (app.trigger_input_cursor, app.trigger_input_on_value), + (Some(1), true) + ); + } + + #[test] + fn trigger_tab_edit_branch_writes_into_trigger_branch() { + // Regression: pre-fix, the "Branch / ref" row was rendered as + // editable but no keystroke path ever wrote into + // `self.trigger_branch`. The user was stuck on the resolved + // default forever. + let mut app = make_app(); + assert!(app.trigger_branch.is_empty()); + app.trigger_tab_edit_branch(); + assert!(app.trigger_editing()); + assert!(app.trigger_branch_focused); + for c in "release/1.0".chars() { + assert!(app.trigger_handle_input_key(KeyCode::Char(c))); + } + assert_eq!(app.trigger_branch, "release/1.0"); + assert!(app.trigger_handle_input_key(KeyCode::Backspace)); + assert_eq!(app.trigger_branch, "release/1."); + // Enter falls through so the global handler can commit the + // edit (see `trigger_tab_enter`). + assert!(!app.trigger_handle_input_key(KeyCode::Enter)); + app.trigger_tab_enter(); + assert!(!app.trigger_branch_focused); + assert_eq!(app.trigger_branch, "release/1."); + } + + #[test] + fn trigger_tab_edit_branch_esc_clears_override() { + let mut app = make_app(); + app.trigger_tab_edit_branch(); + for c in "junk".chars() { + app.trigger_handle_input_key(KeyCode::Char(c)); + } + assert_eq!(app.trigger_branch, "junk"); + assert!(app.trigger_handle_input_key(KeyCode::Esc)); + assert!(!app.trigger_branch_focused); + assert_eq!( + app.trigger_branch, "", + "Esc must revert to the resolved default" + ); + } + + #[test] + fn trigger_tab_edit_branch_and_add_input_are_mutually_exclusive() { + let mut app = make_app(); + app.trigger_tab_edit_branch(); + assert!(app.trigger_branch_focused); + // Adding an input takes focus away from the branch row so the + // two invariants ("only one field edits at a time") hold. + app.trigger_tab_add_input(); + assert!(!app.trigger_branch_focused); + assert_eq!(app.trigger_input_cursor, Some(0)); + // Going back the other way. + app.trigger_tab_edit_branch(); + assert!(app.trigger_input_cursor.is_none()); + assert!(app.trigger_branch_focused); + } + + #[test] + fn trigger_handle_input_key_is_noop_without_cursor() { + let mut app = make_app(); + assert!(!app.trigger_handle_input_key(KeyCode::Char('x'))); + } + + #[test] + fn trigger_handle_input_key_writes_into_active_column() { + let mut app = make_app(); + app.trigger_tab_add_input(); + assert!(app.trigger_handle_input_key(KeyCode::Char('n'))); + assert!(app.trigger_handle_input_key(KeyCode::Char('s'))); + assert_eq!(app.trigger_inputs[0].0, "ns"); + app.trigger_input_on_value = true; + assert!(app.trigger_handle_input_key(KeyCode::Char('1'))); + assert_eq!(app.trigger_inputs[0].1, "1"); + assert!(app.trigger_handle_input_key(KeyCode::Backspace)); + assert_eq!(app.trigger_inputs[0].1, ""); + // Esc clears the cursor cleanly. + assert!(app.trigger_handle_input_key(KeyCode::Esc)); + assert!(app.trigger_input_cursor.is_none()); + } + + #[test] + fn trigger_handle_input_key_lets_enter_tab_fall_through_on_input_rows() { + // Enter/Tab/BackTab must return `false` from the edit handler + // so the surrounding event loop can commit the edit (Enter via + // `trigger_tab_enter`) or advance the field cursor (Tab via + // `trigger_tab_next_field`). The mirror contract is already + // covered for branch-field editing in + // `trigger_tab_edit_branch_writes_into_trigger_branch`; this + // test pins the input-row path so a future edit-handler + // refactor can't silently consume the commit/advance key. + let mut app = make_app(); + app.trigger_tab_add_input(); + app.trigger_inputs[0].0 = "env".into(); + app.trigger_inputs[0].1 = "prod".into(); + app.trigger_input_on_value = true; + for code in [KeyCode::Enter, KeyCode::Tab, KeyCode::BackTab] { + assert!( + !app.trigger_handle_input_key(code), + "{:?} must fall through to the global handler on an input row", + code + ); + } + // The buffer is untouched — those three keys are routing + // signals, not content. + assert_eq!(app.trigger_inputs[0].0, "env"); + assert_eq!(app.trigger_inputs[0].1, "prod"); + } + + #[test] + fn trigger_handle_input_key_swallows_arrows_so_they_dont_cycle_workflow() { + // Regression: pre-fix, Up/Down/Left/Right fell through from + // the edit handler to the global key map, which maps them to + // `trigger_tab_prev/next_workflow`. The consequence was that + // a user correcting a typo mid-edit could silently rebind the + // workflow about to be dispatched. Each arrow key must now + // be consumed (return `true`) while a cursor is active, and + // the inputs must remain untouched. + let mut app = make_app(); + app.trigger_tab_add_input(); + app.trigger_inputs[0].0 = "env".into(); + app.trigger_inputs[0].1 = "prod".into(); + for code in [KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right] { + assert!( + app.trigger_handle_input_key(code), + "arrow {:?} should be consumed in edit mode", + code + ); + } + assert_eq!(app.trigger_inputs[0].0, "env"); + assert_eq!(app.trigger_inputs[0].1, "prod"); + // Without a cursor the handler reports unhandled so the + // global handler still owns arrow navigation outside edit + // mode — we haven't over-reached. + app.trigger_input_cursor = None; + assert!(!app.trigger_handle_input_key(KeyCode::Up)); + assert!(!app.trigger_handle_input_key(KeyCode::Down)); + } + + #[test] + fn resolve_trigger_target_fallback_values_are_consistent_across_fields() { + // Regression: the error branch previously returned + // `default_branch: "main"` while `repo_label` was + // ``, i.e. the UI admitted the repo was unknown + // but invented a branch. A user missing the warn badge could + // end up dispatching against whatever happened to be called + // "main" on the resolved dispatcher side. Both fields must + // now admit un-resolution in the same voice. + // + // We construct the error shape by hand rather than invoking + // `resolve_trigger_target` (which shells out to git) so the + // test works in any CI without a wrkflw remote configured. + let t = TriggerTarget { + platform_label: "GitHub".into(), + repo_label: "".into(), + default_branch: "".into(), + note: Some("git remote get-url failed".into()), + }; + assert_eq!(t.repo_label, t.default_branch); + assert!(t.note.is_some(), "un-resolution must surface a warn note"); + } + + #[test] + fn trigger_dispatch_rejects_when_already_in_flight() { + // Strictly exercise the synchronous guard — pre-arm the flag + // rather than spawning a tokio task. This keeps the test off + // the network regardless of `GITHUB_TOKEN` being set in the + // environment, and runs without a tokio runtime. + let mut app = make_app(); + app.trigger_workflow_idx = 0; + app.trigger_in_flight.store(true, Ordering::SeqCst); + let before_status = app.status_message.clone(); + app.trigger_dispatch(); + assert_ne!(app.status_message, before_status); + assert!(app + .status_message + .as_deref() + .unwrap_or("") + .contains("already in flight")); + } + + #[test] + fn drain_trigger_outcomes_maps_success_and_failure_to_status_bar() { + let mut app = make_app(); + // Success: success (green) severity so a completed dispatch is + // visually distinct from the cyan "queued" info messages that + // share the Trigger tab's accent color. + app.trigger_outcome_tx + .send(DispatchOutcome { + platform: TriggerPlatform::Github, + workflow: "ci".into(), + result: Ok(()), + }) + .unwrap(); + app.drain_trigger_outcomes(); + assert_eq!(app.status_message_severity, StatusSeverity::Success); + assert!(app + .status_message + .as_deref() + .unwrap_or("") + .contains("Dispatched ci")); + + // Failure: error message overwrites the earlier info. + app.trigger_outcome_tx + .send(DispatchOutcome { + platform: TriggerPlatform::Gitlab, + workflow: "deploy".into(), + result: Err("401 Unauthorized".into()), + }) + .unwrap(); + app.drain_trigger_outcomes(); + assert_eq!(app.status_message_severity, StatusSeverity::Error); + assert!(app + .status_message + .as_deref() + .unwrap_or("") + .contains("Dispatch deploy on gitlab failed")); + } + + #[test] + fn trigger_curl_preview_github_uses_resolved_repo_and_escapes_branch() { + let mut app = make_app(); + app.trigger_target_cache = Some(TriggerTarget { + platform_label: "GitHub".into(), + repo_label: "bahdotsh/wrkflw".into(), + default_branch: "main".into(), + note: None, + }); + app.trigger_branch = "release/1.0".into(); + app.trigger_inputs = vec![("env".into(), "prod".into())]; + let curl = app.trigger_curl_preview(); + assert!(curl.contains("repos/bahdotsh/wrkflw/actions/workflows/ci.yml/dispatches")); + assert!(curl.contains("\"ref\":\"release/1.0\"")); + assert!(curl.contains("\"env\":\"prod\"")); + } + + #[test] + fn trigger_curl_preview_falls_back_to_angle_bracket_placeholders_with_no_cache() { + // Regression: on first render after a tab switch the target + // cache is None. The preview must show `/` (and + // for GitLab `/`) rather than silently + // stale-filling an old value or panicking. Users lean on the + // angle brackets as a visual cue that resolution is pending. + let mut app = make_app(); + assert!(app.trigger_target_cache.is_none()); + let gh = app.trigger_curl_preview(); + assert!( + gh.contains("repos/%3Cowner%3E/%3Crepo%3E/"), + "GitHub fallback must url-encode the / placeholders, got {}", + gh + ); + + app.trigger_platform = TriggerPlatform::Gitlab; + let gl = app.trigger_curl_preview(); + assert!( + gl.contains("/projects/%3Cnamespace%3E%2F%3Cproject%3E/pipeline"), + "GitLab fallback must url-encode the / placeholders, got {}", + gl + ); + } + + #[test] + fn trigger_curl_preview_github_matches_dispatcher_and_url_encodes_segments() { + // The preview MUST agree with what `wrkflw_github::trigger_workflow` + // actually POSTs — the user gets to copy-paste this string, and + // any divergence between the two is the exact "preview lies + // about the dispatch" failure mode this PR was built to close. + // + // Both paths route the workflow identifier through + // `workflow_dispatch_path_segment`, which: drops any subdir + // prefix (GitHub forbids subdirs under `.github/workflows/` + // anyway), preserves an existing `.yml`/`.yaml` suffix, and + // appends `.yml` when absent. The preview then url-encodes + // the segment so shell metacharacters in a filename can't + // ride a copy-paste into the user's terminal. + let mut app = make_app(); + app.trigger_target_cache = Some(TriggerTarget { + platform_label: "GitHub".into(), + repo_label: "bah dotsh/wrk;flw".into(), + default_branch: "main".into(), + note: None, + }); + // Subdir prefix must drop (dispatcher parity); space inside + // the basename must still be encoded (%20). + app.workflows[0].name = "subdir/ci step.yml".to_string(); + let curl = app.trigger_curl_preview(); + assert!( + curl.contains("repos/bah%20dotsh/wrk%3Bflw/"), + "owner/repo must be url-encoded, got {}", + curl + ); + assert!( + curl.contains("actions/workflows/ci%20step.yml/dispatches"), + "workflow segment must drop the subdir prefix and url-encode the rest, got {}", + curl + ); + let url_line = curl + .lines() + .find(|l| l.contains("api.github.com")) + .expect("preview must contain the api.github.com line"); + // Subdir must not leak into the URL — the real dispatcher + // doesn't send it either. + assert!( + !url_line.contains("subdir"), + "subdir prefix must be stripped so the preview matches the dispatcher, got {}", + url_line + ); + // Shell metacharacters must be encoded. + assert!( + !url_line.contains("wrk;flw"), + "raw `;` must not appear in the url line, got {}", + url_line + ); + assert!( + !url_line.contains("ci step"), + "raw space must not appear in the url line, got {}", + url_line + ); + // The segment must use the shared helper: same input, same + // output on both sides. + let expected = wrkflw_github::workflow_dispatch_path_segment(&app.workflows[0].name) + .expect("shared helper must produce a segment"); + assert!( + url_line.contains(&urlencoding::encode(&expected).to_string()), + "preview URL must contain the exact segment the dispatcher will POST, got {}", + url_line + ); + } + + #[test] + fn trigger_curl_preview_github_preserves_yaml_extension() { + // Regression: the old preview stripped `.yml` AND `.yaml` and + // then re-appended `.yml`, silently converting a `.yaml` + // workflow into a `.yml` URL. The shared helper preserves + // whichever extension the user has, so `.yaml` round-trips. + let mut app = make_app(); + app.trigger_target_cache = Some(TriggerTarget { + platform_label: "GitHub".into(), + repo_label: "owner/repo".into(), + default_branch: "main".into(), + note: None, + }); + app.workflows[0].name = "release.yaml".to_string(); + let curl = app.trigger_curl_preview(); + assert!( + curl.contains("actions/workflows/release.yaml/dispatches"), + ".yaml extension must round-trip unchanged, got {}", + curl + ); + } + + #[test] + fn trigger_curl_preview_gitlab_hits_pipeline_endpoint_not_trigger() { + let mut app = make_app(); + app.trigger_platform = TriggerPlatform::Gitlab; + app.trigger_target_cache = Some(TriggerTarget { + platform_label: "GitLab".into(), + repo_label: "group/project".into(), + default_branch: "main".into(), + note: None, + }); + app.trigger_branch = "main".into(); + app.trigger_inputs = vec![("K".into(), "V".into())]; + let curl = app.trigger_curl_preview(); + // Real dispatcher hits `/pipeline`, not `/trigger/pipeline`. + assert!(curl.contains("/projects/group%2Fproject/pipeline")); + assert!(!curl.contains("/trigger/pipeline")); + assert!(curl.contains("PRIVATE-TOKEN: $GITLAB_TOKEN")); + // Body shape matches `{"ref":"…","variables":[{"key":…,"value":…}]}`. + assert!(curl.contains("\"ref\":\"main\"")); + assert!(curl.contains("\"key\":\"K\"")); + assert!(curl.contains("\"value\":\"V\"")); + } + + #[test] + fn trigger_tab_toggle_platform_drops_target_cache() { + let mut app = make_app(); + app.trigger_target_cache = Some(TriggerTarget { + platform_label: "GitHub".into(), + repo_label: "x/y".into(), + default_branch: "main".into(), + note: None, + }); + app.trigger_tab_toggle_platform(); + assert!(app.trigger_target_cache.is_none()); + } + + #[test] + fn in_flight_guard_clears_flag_on_normal_drop() { + // Straight-line: guard drops at end of scope, flag clears. + let flag = Arc::new(AtomicBool::new(true)); + { + let _g = InFlightGuard::arm(Arc::clone(&flag)); + assert!(flag.load(Ordering::SeqCst)); + } + assert!( + !flag.load(Ordering::SeqCst), + "guard Drop must clear the in-flight flag so the Trigger tab unlocks" + ); + } + + #[test] + fn in_flight_guard_clears_flag_even_when_unwinding() { + // Regression: if the dispatch task panics inside reqwest + // (or any SDK we call) the trailing `store(false)` would be + // skipped and the Trigger tab would be locked into + // "already in flight" forever. Drop fires during unwinding, + // so the guard still clears the flag. `catch_unwind` is used + // here to observe the unwind without aborting the test run. + let flag = Arc::new(AtomicBool::new(true)); + let flag_clone = Arc::clone(&flag); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = InFlightGuard::arm(flag_clone); + panic!("simulated dispatcher panic"); + })); + assert!(caught.is_err(), "test harness must observe the panic"); + assert!( + !flag.load(Ordering::SeqCst), + "guard Drop must fire during unwinding so a panicking dispatch can't strand the tab" + ); + } + + #[test] + fn secrets_tab_navigation_is_noop_on_empty_and_bounded_otherwise() { + let mut app = make_app(); + let provider_count = secrets_provider_count(); + // Navigate `provider_count * 3` times without panicking and + // always land on a valid index. + for _ in 0..(provider_count.max(1) * 3) { + app.secrets_tab_next(); + if let Some(i) = app.secrets_list_state.selected() { + assert!(i < provider_count.max(1)); + } + } + for _ in 0..(provider_count.max(1) * 3) { + app.secrets_tab_prev(); + if let Some(i) = app.secrets_list_state.selected() { + assert!(i < provider_count.max(1)); + } + } + } } diff --git a/crates/ui/src/components/progress_bar.rs b/crates/ui/src/components/progress_bar.rs index be11e34..79aa741 100644 --- a/crates/ui/src/components/progress_bar.rs +++ b/crates/ui/src/components/progress_bar.rs @@ -1,5 +1,5 @@ // Progress bar component -use crate::theme::COLORS; +use crate::theme::{self, COLORS}; use ratatui::{ style::{Color, Style}, widgets::Gauge, @@ -18,7 +18,7 @@ impl ProgressBar { ProgressBar { progress: progress.clamp(0.0, 1.0), label: None, - color: COLORS.accent, + color: theme::current_accent(), } } diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index cc64ec5..20359cb 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -8,6 +8,29 @@ use ratatui::{ text::Span, widgets::{Block, BorderType, Borders}, }; +use std::cell::Cell; + +thread_local! { + /// Per-render accent override. Populated by `set_accent_override` + /// at the top of [`crate::views::render_ui`] and consulted by + /// [`current_accent`] inside widget builders. Thread-local so each + /// ratatui backend thread gets its own value; a `Cell>` + /// because renders are single-frame and we never need interior + /// sharing — just a scalar handoff. + static ACCENT_OVERRIDE: Cell> = const { Cell::new(None) }; +} + +/// Install an accent color for the current frame. Pass `None` to +/// fall back to [`COLORS.accent`]. +pub fn set_accent_override(color: Option) { + ACCENT_OVERRIDE.with(|c| c.set(color)); +} + +/// Read the active accent color. Falls back to the static palette +/// value when no override is installed. +pub fn current_accent() -> Color { + ACCENT_OVERRIDE.with(|c| c.get()).unwrap_or(COLORS.accent) +} // ── Color Palette ────────────────────────────────────────────────── @@ -85,16 +108,6 @@ pub fn title_style() -> Style { .add_modifier(Modifier::BOLD) } -pub fn brand_style() -> Style { - Style::default() - .fg(COLORS.accent) - .add_modifier(Modifier::BOLD) -} - -pub fn label_style() -> Style { - Style::default().fg(COLORS.accent) -} - pub fn selected_style() -> Style { Style::default() .bg(COLORS.bg_selected) @@ -195,7 +208,7 @@ pub fn block_focused<'a>(title: &'a str) -> Block<'a> { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(COLORS.border_focused)) + .border_style(Style::default().fg(current_accent())) .title(Span::styled(format!(" {} ", title), title_style())) } @@ -226,7 +239,9 @@ impl BadgeKind { BadgeKind::Warning => COLORS.warning, BadgeKind::Trigger => COLORS.trigger, BadgeKind::Dim => COLORS.text_dim, - BadgeKind::Accent => COLORS.accent, + // Resolve dynamically so Tweaks accent changes recolor + // `BadgeKind::Accent` call sites in the same frame. + BadgeKind::Accent => current_accent(), BadgeKind::Highlight => COLORS.highlight, BadgeKind::Docker => COLORS.runtime_docker, BadgeKind::Podman => COLORS.runtime_podman, diff --git a/crates/ui/src/views/dag_tab.rs b/crates/ui/src/views/dag_tab.rs new file mode 100644 index 0000000..49c8f68 --- /dev/null +++ b/crates/ui/src/views/dag_tab.rs @@ -0,0 +1,449 @@ +// DAG full view — screen 4 from the design. +// +// Two modes behind a single tab (toggled with `g`): +// +// - Graph: jobs laid out in topological columns, each column prefixed +// with a stage label. Edges are drawn on the left gutter of each +// column so a user can see `needs:` at a glance without us having +// to pretend we're an SVG canvas. +// - List: topological stages as headers with jobs listed under each — +// the same data as the design's `TopoList`. Denser, read-easier on +// narrow terminals. +// +// The workflow shown is the one currently focused in the Workflows +// tab. This deliberately mirrors the design (no workflow picker on +// this screen) — the Workflows tab is the selector. + +use crate::app::App; +use crate::components::dag::{self, NodeState}; +use crate::models::WorkflowStatus; +use crate::theme::{self, BadgeKind, COLORS}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Wrap}, + Frame, +}; +use wrkflw_executor::JobStatus; +use wrkflw_parser::workflow::WorkflowDefinition; + +pub fn render_dag_tab(f: &mut Frame<'_>, app: &App, area: Rect) { + let Some(idx) = app.workflow_list_state.selected() else { + render_empty_state( + f, + area, + "No workflow selected — pick one on the Workflows tab.", + ); + return; + }; + let Some(workflow) = app.workflows.get(idx) else { + render_empty_state(f, area, "Workflow selection out of range."); + return; + }; + let Some(def) = workflow.definition.as_ref() else { + render_empty_state( + f, + area, + &format!("Couldn't parse {} — DAG unavailable.", workflow.name), + ); + return; + }; + + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + render_header(f, app, workflow, outer[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(30)]) + .split(outer[1]); + + if app.dag_list_view { + render_topo_list(f, app, def, workflow, idx, body[0]); + } else { + render_graph(f, app, def, workflow, idx, body[0]); + } + render_legend(f, app, body[1]); +} + +fn render_empty_state(f: &mut Frame<'_>, area: Rect, msg: &str) { + let block = theme::block("DAG"); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(msg).style(Style::default().fg(COLORS.text_muted)), + inner, + ); +} + +fn render_header(f: &mut Frame<'_>, app: &App, workflow: &crate::models::Workflow, area: Rect) { + let view_label = if app.dag_list_view { "list" } else { "graph" }; + let spans = vec![ + Span::styled( + workflow.name.clone(), + Style::default() + .fg(COLORS.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " · dependency graph · ", + Style::default().fg(COLORS.text_muted), + ), + theme::badge_outline(view_label, BadgeKind::Info), + Span::raw(" "), + Span::styled( + "press `g` to toggle", + Style::default().fg(COLORS.text_muted), + ), + ]; + f.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Left), + area, + ); +} + +/// State lookup for every named job — consults the current +/// `WorkflowExecution` so a running nightly shows `build` as +/// `Running`, mirroring the design's live DAG. `workflow_idx` is the +/// position of this workflow in `app.workflows`; it's threaded from +/// the caller so we don't have to reach for pointer identity here. +fn state_for_job( + app: &App, + workflow: &crate::models::Workflow, + workflow_idx: usize, + name: &str, +) -> NodeState { + if !matches!( + workflow.status, + WorkflowStatus::Running | WorkflowStatus::Success | WorkflowStatus::Failed + ) { + return NodeState::Pending; + } + let Some(exec) = workflow.execution_details.as_ref() else { + return NodeState::Pending; + }; + match exec.jobs.iter().find(|j| j.name == name) { + Some(j) => match j.status { + JobStatus::Success => NodeState::Success, + JobStatus::Failure => NodeState::Failure, + JobStatus::Skipped => NodeState::Skipped, + }, + None => { + if app.current_execution == Some(workflow_idx) { + NodeState::Running + } else { + NodeState::Pending + } + } + } +} + +fn render_graph( + f: &mut Frame<'_>, + app: &App, + def: &WorkflowDefinition, + workflow: &crate::models::Workflow, + workflow_idx: usize, + area: Rect, +) { + let block = theme::block_focused("DAG · topological columns"); + let inner = block.inner(area); + f.render_widget(block, area); + + let levels = dag::topo_levels(def); + if levels.is_empty() { + f.render_widget( + Paragraph::new("no jobs").style(Style::default().fg(COLORS.text_muted)), + inner, + ); + return; + } + + // Node cards are a fixed 18 cells wide ("╭────────────────╮"). + // If we just split the area into `levels.len()` equal columns, each + // column can shrink below 18 on narrow terminals and the box-drawing + // characters wrap — which looks broken. Instead, clamp the visible + // column count to what fits, and render a trailing "… +N more + // stages" marker so the user knows they're seeing a subset. + const NODE_CARD_W: u16 = 18; + const OVERFLOW_W: u16 = 16; // width reserved for "… +N more" column + let total_stages = levels.len(); + let max_visible = (inner.width / NODE_CARD_W).max(1) as usize; + let (visible_stages, truncated) = if total_stages > max_visible { + // Reserve space for the overflow column by dropping one more + // stage; ensures the tail marker has somewhere to live. + let capped = max_visible.saturating_sub(1).max(1); + (capped, total_stages - capped) + } else { + (total_stages, 0) + }; + + let mut constraints: Vec = (0..visible_stages) + .map(|_| Constraint::Length(NODE_CARD_W)) + .collect(); + if truncated > 0 { + constraints.push(Constraint::Length(OVERFLOW_W)); + } + constraints.push(Constraint::Min(0)); // trailing slack + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(inner); + + // Column labels read straight off the topology ("Stage N"). The + // design handoff uses semantic names like "build" / "test", but + // no such grouping exists in GitHub Actions' workflow YAML today, + // so putting a name here would mean *inventing* one. + for (li, layer) in levels.iter().take(visible_stages).enumerate() { + let col = cols[li]; + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + format!("Stage {}", li + 1), + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + for name in layer { + let st = state_for_job(app, workflow, workflow_idx, name); + let color = match st { + NodeState::Success => COLORS.success, + NodeState::Failure => COLORS.error, + NodeState::Skipped => COLORS.warning, + NodeState::Running => COLORS.info, + NodeState::Pending => COLORS.text_muted, + }; + let glyph = match st { + NodeState::Success => theme::symbols::SUCCESS, + NodeState::Failure => theme::symbols::FAILURE, + NodeState::Skipped => theme::symbols::SKIPPED, + NodeState::Running => theme::spinner(app.spinner_frame), + NodeState::Pending => theme::symbols::NOT_STARTED, + }; + // Node card: two-line "┌─ name ─┐" style in plain text. + lines.push(Line::from(vec![Span::styled( + "╭────────────────╮", + Style::default().fg(color), + )])); + // Distinct name from the outer `truncated` stage-count + // binding so the shadow doesn't mislead a future reader. + let short_name = truncate(name, 12); + let padding = 12usize.saturating_sub(short_name.chars().count()); + lines.push(Line::from(vec![ + Span::styled("│ ", Style::default().fg(color)), + Span::styled(glyph.to_string(), Style::default().fg(color)), + Span::raw(" "), + Span::styled( + short_name, + Style::default() + .fg(if matches!(st, NodeState::Running) { + COLORS.text + } else { + color + }) + .add_modifier(if matches!(st, NodeState::Running) { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + Span::styled(" ".repeat(padding), Style::default().fg(color)), + Span::styled(" │", Style::default().fg(color)), + ])); + // Matrix badge for nodes that carry a strategy.matrix. + let matrix_axes = def + .jobs + .get(name) + .and_then(|j| j.matrix_config()) + .map(|m| m.parameters.len()) + .unwrap_or(0); + if matrix_axes > 0 { + lines.push(Line::from(vec![ + Span::styled("│ ", Style::default().fg(color)), + theme::badge_outline(format!("matrix×{}", matrix_axes), BadgeKind::Info), + Span::styled(" │", Style::default().fg(color)), + ])); + } + lines.push(Line::from(vec![Span::styled( + "╰────────────────╯", + Style::default().fg(color), + )])); + } + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), col); + } + + if truncated > 0 { + let overflow_col = cols[visible_stages]; + let mut overflow_lines: Vec = Vec::new(); + overflow_lines.push(Line::from(vec![Span::styled( + format!("+{}", truncated), + Style::default() + .fg(COLORS.text_muted) + .add_modifier(Modifier::BOLD), + )])); + overflow_lines.push(Line::from(Span::styled( + "more stages", + Style::default().fg(COLORS.text_dim), + ))); + overflow_lines.push(Line::from("")); + overflow_lines.push(Line::from(Span::styled( + "press `g`", + Style::default().fg(COLORS.text_dim), + ))); + overflow_lines.push(Line::from(Span::styled( + "for list view", + Style::default().fg(COLORS.text_dim), + ))); + f.render_widget( + Paragraph::new(overflow_lines).wrap(Wrap { trim: false }), + overflow_col, + ); + } +} + +fn render_topo_list( + f: &mut Frame<'_>, + app: &App, + def: &WorkflowDefinition, + workflow: &crate::models::Workflow, + workflow_idx: usize, + area: Rect, +) { + let block = theme::block_focused("Jobs · topological order"); + let inner = block.inner(area); + f.render_widget(block, area); + + let levels = dag::topo_levels(def); + let mut lines: Vec = Vec::new(); + + for (li, layer) in levels.iter().enumerate() { + lines.push(Line::from(vec![Span::styled( + format!(" Stage {}", li + 1), + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )])); + for name in layer { + let st = state_for_job(app, workflow, workflow_idx, name); + let (glyph, style) = match st { + NodeState::Success => { + (theme::symbols::SUCCESS, Style::default().fg(COLORS.success)) + } + NodeState::Failure => (theme::symbols::FAILURE, Style::default().fg(COLORS.error)), + NodeState::Skipped => { + (theme::symbols::SKIPPED, Style::default().fg(COLORS.warning)) + } + NodeState::Running => ( + theme::spinner(app.spinner_frame), + Style::default().fg(COLORS.info), + ), + NodeState::Pending => ( + theme::symbols::NOT_STARTED, + Style::default().fg(COLORS.text_muted), + ), + }; + let needs: String = def + .jobs + .get(name) + .and_then(|j| j.needs.as_ref()) + .map(|n| { + if n.is_empty() { + "—".to_string() + } else { + n.join(", ") + } + }) + .unwrap_or_else(|| "—".to_string()); + let matrix_badge = def + .jobs + .get(name) + .and_then(|j| j.matrix_config()) + .map(|m| format!(" matrix×{}", m.parameters.len())) + .unwrap_or_default(); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(glyph.to_string(), style), + Span::raw(" "), + Span::styled( + name.clone(), + Style::default() + .fg(COLORS.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled(matrix_badge, Style::default().fg(COLORS.info)), + Span::styled(" needs: ", Style::default().fg(COLORS.text_muted)), + Span::styled(needs, Style::default().fg(COLORS.text_dim)), + ])); + } + lines.push(Line::from("")); + } + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn render_legend(f: &mut Frame<'_>, app: &App, area: Rect) { + let block = theme::block("Legend"); + let inner = block.inner(area); + f.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled(theme::symbols::SUCCESS, Style::default().fg(COLORS.success)), + Span::raw(" success"), + ]), + Line::from(vec![ + Span::styled( + theme::spinner(app.spinner_frame), + Style::default().fg(COLORS.info), + ), + Span::raw(" running"), + ]), + Line::from(vec![ + Span::styled( + theme::symbols::NOT_STARTED, + Style::default().fg(COLORS.text_muted), + ), + Span::raw(" pending"), + ]), + Line::from(vec![ + Span::styled(theme::symbols::SKIPPED, Style::default().fg(COLORS.warning)), + Span::raw(" skipped"), + ]), + Line::from(vec![ + Span::styled(theme::symbols::FAILURE, Style::default().fg(COLORS.error)), + Span::raw(" failed"), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "SHORTCUTS", + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + theme::key_chip("g"), + Span::raw(" "), + Span::styled("toggle graph/list", Style::default().fg(COLORS.text_dim)), + ]), + Line::from(vec![ + theme::key_chip("enter"), + Span::raw(" "), + Span::styled("open Execution", Style::default().fg(COLORS.text_dim)), + ]), + ]; + f.render_widget(Paragraph::new(lines), inner); +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + let mut out: String = s.chars().take(n.saturating_sub(1)).collect(); + out.push('…'); + out + } +} diff --git a/crates/ui/src/views/execution_tab.rs b/crates/ui/src/views/execution_tab.rs index 2bcd426..792d06e 100644 --- a/crates/ui/src/views/execution_tab.rs +++ b/crates/ui/src/views/execution_tab.rs @@ -446,7 +446,7 @@ fn render_empty_state(f: &mut Frame<'_>, area: Rect) { Line::from(""), Line::from(vec![ Span::styled("Switch to ", Style::default().fg(COLORS.text_muted)), - Span::styled("Workflows", Style::default().fg(COLORS.accent)), + Span::styled("Workflows", Style::default().fg(theme::current_accent())), Span::styled(" and press ", Style::default().fg(COLORS.text_muted)), theme::key_chip("r"), Span::styled(" to run, or ", Style::default().fg(COLORS.text_muted)), diff --git a/crates/ui/src/views/help_overlay.rs b/crates/ui/src/views/help_overlay.rs index 12ce613..79e79b7 100644 --- a/crates/ui/src/views/help_overlay.rs +++ b/crates/ui/src/views/help_overlay.rs @@ -13,7 +13,7 @@ fn section_header<'a>(title: &'a str) -> Vec> { Line::from(Span::styled( title, Style::default() - .fg(COLORS.accent) + .fg(theme::current_accent()) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), )), Line::from(Span::styled( @@ -48,9 +48,11 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize) left_lines.extend(section_header("NAVIGATION")); left_lines.push(Line::from("")); left_lines.push(key_line("Tab / Shift+Tab", "Switch between tabs")); - left_lines.push(key_line("1-4 / w,x,l,h", "Jump to specific tab")); + left_lines.push(key_line("1-7", "Jump to tab by number")); + left_lines.push(key_line("w,x,l,h", "Workflows / Execution / Logs / Help")); left_lines.push(key_line("\u{2191}/\u{2193} or k/j", "Navigate lists")); left_lines.push(key_line("Enter", "Select / View details")); + left_lines.push(key_line(",", "Toggle Tweaks overlay")); left_lines.push(key_line("Esc", "Back / Exit help")); left_lines.push(Line::from("")); @@ -117,66 +119,53 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize) right_lines.extend(section_header("LOGS & SEARCH")); right_lines.push(Line::from("")); - right_lines.push(key_line("s", "Toggle log search")); - right_lines.push(key_line("f", "Toggle log filter")); - right_lines.push(key_line("c", "Clear search & filter")); + right_lines.push(key_line("s", "Toggle log search (Logs tab)")); + right_lines.push(key_line("f", "Toggle log filter (Logs tab)")); + right_lines.push(key_line("c", "Clear search & filter (Logs tab)")); right_lines.push(key_line("n", "Next search match")); right_lines.push(key_line("\u{2191}/\u{2193}", "Scroll logs / Navigate")); right_lines.push(Line::from("")); + right_lines.extend(section_header("DAG · TRIGGER · SECRETS")); + right_lines.push(Line::from("")); + right_lines.push(key_line("g", "DAG: toggle graph ↔ list")); + right_lines.push(key_line("p", "Trigger: flip platform github ↔ gitlab")); + right_lines.push(key_line("b", "Trigger: edit branch / ref")); + right_lines.push(key_line("+", "Trigger: add a key=value input")); + right_lines.push(key_line("Tab", "Trigger: next field (in edit mode)")); + right_lines.push(key_line("Enter", "Trigger: dispatch (or commit edit)")); + right_lines.push(key_line("c", "Trigger: copy curl preview to logs")); + right_lines.push(Line::from("")); + right_lines.extend(section_header("TAB OVERVIEW")); right_lines.push(Line::from("")); - right_lines.push(Line::from(vec![ - Span::styled( - "1. Workflows", - Style::default() - .fg(COLORS.accent) - .add_modifier(Modifier::BOLD), + for (idx, name, color, tag) in [ + ( + 1u32, + "Workflows", + theme::current_accent(), + "Browse & select workflows", ), - Span::styled(" \u{2500} Browse & select workflows", theme::dim_style()), - ])); - right_lines.push(Line::from(Span::styled( - " View, select, and run workflows", - theme::muted_style(), - ))); - right_lines.push(Line::from("")); - right_lines.push(Line::from(vec![ - Span::styled( - "2. Execution", - Style::default() - .fg(COLORS.success) - .add_modifier(Modifier::BOLD), + (2, "Execution", COLORS.success, "Monitor job progress"), + (3, "DAG", COLORS.info, "Dependency graph / topological list"), + (4, "Logs", COLORS.info, "Execution logs · search · filter"), + ( + 5, + "Trigger", + COLORS.trigger, + "Dispatch remote workflow_dispatch", ), - Span::styled(" \u{2500} Monitor job progress", theme::dim_style()), - ])); - right_lines.push(Line::from(Span::styled( - " Track jobs, steps, and output", - theme::muted_style(), - ))); - right_lines.push(Line::from("")); - right_lines.push(Line::from(vec![ - Span::styled( - "3. Logs", - Style::default() - .fg(COLORS.info) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" \u{2500} View execution logs", theme::dim_style()), - ])); - right_lines.push(Line::from(Span::styled( - " Search, filter, real-time streaming", - theme::muted_style(), - ))); - right_lines.push(Line::from("")); - right_lines.push(Line::from(vec![ - Span::styled( - "4. Help", - Style::default() - .fg(COLORS.highlight) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" \u{2500} This guide", theme::dim_style()), - ])); + (6, "Secrets", COLORS.warning, "Provider routing & runtime"), + (7, "Help", COLORS.highlight, "This guide"), + ] { + right_lines.push(Line::from(vec![ + Span::styled( + format!("{}. {}", idx, name), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" \u{2500} {}", tag), theme::dim_style()), + ])); + } right_lines.push(Line::from("")); right_lines.extend(section_header("QUICK ACTIONS")); diff --git a/crates/ui/src/views/job_detail.rs b/crates/ui/src/views/job_detail.rs index c816f46..7a35905 100644 --- a/crates/ui/src/views/job_detail.rs +++ b/crates/ui/src/views/job_detail.rs @@ -125,7 +125,7 @@ fn render_tab_strip(f: &mut Frame<'_>, active: usize, area: Rect) { format!(" {} ", label), if is_active { Style::default() - .fg(COLORS.accent) + .fg(theme::current_accent()) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) } else { Style::default().fg(COLORS.text_dim) @@ -380,7 +380,10 @@ fn render_matrix_pane( None => vec![format!("{:?}", value)], }; lines.push(Line::from(vec![ - Span::styled(format!(" {}: ", name), Style::default().fg(COLORS.accent)), + Span::styled( + format!(" {}: ", name), + Style::default().fg(theme::current_accent()), + ), Span::styled(values.join(", "), Style::default().fg(COLORS.text)), ])); } @@ -401,16 +404,94 @@ fn render_matrix_pane( lines.push(Line::from("")); lines.push(Line::from(chips)); } - if !matrix.include.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - format!("INCLUDE ({})", matrix.include.len()), - Style::default() - .fg(COLORS.highlight) - .add_modifier(Modifier::BOLD), - )])); + + // Matrix combinations — real expansion via `wrkflw_matrix::expand_matrix`. + // + // We can show the combos (the *what* — design screen 5's grid) but + // not per-combo runtime status (the *how it went* — we don't track + // status per combo, only aggregated job status). So rows are + // labelled `queued` by default; if the parent job finished we + // inherit its status for every row. This is honest: a future + // executor change to surface per-combo results will drop right + // into this render. + match wrkflw_matrix::expand_matrix(matrix) { + Ok(combos) if !combos.is_empty() => { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + format!("COMBINATIONS ({})", combos.len()), + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )])); + // Key order: show axes in the order they were declared, + // plus any extra keys an `include:` entry introduced, + // appended after (and sorted, since `MatrixCombination.values` + // is a HashMap whose iteration order is not stable — without + // a sort, include-only columns could jitter between frames + // or process runs). + let mut key_order: Vec = matrix.parameters.keys().cloned().collect(); + let mut extra: Vec = Vec::new(); + for c in &combos { + for k in c.values.keys() { + if !key_order.contains(k) && !extra.contains(k) { + extra.push(k.clone()); + } + } + } + extra.sort(); + key_order.extend(extra); + for c in combos.iter().take(32) { + let mut spans: Vec = vec![Span::raw(" ")]; + let status_glyph = inherited_combo_glyph(workflow, job_name); + spans.push(status_glyph); + spans.push(Span::raw(" ")); + for (i, k) in key_order.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", Style::default().fg(COLORS.text_muted))); + } + spans.push(Span::styled( + format!("{}=", k), + Style::default().fg(theme::current_accent()), + )); + let v = c + .values + .get(k) + .map(format_yaml_scalar) + .unwrap_or_else(|| "—".to_string()); + spans.push(Span::styled(v, Style::default().fg(COLORS.text))); + } + if c.is_included { + spans.push(Span::raw(" ")); + spans.push(theme::badge_outline("+include", BadgeKind::Warning)); + } + lines.push(Line::from(spans)); + } + if combos.len() > 32 { + lines.push(Line::from(vec![Span::styled( + format!(" … +{} more", combos.len() - 32), + Style::default().fg(COLORS.text_muted), + )])); + } + } + Ok(_) => { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "(matrix expanded to 0 combinations — check exclude: or empty axes)", + Style::default().fg(COLORS.text_muted), + )])); + } + Err(e) => { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + theme::badge_outline("expansion error", BadgeKind::Error), + Span::raw(" "), + Span::styled(e.to_string(), Style::default().fg(COLORS.text_dim)), + ])); + } } + if !matrix.exclude.is_empty() { + lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( format!("EXCLUDE ({})", matrix.exclude.len()), Style::default() @@ -419,7 +500,60 @@ fn render_matrix_pane( )])); } - f.render_widget(Paragraph::new(lines), inner_area); + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner_area); +} + +fn format_yaml_scalar(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::String(s) => collapse_newlines(s), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Null => "~".to_string(), + other => { + // Sequences and maps round-trip to multi-line YAML; each + // combo renders into a single ratatui Span, so embedded + // newlines would silently garble the layout. Collapse + // them into a visible ` · ` separator. + let raw = serde_yaml::to_string(other).unwrap_or_default(); + collapse_newlines(raw.trim()) + } + } +} + +/// Replace any `\n` / `\r` with a visible separator so multi-line +/// payloads don't break single-Line rendering. +fn collapse_newlines(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut last_was_sep = false; + for ch in s.chars() { + if ch == '\n' || ch == '\r' { + if !last_was_sep { + out.push_str(" · "); + last_was_sep = true; + } + } else { + out.push(ch); + last_was_sep = false; + } + } + out +} + +/// Return a small colored glyph indicating what we know about the +/// parent matrix job's state — per-combo status isn't tracked, so +/// every row mirrors the parent. +fn inherited_combo_glyph<'a>(workflow: &'a crate::models::Workflow, job_name: &'a str) -> Span<'a> { + let job = workflow + .execution_details + .as_ref() + .and_then(|e| e.jobs.iter().find(|j| j.name == job_name)); + let (glyph, color) = match job.map(|j| &j.status) { + Some(JobStatus::Success) => (theme::symbols::SUCCESS, COLORS.success), + Some(JobStatus::Failure) => (theme::symbols::FAILURE, COLORS.error), + Some(JobStatus::Skipped) => (theme::symbols::SKIPPED, COLORS.warning), + None => (theme::symbols::NOT_STARTED, COLORS.text_muted), + }; + Span::styled(glyph.to_string(), Style::default().fg(color)) } // ─── Timeline pane (uses timing component) ──────────────────────── diff --git a/crates/ui/src/views/mod.rs b/crates/ui/src/views/mod.rs index fd7b042..569e235 100644 --- a/crates/ui/src/views/mod.rs +++ b/crates/ui/src/views/mod.rs @@ -1,17 +1,52 @@ // UI Views module +mod dag_tab; mod execution_tab; mod help_overlay; mod job_detail; mod logs_tab; +mod secrets_tab; mod status_bar; mod title_bar; +mod trigger_tab; +mod tweaks_overlay; mod workflows_tab; +pub use title_bar::{ + TAB_COUNT, TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER, TAB_WORKFLOWS, +}; + use crate::app::App; use ratatui::Frame; +/// RAII guard that installs an accent override on construction and +/// clears it on drop. Scoping the thread-local to one render pass +/// stops later code (tests, alternate backends) from inheriting stale +/// state — the thread-local is a handoff, not a setting. +struct AccentScope; + +impl AccentScope { + fn install(color: ratatui::style::Color) -> Self { + crate::theme::set_accent_override(Some(color)); + Self + } +} + +impl Drop for AccentScope { + fn drop(&mut self) { + crate::theme::set_accent_override(None); + } +} + // Main render function for the UI pub fn render_ui(f: &mut Frame<'_>, app: &mut App) { + // Plumb the Tweaks accent into the theme's thread-local so + // anything that calls `theme::current_accent()` or uses + // `block_focused` picks up the user's choice. The guard clears + // the override on drop so the override lives for exactly this + // frame. + let (r, g, b) = app.tweaks_accent.rgb(); + let _accent = AccentScope::install(ratatui::style::Color::Rgb(r, g, b)); + // Check if help should be shown as an overlay if app.show_help { help_overlay::render_help_overlay(f, app.help_scroll); @@ -38,19 +73,52 @@ pub fn render_ui(f: &mut Frame<'_>, app: &mut App) { // Render main content based on selected tab match app.selected_tab { - 0 => workflows_tab::render_workflows_tab(f, app, main_chunks[1]), - 1 => { + TAB_WORKFLOWS => workflows_tab::render_workflows_tab(f, app, main_chunks[1]), + TAB_EXECUTION => { if app.detailed_view { job_detail::render_job_detail_view(f, app, main_chunks[1]) } else { execution_tab::render_execution_tab(f, app, main_chunks[1]) } } - 2 => logs_tab::render_logs_tab(f, app, main_chunks[1]), - 3 => help_overlay::render_help_content(f, main_chunks[1], app.help_scroll), + TAB_DAG => dag_tab::render_dag_tab(f, app, main_chunks[1]), + TAB_LOGS => logs_tab::render_logs_tab(f, app, main_chunks[1]), + TAB_TRIGGER => trigger_tab::render_trigger_tab(f, app, main_chunks[1]), + TAB_SECRETS => secrets_tab::render_secrets_tab(f, app, main_chunks[1]), + TAB_HELP => help_overlay::render_help_content(f, main_chunks[1], app.help_scroll), _ => {} } // Render status bar status_bar::render_status_bar(f, app, main_chunks[2]); + + // Tweaks overlay is rendered last so it sits above the main view + // (matches the floating `TweaksPanel` in the design's bottom-right). + if app.tweaks_open { + tweaks_overlay::render_tweaks_overlay(f, app, size); + } +} + +#[cfg(test)] +mod tests { + use super::AccentScope; + use crate::theme::{self, COLORS}; + use ratatui::style::Color; + + #[test] + fn accent_scope_clears_thread_local_on_drop() { + // Regression guard: the thread-local accent override is a + // per-frame handoff, not a setting. Installing it and dropping + // the guard must restore `current_accent()` to the static + // palette so later code (tests, alternate backends, the next + // frame) doesn't inherit stale state. Without this contract a + // test that renders with a Tweaks accent installed could leak + // the override into every subsequent test on the same thread. + assert_eq!(theme::current_accent(), COLORS.accent); + { + let _guard = AccentScope::install(Color::Rgb(0xff, 0x00, 0x00)); + assert_eq!(theme::current_accent(), Color::Rgb(0xff, 0x00, 0x00)); + } + assert_eq!(theme::current_accent(), COLORS.accent); + } } diff --git a/crates/ui/src/views/secrets_tab.rs b/crates/ui/src/views/secrets_tab.rs new file mode 100644 index 0000000..1fabe88 --- /dev/null +++ b/crates/ui/src/views/secrets_tab.rs @@ -0,0 +1,292 @@ +// Secrets & runtime — screen 7 from the design. +// +// Honesty note: we don't have a rich secrets metadata store (last-used +// timestamps, length, scope etc. are not persisted anywhere). We also +// don't read the user's real secrets config file yet — the tab shows +// `SecretConfig::default()`, i.e. the two providers that are always +// wired (env + file). The header badge therefore says "defaults" so +// the user isn't misled into believing a customised config has been +// loaded. +// +// The design included a "reveal 5s" cleartext toggle on individual +// secret values. We don't render that: there are no per-secret values +// at this layer to reveal, only provider-source descriptors +// (a filesystem path or an env-var prefix). Masking a filename is +// theatre — it doesn't protect anything and confuses the user about +// what "masking" means here. When `SecretManager::list_known_keys()` +// lands the reveal toggle can come back attached to actual values. +// +// A future PR can flesh this out — e.g. plumb through a real config +// loader plus a key-list for the left pane — and this layout will +// accommodate it without restructure. + +use crate::app::App; +use crate::theme::{self, BadgeKind, COLORS}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, Paragraph, Wrap}, + Frame, +}; +use wrkflw_executor::RuntimeType; +use wrkflw_secrets::{SecretConfig, SecretProviderConfig}; + +pub fn render_secrets_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + render_header(f, outer[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Min(0)]) + .split(outer[1]); + + // One provider read per frame — the three panes share the same slice. + let rows = provider_entries(); + render_providers_pane(f, app, &rows, body[0]); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(40), Constraint::Min(0)]) + .split(body[1]); + render_detail_pane(f, app, &rows, right[0]); + render_runtime_pane(f, app, &rows, right[1]); +} + +fn render_header(f: &mut Frame<'_>, area: Rect) { + let spans = vec![ + Span::styled( + "Secrets & runtime", + Style::default() + .fg(COLORS.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(COLORS.text_muted)), + theme::badge_outline("masking: on", BadgeKind::Success), + Span::raw(" "), + // "defaults" rather than "configured": the tab reads + // `SecretConfig::default()` unconditionally — a custom + // config file is not loaded yet. Labelling this "configured" + // would be the exact kind of quiet UI lie PR #104 set out to + // avoid. + theme::badge_outline("default providers", BadgeKind::Info), + ]; + f.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Left), + area, + ); +} + +fn provider_entries() -> Vec<(String, SecretProviderConfig)> { + let cfg = SecretConfig::default(); + let mut rows: Vec<(String, SecretProviderConfig)> = cfg.providers.into_iter().collect(); + // Deterministic order — HashMap iteration isn't stable. + rows.sort_by(|(a, _), (b, _)| a.cmp(b)); + rows +} + +fn render_providers_pane( + f: &mut Frame<'_>, + app: &mut App, + rows: &[(String, SecretProviderConfig)], + area: Rect, +) { + let block = theme::block_focused("Providers"); + let inner = block.inner(area); + f.render_widget(block, area); + + let items: Vec = rows + .iter() + .map(|(name, cfg)| { + let kind = match cfg { + SecretProviderConfig::Environment { prefix } => match prefix { + Some(p) => format!("env (prefix: {})", p), + None => "env".to_string(), + }, + SecretProviderConfig::File { path } => format!("file → {}", path), + }; + let spans = vec![ + Span::styled("◉ ", Style::default().fg(COLORS.warning)), + Span::styled( + name.clone(), + Style::default() + .fg(COLORS.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ", Style::default()), + Span::styled(kind, Style::default().fg(COLORS.text_dim)), + ]; + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items) + .highlight_style( + Style::default() + .bg(COLORS.bg_selected) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(" ▸ "); + + f.render_stateful_widget(list, inner, &mut app.secrets_list_state); +} + +fn render_detail_pane( + f: &mut Frame<'_>, + app: &App, + rows: &[(String, SecretProviderConfig)], + area: Rect, +) { + let sel = app.secrets_list_state.selected().unwrap_or(0); + let (name, cfg) = match rows.get(sel) { + Some(r) => r, + None => { + let block = theme::block("Detail"); + f.render_widget(block, area); + return; + } + }; + + let title = format!("Provider · {}", name); + let block = theme::block(&title); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + // Source descriptor (file path or env-var prefix) — shown plainly. + // These aren't secret values, so bullet-masking them would be + // theatre (see module header). Real per-secret values will come + // later and land in a dedicated row with an actual reveal toggle. + let (source_label, source_value) = match cfg { + SecretProviderConfig::Environment { prefix } => ( + "Source".to_string(), + prefix + .clone() + .map(|p| format!("env vars matching {}*", p)) + .unwrap_or_else(|| "any env var".to_string()), + ), + SecretProviderConfig::File { path } => ("Path".to_string(), path.clone()), + }; + lines.push(Line::from(vec![ + Span::styled( + format!("{}: ", source_label), + Style::default().fg(COLORS.text_muted), + ), + Span::styled(source_value, Style::default().fg(COLORS.text)), + ])); + lines.push(Line::from("")); + + let kind_label = match cfg { + SecretProviderConfig::Environment { .. } => "environment", + SecretProviderConfig::File { .. } => "file", + }; + lines.push(kv("Kind", kind_label)); + lines.push(kv("Masking", "applies to resolved values (not shown here)")); + + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn render_runtime_pane( + f: &mut Frame<'_>, + app: &App, + rows: &[(String, SecretProviderConfig)], + area: Rect, +) { + let block = theme::block("Runtime"); + let inner = block.inner(area); + f.render_widget(block, area); + + // Pills row. + let pill = |label: &str, kind: BadgeKind, active: bool| -> Vec> { + if active { + vec![theme::badge_solid(label.to_string(), kind), Span::raw(" ")] + } else { + vec![ + theme::badge_outline(label.to_string(), kind), + Span::raw(" "), + ] + } + }; + let mut pills: Vec = Vec::new(); + pills.extend(pill( + "Docker", + BadgeKind::Docker, + matches!(app.runtime_type, RuntimeType::Docker), + )); + pills.extend(pill( + "Podman", + BadgeKind::Podman, + matches!(app.runtime_type, RuntimeType::Podman), + )); + pills.extend(pill( + "Emulation", + BadgeKind::Emulation, + matches!(app.runtime_type, RuntimeType::Emulation), + )); + pills.extend(pill( + "Secure-emu", + BadgeKind::Secure, + matches!(app.runtime_type, RuntimeType::SecureEmulation), + )); + let mut lines: Vec = vec![Line::from(pills)]; + lines.push(Line::from("")); + lines.push(kv("Active", app.runtime_type_name())); + lines.push(kv( + "Available", + if app.runtime_available { + "yes" + } else { + "no (will use emulation)" + }, + )); + lines.push(kv( + "Preserve on failure", + if app.preserve_containers_on_failure { + "on" + } else { + "off" + }, + )); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "PROVIDERS · routing", + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )])); + for (name, cfg) in rows { + let right = match cfg { + SecretProviderConfig::Environment { prefix } => prefix + .clone() + .map(|p| format!("{}*", p)) + .unwrap_or_else(|| "$*".to_string()), + SecretProviderConfig::File { path } => path.clone(), + }; + lines.push(Line::from(vec![ + Span::styled(name.clone(), Style::default().fg(theme::current_accent())), + Span::raw(" → "), + Span::styled(right, Style::default().fg(COLORS.text)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + theme::key_chip("e"), + Span::raw(" "), + Span::styled("cycle runtime", Style::default().fg(COLORS.text_dim)), + ])); + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn kv<'a>(key: &'a str, value: impl Into) -> Line<'a> { + Line::from(vec![ + Span::styled( + format!(" {:<20}", key), + Style::default().fg(COLORS.text_muted), + ), + Span::styled(value.into(), Style::default().fg(COLORS.text)), + ]) +} diff --git a/crates/ui/src/views/status_bar.rs b/crates/ui/src/views/status_bar.rs index a5e3b7d..968ad67 100644 --- a/crates/ui/src/views/status_bar.rs +++ b/crates/ui/src/views/status_bar.rs @@ -1,4 +1,5 @@ // Status bar — left-aligned key chips, right-aligned runtime + meta. +use super::{TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER, TAB_WORKFLOWS}; use crate::app::App; use crate::models::StatusSeverity; use crate::theme::{self, BadgeKind, COLORS}; @@ -94,7 +95,7 @@ pub fn render_status_bar(f: &mut Frame<'_>, app: &App, area: Rect) { fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> { match app.selected_tab { - 0 => { + TAB_WORKFLOWS => { if app.job_selection_mode { vec![ ("Enter", "run"), @@ -124,10 +125,10 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> { ] } } - 1 => { + TAB_EXECUTION => { if app.detailed_view { vec![ - ("Tab", "switch pane"), + ("Tab", "sub-tab"), ("↑↓", "steps"), ("Esc", "back"), ("?", "help"), @@ -136,20 +137,43 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> { vec![ ("j/k", "move"), ("Enter", "inspect"), - ("/", "search"), - ("p", "pause"), ("?", "help"), + ("q", "quit"), ] } } - 2 => vec![ + TAB_DAG => vec![ + ("g", "graph/list"), + ("↑↓", "workflows"), + (",", "tweaks"), + ("?", "help"), + ], + TAB_LOGS => vec![ ("↑↓", "scroll"), ("s", "search"), ("f", "filter"), + ("c", "clear"), + ("n", "next match"), ("?", "help"), ("q", "quit"), ], - 3 => vec![("↑↓", "scroll"), ("?", "close"), ("q", "quit")], + TAB_TRIGGER => vec![ + ("p", "platform"), + ("↑↓", "workflow"), + ("b", "edit branch"), + ("+", "add input"), + ("Tab", "next field"), + ("Enter", "dispatch"), + ("c", "copy curl"), + ("?", "help"), + ], + TAB_SECRETS => vec![ + ("↑↓", "provider"), + ("e", "runtime"), + (",", "tweaks"), + ("?", "help"), + ], + TAB_HELP => vec![("↑↓", "scroll"), ("?", "close"), ("q", "quit")], _ => vec![], } } diff --git a/crates/ui/src/views/title_bar.rs b/crates/ui/src/views/title_bar.rs index 6366070..7741d76 100644 --- a/crates/ui/src/views/title_bar.rs +++ b/crates/ui/src/views/title_bar.rs @@ -11,7 +11,27 @@ use ratatui::{ }; use wrkflw_executor::RuntimeType; -const TAB_LABELS: [&str; 4] = ["Workflows", "Execution", "Logs", "Help"]; +pub const TAB_LABELS: [&str; 7] = [ + "Workflows", + "Execution", + "DAG", + "Logs", + "Trigger", + "Secrets", + "Help", +]; +pub const TAB_COUNT: usize = TAB_LABELS.len(); + +// Canonical tab indices. Kept as `usize` constants rather than an enum +// so they drop into the existing `selected_tab: usize` comparisons and +// `switch_tab(usize)` calls without conversion. +pub const TAB_WORKFLOWS: usize = 0; +pub const TAB_EXECUTION: usize = 1; +pub const TAB_DAG: usize = 2; +pub const TAB_LOGS: usize = 3; +pub const TAB_TRIGGER: usize = 4; +pub const TAB_SECRETS: usize = 5; +pub const TAB_HELP: usize = 6; pub fn render_title_bar(f: &mut Frame<'_>, app: &App, area: Rect) { let chunks = Layout::default() @@ -24,13 +44,12 @@ pub fn render_title_bar(f: &mut Frame<'_>, app: &App, area: Rect) { .split(area); // ─── Brand ──────────────────────────────────────────────── + let accent = theme::current_accent(); let brand = Paragraph::new(Line::from(vec![ - Span::styled(" w∿w ", Style::default().fg(COLORS.accent)), + Span::styled(" w∿w ", Style::default().fg(accent)), Span::styled( "wrkflw", - Style::default() - .fg(COLORS.accent) - .add_modifier(Modifier::BOLD), + Style::default().fg(accent).add_modifier(Modifier::BOLD), ), ])) .style(Style::default().bg(COLORS.bg_dark)) diff --git a/crates/ui/src/views/trigger_tab.rs b/crates/ui/src/views/trigger_tab.rs new file mode 100644 index 0000000..6eecd31 --- /dev/null +++ b/crates/ui/src/views/trigger_tab.rs @@ -0,0 +1,332 @@ +// Remote trigger — screen 8 from the design. +// +// Two-pane layout: +// - Left: target form (platform · repo · workflow · branch · token · inputs) +// - Right: live curl-equivalent preview of the POST we'd send +// +// Backing features that already exist and this tab binds to: +// - wrkflw_github::get_repo_info (git `origin` → owner/repo/default_branch) +// - wrkflw_github::trigger_workflow (workflow_dispatch) +// - wrkflw_gitlab::get_repo_info (same, GitLab flavour) +// - wrkflw_gitlab::trigger_pipeline +// +// Repo info is resolved once per platform and cached on `App` +// (`trigger_tab_target`) so we don't re-shell `git remote` on every +// render. The cache is invalidated on platform toggle. + +use crate::app::{App, TriggerPlatform}; +use crate::theme::{self, BadgeKind, COLORS}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Wrap}, + Frame, +}; + +pub fn render_trigger_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + render_header(f, app, outer[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Min(0)]) + .split(outer[1]); + + render_target_pane(f, app, body[0]); + render_preview_pane(f, app, body[1]); +} + +/// An env token we treat as "set". Empty string doesn't count — users +/// occasionally `export GITHUB_TOKEN=` to clear the value without +/// unsetting the var, and calling that "authenticated" would mislead. +fn token_is_set(var: &str) -> bool { + std::env::var(var).ok().is_some_and(|v| !v.is_empty()) +} + +fn render_header(f: &mut Frame<'_>, app: &App, area: Rect) { + let auth_state = match app.trigger_platform { + TriggerPlatform::Github => { + if token_is_set("GITHUB_TOKEN") { + ("authenticated", BadgeKind::Success) + } else { + ("GITHUB_TOKEN missing", BadgeKind::Error) + } + } + TriggerPlatform::Gitlab => { + if token_is_set("GITLAB_TOKEN") { + ("authenticated", BadgeKind::Success) + } else { + ("GITLAB_TOKEN missing", BadgeKind::Error) + } + } + }; + + let header = Line::from(vec![ + Span::styled( + "TRIGGER REMOTE", + Style::default() + .fg(COLORS.trigger) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!( + " · dispatch workflow on {} · ", + app.trigger_platform.as_str() + ), + Style::default().fg(COLORS.text_muted), + ), + theme::badge_outline(auth_state.0, auth_state.1), + ]); + f.render_widget(Paragraph::new(header).alignment(Alignment::Left), area); +} + +fn render_target_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) { + let block = theme::block_focused("Target"); + let inner = block.inner(area); + f.render_widget(block, area); + + // Clone out of the cache so we don't hold an immutable borrow of + // `app` while the rest of this function reads other fields. The + // clone is a few small strings; cheap. + let target = app.trigger_tab_target().clone(); + let mut lines: Vec = Vec::new(); + + // Platform row — pill group. + lines.push(Line::from(vec![Span::styled( + "PLATFORM", + Style::default() + .fg(COLORS.text_muted) + .add_modifier(Modifier::BOLD), + )])); + let mk_pill = |label: &str, kind: BadgeKind, active: bool| -> Span<'_> { + if active { + theme::badge_solid(label.to_string(), kind) + } else { + theme::badge_outline(label.to_string(), kind) + } + }; + lines.push(Line::from(vec![ + mk_pill( + "github", + BadgeKind::Trigger, + matches!(app.trigger_platform, TriggerPlatform::Github), + ), + Span::raw(" "), + mk_pill( + "gitlab", + BadgeKind::Warning, + matches!(app.trigger_platform, TriggerPlatform::Gitlab), + ), + Span::styled( + " press `p` to toggle", + Style::default().fg(COLORS.text_muted), + ), + ])); + lines.push(Line::from("")); + + // Target rows. + lines.push(field_row("Platform", &target.platform_label)); + lines.push(field_row("Repository", &target.repo_label)); + let wf_label = app + .trigger_selected_workflow_name() + .unwrap_or(""); + let wf_hint = format!( + "{}/{}", + app.trigger_workflow_idx + 1, + app.workflows.len().max(1) + ); + lines.push(field_row_hl("Workflow", wf_label, &wf_hint)); + let branch_display = if app.trigger_branch.is_empty() { + if app.trigger_branch_focused { + // Focused but no characters typed yet — show an empty + // edit caret rather than the resolved default so the + // user can see they're starting fresh. + "_".to_string() + } else { + format!("(default: {})", target.default_branch) + } + } else { + app.trigger_branch.clone() + }; + if app.trigger_branch_focused { + lines.push(Line::from(vec![ + Span::styled( + format!(" {:<14}", "Branch / ref"), + Style::default().fg(COLORS.text_muted), + ), + Span::styled( + branch_display, + Style::default() + .fg(COLORS.bg_dark) + .bg(theme::current_accent()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " (Enter/Esc to commit — Esc clears)", + Style::default().fg(COLORS.text_dim), + ), + ])); + } else { + lines.push(field_row("Branch / ref", &branch_display)); + } + lines.push(field_row( + "Token", + match app.trigger_platform { + TriggerPlatform::Github => "$GITHUB_TOKEN", + TriggerPlatform::Gitlab => "$GITLAB_TOKEN", + }, + )); + + if let Some(note) = target.note { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + theme::badge_outline("warn", BadgeKind::Warning), + Span::raw(" "), + Span::styled(note, Style::default().fg(COLORS.text_dim)), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "INPUTS", + Style::default() + .fg(COLORS.highlight) + .add_modifier(Modifier::BOLD), + )])); + if app.trigger_inputs.is_empty() { + lines.push(Line::from(vec![Span::styled( + " (none) — press `+` to add a key=value input", + Style::default().fg(COLORS.text_muted), + )])); + } else { + for (i, (k, v)) in app.trigger_inputs.iter().enumerate() { + let editing = app.trigger_input_cursor == Some(i); + let k_focus = editing && !app.trigger_input_on_value; + let v_focus = editing && app.trigger_input_on_value; + let k_display = if k.is_empty() && !k_focus { + "".to_string() + } else { + k.clone() + }; + let v_display = if v.is_empty() && !v_focus { + "".to_string() + } else { + v.clone() + }; + let k_style = if k_focus { + Style::default() + .fg(COLORS.bg_dark) + .bg(theme::current_accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::current_accent()) + }; + let v_style = if v_focus { + Style::default() + .fg(COLORS.bg_dark) + .bg(theme::current_accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(COLORS.text) + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(k_display, k_style), + Span::styled(" = ", Style::default().fg(COLORS.text_muted)), + Span::styled(v_display, v_style), + ])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + theme::key_chip("p"), + Span::raw(" "), + Span::styled("platform", Style::default().fg(COLORS.text_dim)), + Span::raw(" "), + theme::key_chip("↑↓"), + Span::raw(" "), + Span::styled("workflow", Style::default().fg(COLORS.text_dim)), + Span::raw(" "), + theme::key_chip("b"), + Span::raw(" "), + Span::styled("edit branch", Style::default().fg(COLORS.text_dim)), + Span::raw(" "), + theme::key_chip("+"), + Span::raw(" "), + Span::styled("add input", Style::default().fg(COLORS.text_dim)), + Span::raw(" "), + theme::key_chip("tab"), + Span::raw(" "), + Span::styled("next field", Style::default().fg(COLORS.text_dim)), + ])); + lines.push(Line::from(vec![ + theme::key_chip("enter"), + Span::raw(" "), + Span::styled( + "dispatch (or commit edit)", + Style::default().fg(COLORS.text_dim), + ), + Span::raw(" "), + theme::key_chip("c"), + Span::raw(" "), + Span::styled("copy curl → logs", Style::default().fg(COLORS.text_dim)), + ])); + + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn render_preview_pane(f: &mut Frame<'_>, app: &App, area: Rect) { + let block = theme::block("Preview · curl equivalent"); + let inner = block.inner(area); + f.render_widget(block, area); + + // Each flag lives on its own line (joined in `trigger_curl_preview` + // with ` \\\n`). Splitting on `\n` gives us one ratatui Line per + // flag so a narrow pane doesn't soft-wrap mid-header. + let lines: Vec = app + .trigger_curl_preview() + .split('\n') + .map(|s| { + Line::from(Span::styled( + s.to_string(), + Style::default().fg(COLORS.text), + )) + }) + .collect(); + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn field_row<'a>(label: &'a str, value: &'a str) -> Line<'a> { + Line::from(vec![ + Span::styled( + format!(" {:<14}", label), + Style::default().fg(COLORS.text_muted), + ), + Span::styled(value.to_string(), Style::default().fg(COLORS.text)), + ]) +} + +fn field_row_hl<'a>(label: &'a str, value: &'a str, hint: &str) -> Line<'a> { + Line::from(vec![ + Span::styled( + format!(" {:<14}", label), + Style::default().fg(COLORS.text_muted), + ), + Span::styled( + value.to_string(), + Style::default() + .fg(COLORS.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" [{}]", hint), + Style::default().fg(COLORS.text_dim), + ), + ]) +} diff --git a/crates/ui/src/views/tweaks_overlay.rs b/crates/ui/src/views/tweaks_overlay.rs new file mode 100644 index 0000000..164ecb6 --- /dev/null +++ b/crates/ui/src/views/tweaks_overlay.rs @@ -0,0 +1,117 @@ +// Tweaks overlay — the design's floating `TweaksPanel`, ported to a +// ratatui popup. +// +// We wire up the knobs that actually plumb through to the theme and +// layouts. Anything we *can't* back up end-to-end (e.g. a full light +// theme, which would need to re-table all the COLORS constants) is +// omitted rather than rendered as a dead toggle — matches the rule +// from PR #104: "A UI without backing data is worse than no UI." +// +// The key dispatch in `app/mod.rs` treats the overlay as modal: +// while `tweaks_open` is true, unmatched keys are swallowed instead +// of falling through to the global handler. The one exception is `q`, +// which always quits — swallowing quit silently is a discoverability +// trap, and quit is universally modal-safe in this TUI. +// +// Controls: +// - `a` / `A` : cycle accent forwards (wraps) +// - `esc` / `,` : close +// - `q` : quit (same as anywhere else) + +use crate::app::{Accent, App}; +use crate::theme::{self, COLORS}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; + +pub fn render_tweaks_overlay(f: &mut Frame<'_>, app: &App, area: Rect) { + // Anchor the panel to the bottom-right, sized like the design's + // 260×auto card. We use absolute dimensions rather than a fraction + // so the panel looks right on wide 4K terminals instead of growing + // into a banner. + let panel_w: u16 = 38; + let panel_h: u16 = 8; + let x = area.right().saturating_sub(panel_w + 2); + let y = area.bottom().saturating_sub(panel_h + 2); + let panel_rect = Rect { + x, + y, + width: panel_w.min(area.width), + height: panel_h.min(area.height), + }; + + // Clear behind the panel so the underlying tab doesn't bleed + // through. + f.render_widget(Clear, panel_rect); + + let block = theme::block_focused("Tweaks"); + let inner = block.inner(panel_rect); + f.render_widget(block, panel_rect); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(inner); + + render_accent_row(f, app, rows[0]); + render_shortcut_hint(f, rows[1]); +} + +fn render_accent_row(f: &mut Frame<'_>, app: &App, area: Rect) { + let swatch = |c: Accent, active: bool| -> Span<'_> { + let (r, g, b) = c.rgb(); + let bg = Color::Rgb(r, g, b); + let label = format!(" {} ", if active { "●" } else { " " }); + Span::styled( + label, + Style::default() + .bg(bg) + .fg(COLORS.bg_dark) + .add_modifier(if active { + Modifier::BOLD + } else { + Modifier::empty() + }), + ) + }; + let mut spans: Vec = vec![Span::styled( + "accent ", + Style::default().fg(COLORS.text_muted), + )]; + for c in [ + Accent::Cyan, + Accent::Amber, + Accent::Green, + Accent::Violet, + Accent::Coral, + ] { + spans.push(swatch(c, app.tweaks_accent == c)); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + app.tweaks_accent.as_str(), + Style::default().fg(COLORS.text), + )); + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_shortcut_hint(f: &mut Frame<'_>, area: Rect) { + let spans = vec![ + theme::key_chip("a"), + Span::raw(" "), + Span::styled("cycle accent", Style::default().fg(COLORS.text_dim)), + Span::raw(" "), + theme::key_chip(","), + Span::raw(" "), + Span::styled("close", Style::default().fg(COLORS.text_dim)), + ]; + f.render_widget(Paragraph::new(Line::from(spans)), area); +} diff --git a/crates/ui/src/views/workflows_tab.rs b/crates/ui/src/views/workflows_tab.rs index 750caa0..4e5963c 100644 --- a/crates/ui/src/views/workflows_tab.rs +++ b/crates/ui/src/views/workflows_tab.rs @@ -156,7 +156,7 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) { if let Some(def) = wf.definition.as_ref() { lines.push(Line::from(vec![ - Span::styled("name: ", Style::default().fg(COLORS.accent)), + Span::styled("name: ", Style::default().fg(theme::current_accent())), Span::styled( def.name.clone(), Style::default() @@ -170,11 +170,11 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) { def.on.join(", ") }; lines.push(Line::from(vec![ - Span::styled("on: ", Style::default().fg(COLORS.accent)), + Span::styled("on: ", Style::default().fg(theme::current_accent())), Span::styled(triggers, Style::default().fg(COLORS.text_dim)), ])); lines.push(Line::from(vec![ - Span::styled("jobs: ", Style::default().fg(COLORS.accent)), + Span::styled("jobs: ", Style::default().fg(theme::current_accent())), Span::styled( format!("{}", def.jobs.len()), Style::default().fg(COLORS.text_dim), @@ -234,7 +234,7 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) { Style::default().fg(COLORS.text_muted), ))); lines.push(Line::from(vec![ - Span::styled("jobs: ", Style::default().fg(COLORS.accent)), + Span::styled("jobs: ", Style::default().fg(theme::current_accent())), Span::styled( format!("{}", wf.job_names.len()), Style::default().fg(COLORS.text_dim), diff --git a/examples/ui-demo/01-dag-diamond.yml b/examples/ui-demo/01-dag-diamond.yml new file mode 100644 index 0000000..ae4d04f --- /dev/null +++ b/examples/ui-demo/01-dag-diamond.yml @@ -0,0 +1,36 @@ +name: DAG — Diamond + +# Classic diamond: one fan-out stage, one fan-in stage. +# Exercises: +# - DAG tab graph mode: two middle-column nodes sharing a parent + child. +# - DAG tab list mode: three topological stages collapsed into rows. +# - `needs:` edge rendering in the left gutter. + +on: + push: + branches: [main] + +jobs: + checkout: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "sources ready" + + lint: + runs-on: ubuntu-latest + needs: [checkout] + steps: + - run: echo "linting" + + test: + runs-on: ubuntu-latest + needs: [checkout] + steps: + - run: echo "running unit tests" + + package: + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - run: echo "packaging artifact" diff --git a/examples/ui-demo/02-dag-wide-fan.yml b/examples/ui-demo/02-dag-wide-fan.yml new file mode 100644 index 0000000..f06b6af --- /dev/null +++ b/examples/ui-demo/02-dag-wide-fan.yml @@ -0,0 +1,85 @@ +name: DAG — Wide fan-out / fan-in + +# Realistic CI pipeline with several parallel jobs per stage. Good for +# stress-testing the DAG tab's column layout (graph mode) and stage +# labels (list mode). Also exercises the Workflows tab card with many +# jobs summarised. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # Stage 0 — single root. + setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "installing toolchain" + + # Stage 1 — four parallel checks off the root. + lint-rust: + runs-on: ubuntu-latest + needs: [setup] + steps: + - run: cargo fmt --check + + lint-yaml: + runs-on: ubuntu-latest + needs: [setup] + steps: + - run: echo "yamllint ." + + audit: + runs-on: ubuntu-latest + needs: [setup] + steps: + - run: echo "cargo audit" + + typecheck: + runs-on: ubuntu-latest + needs: [setup] + steps: + - run: echo "cargo check --all-targets" + + # Stage 2 — tests gated by stage 1. + unit-tests: + runs-on: ubuntu-latest + needs: [lint-rust, typecheck] + steps: + - run: echo "cargo test --lib" + + integration-tests: + runs-on: ubuntu-latest + needs: [lint-rust, typecheck] + steps: + - run: echo "cargo test --test '*'" + + # Stage 3 — build once tests pass. + build: + runs-on: ubuntu-latest + needs: [unit-tests, integration-tests, lint-yaml, audit] + steps: + - run: cargo build --release + + # Stage 4 — fan-out publish. + publish-crate: + runs-on: ubuntu-latest + needs: [build] + steps: + - run: echo "cargo publish --dry-run" + + publish-image: + runs-on: ubuntu-latest + needs: [build] + steps: + - run: echo "docker build + push" + + # Stage 5 — fan-in release. + release: + runs-on: ubuntu-latest + needs: [publish-crate, publish-image] + steps: + - run: echo "cutting release" diff --git a/examples/ui-demo/03-dag-linear.yml b/examples/ui-demo/03-dag-linear.yml new file mode 100644 index 0000000..6505426 --- /dev/null +++ b/examples/ui-demo/03-dag-linear.yml @@ -0,0 +1,40 @@ +name: DAG — Linear chain + +# Five-job linear chain. Renders as five sequential columns in graph +# mode and five single-job stages in list mode — the two views should +# read identically on this one, which is the quickest way to spot a +# regression in either renderer. + +on: + push: + branches: [main] + +jobs: + stage-a: + runs-on: ubuntu-latest + steps: + - run: echo "a" + + stage-b: + runs-on: ubuntu-latest + needs: [stage-a] + steps: + - run: echo "b" + + stage-c: + runs-on: ubuntu-latest + needs: [stage-b] + steps: + - run: echo "c" + + stage-d: + runs-on: ubuntu-latest + needs: [stage-c] + steps: + - run: echo "d" + + stage-e: + runs-on: ubuntu-latest + needs: [stage-d] + steps: + - run: echo "e" diff --git a/examples/ui-demo/04-trigger-dispatch.yml b/examples/ui-demo/04-trigger-dispatch.yml new file mode 100644 index 0000000..ad8ac66 --- /dev/null +++ b/examples/ui-demo/04-trigger-dispatch.yml @@ -0,0 +1,50 @@ +name: Trigger — workflow_dispatch with inputs + +# Primary target for the Trigger tab (screen 8). Renders with: +# - A branch input that defaults to the repo's default branch. +# - A key=value input grid seeded from the workflow's declared inputs. +# - A live curl preview of the POST to the GitHub dispatches endpoint. + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment" + required: true + default: "staging" + type: choice + options: + - staging + - production + - canary + version: + description: "Version tag to deploy (e.g. v1.4.2)" + required: true + type: string + dry_run: + description: "Skip destructive steps" + required: false + default: true + type: boolean + notes: + description: "Release notes (markdown, optional)" + required: false + type: string + push: + branches: [main] + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - run: | + echo "env=${{ inputs.environment }}" + echo "version=${{ inputs.version }}" + echo "dry_run=${{ inputs.dry_run }}" + + deploy: + runs-on: ubuntu-latest + needs: [dispatch] + if: ${{ inputs.dry_run != true }} + steps: + - run: echo "deploying ${{ inputs.version }} to ${{ inputs.environment }}" diff --git a/examples/ui-demo/05-matrix-inspector.yml b/examples/ui-demo/05-matrix-inspector.yml new file mode 100644 index 0000000..0d74d16 --- /dev/null +++ b/examples/ui-demo/05-matrix-inspector.yml @@ -0,0 +1,61 @@ +name: Matrix — Step Inspector + +# Drives the Step Inspector's Matrix sub-tab. Uses include/exclude so +# the expanded matrix is non-trivial (and so the exclude row disappears +# from the rendered list — a good manual correctness check). +# +# Also has per-step env at workflow/job/step scope so the Env sub-tab +# has three layers of inheritance to display. + +on: + push: + branches: [main] + workflow_dispatch: {} + +env: + WORKFLOW_SCOPE: "visible everywhere" + LOG_LEVEL: "info" + +jobs: + test: + name: test (${{ matrix.os }} · node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + env: + JOB_SCOPE: "visible to all steps in this job" + LOG_LEVEL: "debug" # overrides workflow-scope to test precedence + strategy: + fail-fast: false + max-parallel: 3 + matrix: + os: [ubuntu-latest, macos-latest] + node: [18, 20, 22] + include: + - os: ubuntu-latest + node: 22 + experimental: true + exclude: + - os: macos-latest + node: 18 + steps: + - uses: actions/checkout@v4 + + - name: show scope layering + env: + STEP_SCOPE: "visible only to this step" + run: | + echo "workflow: $WORKFLOW_SCOPE" + echo "job: $JOB_SCOPE" + echo "step: $STEP_SCOPE" + echo "log: $LOG_LEVEL" + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: run tests + run: | + echo "os=${{ matrix.os }} node=${{ matrix.node }}" + if [ "${{ matrix.experimental }}" = "true" ]; then + echo "experimental lane — failures are non-blocking" + fi diff --git a/examples/ui-demo/06-secrets-runtime.yml b/examples/ui-demo/06-secrets-runtime.yml new file mode 100644 index 0000000..58f7f6f --- /dev/null +++ b/examples/ui-demo/06-secrets-runtime.yml @@ -0,0 +1,67 @@ +name: Secrets & runtime + +# Exercises the Secrets tab (screen 7) — the tab itself reads the +# configured providers, not the workflow, but running this workflow +# gives the Step Inspector realistic secret-using steps to show. +# +# Also uses `services:` + `container:` so the runtime pane in the +# Secrets tab has a non-trivial shape to contrast against. + +on: + push: + branches: [main] + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + container: + image: rust:1.82-slim + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} + ports: + - 5432:5432 + redis: + image: redis:7 + ports: + - 6379:6379 + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + REDIS_URL: redis://redis:6379 + steps: + - uses: actions/checkout@v4 + + - name: authenticate with registry + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + API_KEY: ${{ secrets.API_KEY }} + run: | + echo "REGISTRY_TOKEN length: ${#REGISTRY_TOKEN}" + echo "API_KEY length: ${#API_KEY}" + + - name: pull private image + run: | + echo "pulling internal.registry/wrkflw/base:latest" + + - name: run migrations + run: | + echo "running db migrations against $DATABASE_URL" + + deploy: + runs-on: ubuntu-latest + needs: [build] + environment: production + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + steps: + - name: deploy + run: echo "ssh-ing to prod host" + + - name: notify + run: | + curl -X POST -H 'Content-type: application/json' \ + --data '{"text":"deploy ok"}' "$SLACK_WEBHOOK" diff --git a/examples/ui-demo/07-multi-event.yml b/examples/ui-demo/07-multi-event.yml new file mode 100644 index 0000000..4c42249 --- /dev/null +++ b/examples/ui-demo/07-multi-event.yml @@ -0,0 +1,39 @@ +name: Multi-event triggers + +# Exercises the trigger-matching logic the UI uses for its diff filter +# and event badges. Pair this workflow with the `d` key on the +# Workflows tab to toggle event-based filtering, and watch the status +# badge flip between "matches" / "skipped". + +on: + push: + branches: [main, "release/*"] + paths: + - "src/**" + - "Cargo.toml" + - "!**/*.md" + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + schedule: + # Every day at 03:30 UTC. + - cron: "30 3 * * *" + workflow_dispatch: + inputs: + reason: + description: "Why are you running this manually?" + required: true + type: string + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: what triggered me + run: | + echo "event=${{ github.event_name }}" + echo "ref=${{ github.ref }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "reason=${{ inputs.reason }}" + fi diff --git a/examples/ui-demo/08-failing.yml b/examples/ui-demo/08-failing.yml new file mode 100644 index 0000000..f44f731 --- /dev/null +++ b/examples/ui-demo/08-failing.yml @@ -0,0 +1,61 @@ +name: Status — mixed success / failure + +# Used to validate status rendering across the UI: job cards on the +# Workflows tab, coloured nodes on the DAG tab, and the summary badge +# on the Execution tab. +# +# `continue-on-error` on the flaky lane ensures downstream jobs still +# run so the DAG shows a mix of states in one run. + +on: + push: + branches: [main] + workflow_dispatch: {} + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - run: echo "ok" + + flaky: + runs-on: ubuntu-latest + needs: [setup] + continue-on-error: true + steps: + - name: coin flip + run: | + if [ $(( RANDOM % 2 )) -eq 0 ]; then + echo "heads" + else + echo "tails" + exit 1 + fi + + always-fails: + runs-on: ubuntu-latest + needs: [setup] + steps: + - run: | + echo "this job exists to produce a failure state" + exit 1 + + long-running: + runs-on: ubuntu-latest + needs: [setup] + steps: + - name: sleep 15 + run: | + for i in 1 2 3 4 5; do + echo "tick $i" + sleep 3 + done + + downstream: + runs-on: ubuntu-latest + # Runs regardless of upstream outcomes so the DAG visibly shows a + # green node downstream of a red one. + needs: [flaky, always-fails, long-running] + if: ${{ always() }} + steps: + - run: echo "downstream observed upstream outcomes"