feat(ui): overhaul TUI and CLI output (#85)

* feat(ui): overhaul TUI and CLI output for professional look

The terminal UI looked like it was thrown together during a
hackathon. Colors were hardcoded across 11 files with zero
consistency — Color::Green here, Color::Cyan there, emoji like 
and  mixed with Unicode symbols like ○ and ⟳, tab dividers
using a raw pipe character. This is not great.

The core problem was the complete absence of any centralized
theming. Every view file was its own little island of ad-hoc
styling decisions, and the non-TUI CLI output had *zero* color
support despite the colored crate sitting right there in the
workspace Cargo.toml, imported by absolutely nobody.

So let's fix all of that:

- Add theme.rs as single source of truth for colors, styles,
  symbols, and block/badge helpers. Every view now imports from
  here instead of hardcoding Color:: literals everywhere.
- Replace the inconsistent emoji zoo (⏭⟳) with proper
  single-cell-width Unicode symbols (✔✖⊘◉○) that don't render
  at double width and break column alignment.
- Upgrade ratatui 0.23 -> 0.28 and crossterm 0.26 -> 0.28,
  migrating all the breaking API changes (Frame generics gone,
  Table::new signature, f.size() -> f.area()).
- Add cli_style.rs and wire colored output through all CLI
  paths — validate, execute, and list commands now have proper
  colored output with tree-style rendering.
- Add braille spinner animation for running workflow states.
- Rip out the redundant instruction headers from workflows and
  logs tabs (the status bar already shows the same hints),
  reclaiming vertical space.
- Clean up the help tab from emoji section headers to properly
  styled underlined headers with fixed-width key columns.

Net result: -418 lines. The UI got *better* and *smaller*.

* fix(ui): replace fragile string-matching in status toast with typed severity

The status bar was detecting success/error toasts by doing
substring matching on the message text — checking for "success",
"Success", and the ✔ symbol. It turns out that any message
containing the word "success" anywhere (even in an error context)
would render with a green background. This is not great.

Add a StatusSeverity enum and a set_success_message() helper so
callers declare intent explicitly instead of hoping their message
text passes a regex-by-vibes check. Also replace the last 
emoji in set_status_message calls and swap the double-width 🔒
(U+1F512) LOCK symbol for single-width ⚿ (U+26BF), because the
whole point of the theme overhaul was to kill double-width emoji.

* fix(ui): align log detection with new Unicode symbols and clean up API

The previous commits replaced all user-facing emoji with single-cell
Unicode symbols, but *forgot* to update the two places that actually
match against those symbols: LogFilterLevel::matches() and
log_processor's type detection.

So we had the delightful situation where cli_style outputs ✖ on
errors, but the log filter is still looking for . The text-based
fallbacks ("Error", "WARN", etc.) masked the breakage, but the
symbol branches were effectively dead code.

While at it, rename set_status_message() to set_error_message()
because a generic "set status" method that silently defaults to
Error severity is a trap waiting to happen. Both existing call
sites are genuine error paths, so the rename is accurate.

Also run cargo fmt across the board -- the previous commits left a
fair amount of unformatted code behind.

* refactor: centralize Unicode symbols into wrkflw-logging

The previous commit introduced a proper theme module with Unicode
symbols for the TUI, but then three different places independently
hardcoded the *same* symbols: theme::symbols, cli_style.rs, and
models/mod.rs. Meanwhile, the executor, runtime, and logging crates
were still happily emitting double-width emoji into log output.

So we had the worst of both worlds: the UI layer had to pattern-
match against *both* old emoji and new Unicode to detect log levels,
and the "single source of truth" for symbols existed in triplicate.
This is not great.

Extract a shared `wrkflw_logging::symbols` module that every crate
already depends on. theme.rs now re-exports it instead of defining
its own copy. cli_style.rs and LogFilterLevel::matches() import
from it. All emoji in executor, runtime, and logging are replaced.
The old fallback in main.rs error filtering is gone because the
executor no longer emits the old symbols.

While at it, expand StatusSeverity with Info and Warning variants
so "no matches found" shows as a yellow warning toast instead of
an angry red error. Because not finding a search result is not an
error, it's just disappointing.

* fix(ui): stop hammering Docker on every render frame

The status bar was calling docker::is_available() on *every single
render tick* — that's 4 times per second, each spawning an OS
thread, a docker subprocess, and a throwaway tokio runtime. The
check has a 3-second timeout, we're calling it every 250ms. This
is not great.

It turns out that under this kind of self-inflicted load, the
check frequently times out and returns false. So Docker shows as
"Unavailable" even when it's sitting right there, perfectly happy.
The double-nested with_stderr_to_null wrapping wasn't helping
either.

While at it, the logging crate was also cheerfully eprintln!()-ing
warnings while the TUI had the terminal in raw mode, which is how
you get "Docker is not available" splattered across the middle of
your carefully rendered UI. Confusion ensues.

The fix: cache the availability result in App state, seed it from
the existing startup check, refresh it every 30 seconds in tick(),
and add a quiet_mode flag to the logging crate so it stops writing
to stderr while the TUI owns the terminal. Messages still go into
the log buffer for the Logs tab — they just don't corrupt the
display anymore.
This commit is contained in:
Gokul
2026-04-03 11:53:30 +05:30
committed by GitHub
parent 4c0f890ba7
commit f650f5533c
28 changed files with 1684 additions and 1551 deletions

204
Cargo.lock generated
View File

@@ -75,6 +75,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -354,6 +360,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.34"
@@ -449,7 +464,7 @@ version = "4.5.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6"
dependencies = [
"heck 0.5.0",
"heck",
"proc-macro2",
"quote",
"syn",
@@ -477,6 +492,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -574,31 +603,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.26.1"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -639,6 +652,40 @@ dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deadpool"
version = "0.9.5"
@@ -791,6 +838,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1041,12 +1094,11 @@ name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -1300,6 +1352,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1380,6 +1438,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -1450,6 +1521,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -1553,6 +1633,15 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "memchr"
version = "2.7.5"
@@ -1574,18 +1663,6 @@ dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.4"
@@ -1593,6 +1670,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -1995,18 +2073,22 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.23.0"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
"bitflags 2.9.2",
"cassowary",
"crossterm 0.27.0",
"indoc",
"itertools 0.11.0",
"compact_str",
"crossterm",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
@@ -2368,7 +2450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio 0.8.11",
"mio",
"signal-hook",
]
@@ -2419,6 +2501,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -2427,20 +2515,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.25.0"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck 0.4.1",
"heck",
"proc-macro2",
"quote",
"rustversion",
@@ -2607,7 +2695,7 @@ dependencies = [
"bytes",
"io-uring",
"libc",
"mio 1.0.4",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -2736,6 +2824,17 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -3536,7 +3635,8 @@ name = "wrkflw-ui"
version = "0.7.3"
dependencies = [
"chrono",
"crossterm 0.26.1",
"colored",
"crossterm",
"futures",
"ratatui",
"regex",

View File

@@ -49,8 +49,8 @@ dirs = "5.0"
thiserror = "1.0"
log = "0.4"
which = "4.4"
crossterm = "0.26.1"
ratatui = { version = "0.23.0", features = ["crossterm"] }
crossterm = "0.28"
ratatui = { version = "0.28", features = ["crossterm"] }
once_cell = "1.19.0"
itertools = "0.11.0"
indexmap = { version = "2.0.0", features = ["serde"] }

View File

@@ -175,12 +175,21 @@ async fn execute_github_workflow(
for job_result in &job_results {
if job_result.status == JobStatus::Failure {
has_failures = true;
failure_details.push_str(&format!("\n❌ Job failed: {}\n", job_result.name));
failure_details.push_str(&format!(
"\n{} Job failed: {}\n",
wrkflw_logging::symbols::FAILURE,
job_result.name
));
// Add step details for failed jobs
for step in &job_result.steps {
if step.status == StepStatus::Failure {
failure_details.push_str(&format!("{}: {}\n", step.name, step.output));
failure_details.push_str(&format!(
" {} {}: {}\n",
wrkflw_logging::symbols::FAILURE,
step.name,
step.output
));
}
}
}
@@ -306,12 +315,21 @@ async fn execute_gitlab_pipeline(
for job_result in &job_results {
if job_result.status == JobStatus::Failure {
has_failures = true;
failure_details.push_str(&format!("\n❌ Job failed: {}\n", job_result.name));
failure_details.push_str(&format!(
"\n{} Job failed: {}\n",
wrkflw_logging::symbols::FAILURE,
job_result.name
));
// Add step details for failed jobs
for step in &job_result.steps {
if step.status == StepStatus::Failure {
failure_details.push_str(&format!("{}: {}\n", step.name, step.output));
failure_details.push_str(&format!(
" {} {}: {}\n",
wrkflw_logging::symbols::FAILURE,
step.name,
step.output
));
}
}
}
@@ -1595,8 +1613,10 @@ async fn execute_job_with_matrix(
let should_run = evaluate_job_condition(if_condition, env_context, workflow);
if !should_run {
wrkflw_logging::info(&format!(
"⏭️ Skipping job '{}' due to condition: {}",
job_name, if_condition
"{} Skipping job '{}' due to condition: {}",
wrkflw_logging::symbols::SKIPPED,
job_name,
if_condition
));
// Return a skipped job result
return Ok(vec![JobResult {
@@ -2079,8 +2099,10 @@ async fn run_step_with_guards(
let should_run = evaluate_job_condition(if_cond, job_env, workflow);
if !should_run {
wrkflw_logging::info(&format!(
" ⏭️ Skipping step '{}' due to condition: {}",
step_name, if_cond
" {} Skipping step '{}' due to condition: {}",
wrkflw_logging::symbols::SKIPPED,
step_name,
if_cond
));
return Ok(StepOutcome::Skipped(StepResult {
name: step_name,
@@ -2528,7 +2550,11 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
// Only log a message to the console if we're showing action messages
if !hide_messages {
// For Emulation mode, log a message about what action would be executed
println!(" ⚙️ Would execute GitHub action: {}", uses);
println!(
" {} Would execute GitHub action: {}",
wrkflw_logging::symbols::GEAR,
uses
);
}
// Extract the actual command from the GitHub action if applicable
@@ -2694,7 +2720,8 @@ async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, Execu
// Add detailed error information for failed cargo/rust commands
if output.exit_code != 0 && (uses.contains("cargo") || uses.contains("rust")) {
let mut error_details = format!(
"\n\n Command failed with exit code: {}\n",
"\n\n{} Command failed with exit code: {}\n",
wrkflw_logging::symbols::FAILURE,
output.exit_code
);

View File

@@ -1,3 +1,5 @@
pub mod symbols;
use chrono::Local;
use once_cell::sync::Lazy;
use std::sync::{Arc, Mutex};
@@ -8,6 +10,10 @@ static LOGS: Lazy<Arc<Mutex<Vec<String>>>> = Lazy::new(|| Arc::new(Mutex::new(Ve
// Current log level
static LOG_LEVEL: Lazy<Arc<Mutex<LogLevel>>> = Lazy::new(|| Arc::new(Mutex::new(LogLevel::Info)));
// When true, log() stores messages but does not print to stdout/stderr.
// Enable this while a TUI owns the terminal to prevent display corruption.
static QUIET_MODE: Lazy<Arc<Mutex<bool>>> = Lazy::new(|| Arc::new(Mutex::new(false)));
// Log levels
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
@@ -20,10 +26,10 @@ pub enum LogLevel {
impl LogLevel {
fn prefix(&self) -> &'static str {
match self {
LogLevel::Debug => "🔍",
LogLevel::Info => "",
LogLevel::Warning => "⚠️",
LogLevel::Error => "",
LogLevel::Debug => symbols::DEBUG,
LogLevel::Info => symbols::INFO,
LogLevel::Warning => symbols::WARNING,
LogLevel::Error => symbols::FAILURE,
}
}
}
@@ -35,6 +41,15 @@ pub fn set_log_level(level: LogLevel) {
}
}
/// Suppress console output (stdout/stderr) from log calls.
/// Messages are still stored and available via `get_logs()`.
/// Call with `true` before entering TUI mode, `false` after leaving.
pub fn set_quiet_mode(quiet: bool) {
if let Ok(mut q) = QUIET_MODE.lock() {
*q = quiet;
}
}
// Get the current log level
pub fn get_log_level() -> LogLevel {
if let Ok(level) = LOG_LEVEL.lock() {
@@ -56,14 +71,15 @@ pub fn log(level: LogLevel, message: &str) {
logs.push(formatted.clone());
}
// Print to console if the message level is >= the current log level
// This ensures Debug messages only show up when the Debug level is set
if let Ok(current_level) = LOG_LEVEL.lock() {
if level >= *current_level {
// Print to stdout/stderr based on level
match level {
LogLevel::Error | LogLevel::Warning => eprintln!("{}", formatted),
_ => println!("{}", formatted),
// Print to console unless quiet mode is active (TUI owns the terminal)
let is_quiet = QUIET_MODE.lock().map(|q| *q).unwrap_or(false);
if !is_quiet {
if let Ok(current_level) = LOG_LEVEL.lock() {
if level >= *current_level {
match level {
LogLevel::Error | LogLevel::Warning => eprintln!("{}", formatted),
_ => println!("{}", formatted),
}
}
}
}
@@ -76,7 +92,11 @@ pub fn get_logs() -> Vec<String> {
} else {
// If we can't access logs, return an error message with timestamp
let timestamp = Local::now().format("%H:%M:%S").to_string();
vec![format!("[{}] ❌ Error accessing logs", timestamp)]
vec![format!(
"[{}] {} Error accessing logs",
timestamp,
symbols::FAILURE
)]
}
}

View File

@@ -0,0 +1,37 @@
// Shared Unicode symbols for consistent terminal output across all crates.
//
// These are single-cell-width Unicode characters that replace the mixed emoji
// (✅❌⏭🔒) which render at double width in many terminals.
// ── Status symbols ─────────────────────────────────────────────────
pub const SUCCESS: &str = "\u{2714}"; // ✔
pub const FAILURE: &str = "\u{2716}"; // ✖
pub const RUNNING: &str = "\u{25C9}"; // ◉
pub const SKIPPED: &str = "\u{2298}"; // ⊘
pub const NOT_STARTED: &str = "\u{25CB}"; // ○
pub const WARNING: &str = "\u{26A0}"; // ⚠
pub const INFO: &str = "\u{25CF}"; // ●
pub const DEBUG: &str = "\u{25E6}"; // ◦
pub const GEAR: &str = "\u{2699}"; // ⚙
// ── UI chrome ──────────────────────────────────────────────────────
pub const LOCK: &str = "\u{26BF}"; // ⚿
pub const BLOCKED: &str = "\u{26D4}"; // ⛔
pub const SEPARATOR: &str = "\u{2502}"; // │
pub const ARROW: &str = "\u{2192}"; // →
pub const HRULE: &str = "\u{2500}"; // ─
// ── TUI-only symbols (re-exported by theme.rs) ────────────────────
pub const SELECTED: &str = "\u{25B8} "; // ▸ (with trailing space for highlight_symbol)
pub const CHECKBOX_ON: &str = "[\u{2714}]"; // [✔]
pub const CHECKBOX_OFF: &str = "[ ]";
pub const TAB_DIVIDER: &str = " \u{2502} "; // │
// Braille spinner frames for running animation
pub const SPINNER: &[&str] = &[
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
"\u{2807}", "\u{280F}",
];

View File

@@ -237,7 +237,8 @@ impl Sandbox {
for pattern in &self.dangerous_patterns {
if pattern.is_match(command_str) {
wrkflw_logging::warning(&format!(
"🚫 Blocked dangerous command pattern: {}",
"{} Blocked dangerous command pattern: {}",
wrkflw_logging::symbols::BLOCKED,
command_str
));
return Err(SandboxError::DangerousPattern {
@@ -269,7 +270,11 @@ impl Sandbox {
// Check blocked commands
if self.config.blocked_commands.contains(command_name) {
wrkflw_logging::warning(&format!("🚫 Blocked command: {}", command_name));
wrkflw_logging::warning(&format!(
"{} Blocked command: {}",
wrkflw_logging::symbols::BLOCKED,
command_name
));
return Err(SandboxError::BlockedCommand {
command: command_name.to_string(),
});
@@ -278,7 +283,8 @@ impl Sandbox {
// In strict mode, only allow whitelisted commands
if self.config.strict_mode && !self.config.allowed_commands.contains(command_name) {
wrkflw_logging::warning(&format!(
"🚫 Command not in whitelist (strict mode): {}",
"{} Command not in whitelist (strict mode): {}",
wrkflw_logging::symbols::BLOCKED,
command_name
));
return Err(SandboxError::BlockedCommand {
@@ -287,7 +293,11 @@ impl Sandbox {
}
}
wrkflw_logging::info(&format!("✅ Command validation passed: {}", command_str));
wrkflw_logging::info(&format!(
"{} Command validation passed: {}",
wrkflw_logging::symbols::SUCCESS,
command_str
));
Ok(())
}
@@ -437,14 +447,16 @@ impl Sandbox {
match result {
Ok(output_result) => {
wrkflw_logging::info(&format!(
" Sandboxed command completed in {:.2}s",
"{} Sandboxed command completed in {:.2}s",
wrkflw_logging::symbols::SUCCESS,
execution_time.as_secs_f64()
));
output_result
}
Err(_) => {
wrkflw_logging::warning(&format!(
" Sandboxed command timed out after {:.2}s",
"{} Sandboxed command timed out after {:.2}s",
wrkflw_logging::symbols::WARNING,
timeout_duration.as_secs_f64()
));
Err(SandboxError::ExecutionTimeout {

View File

@@ -21,7 +21,10 @@ impl SecureEmulationRuntime {
let config = create_workflow_sandbox_config();
let sandbox = Sandbox::new(config).expect("Failed to create sandbox");
wrkflw_logging::info("🔒 Initialized secure emulation runtime with sandboxing");
wrkflw_logging::info(&format!(
"{} Initialized secure emulation runtime with sandboxing",
wrkflw_logging::symbols::LOCK
));
Self { sandbox }
}
@@ -32,7 +35,10 @@ impl SecureEmulationRuntime {
ContainerError::ContainerStart(format!("Failed to create sandbox: {}", e))
})?;
wrkflw_logging::info("🔒 Initialized secure emulation runtime with custom config");
wrkflw_logging::info(&format!(
"{} Initialized secure emulation runtime with custom config",
wrkflw_logging::symbols::LOCK
));
Ok(Self { sandbox })
}
@@ -58,7 +64,8 @@ impl ContainerRuntime for SecureEmulationRuntime {
}
wrkflw_logging::info(&format!(
"🔒 Executing sandboxed command: {} (image: {})",
"{} Executing sandboxed command: {} (image: {})",
wrkflw_logging::symbols::LOCK,
command.join(" "),
image
));
@@ -71,14 +78,18 @@ impl ContainerRuntime for SecureEmulationRuntime {
match result {
Ok(output) => {
wrkflw_logging::info("✅ Sandboxed command completed successfully");
wrkflw_logging::info(&format!(
"{} Sandboxed command completed successfully",
wrkflw_logging::symbols::SUCCESS
));
Ok(output)
}
Err(SandboxError::BlockedCommand { command }) => {
let error_msg = format!(
"🚫 SECURITY BLOCK: Command '{}' is not allowed in secure emulation mode. \
"{} SECURITY BLOCK: Command '{}' is not allowed in secure emulation mode. \
This command was blocked for security reasons. \
If you need to run this command, please use Docker or Podman mode instead.",
wrkflw_logging::symbols::BLOCKED,
command
);
wrkflw_logging::warning(&error_msg);
@@ -86,9 +97,10 @@ impl ContainerRuntime for SecureEmulationRuntime {
}
Err(SandboxError::DangerousPattern { pattern }) => {
let error_msg = format!(
"🚫 SECURITY BLOCK: Dangerous command pattern detected: '{}'. \
"{} SECURITY BLOCK: Dangerous command pattern detected: '{}'. \
This command was blocked because it matches a known dangerous pattern. \
Please review your workflow for potentially harmful commands.",
wrkflw_logging::symbols::BLOCKED,
pattern
);
wrkflw_logging::warning(&error_msg);
@@ -96,8 +108,9 @@ impl ContainerRuntime for SecureEmulationRuntime {
}
Err(SandboxError::ExecutionTimeout { seconds }) => {
let error_msg = format!(
" Command execution timed out after {} seconds. \
"{} Command execution timed out after {} seconds. \
Consider optimizing your command or increasing timeout limits.",
wrkflw_logging::symbols::WARNING,
seconds
);
wrkflw_logging::warning(&error_msg);
@@ -105,8 +118,9 @@ impl ContainerRuntime for SecureEmulationRuntime {
}
Err(SandboxError::PathAccessDenied { path }) => {
let error_msg = format!(
"🚫 Path access denied: '{}'. \
"{} Path access denied: '{}'. \
The sandbox restricts file system access for security.",
wrkflw_logging::symbols::BLOCKED,
path
);
wrkflw_logging::warning(&error_msg);
@@ -114,8 +128,9 @@ impl ContainerRuntime for SecureEmulationRuntime {
}
Err(SandboxError::ResourceLimitExceeded { resource }) => {
let error_msg = format!(
"📊 Resource limit exceeded: {}. \
"{} Resource limit exceeded: {}. \
Your command used too many system resources.",
wrkflw_logging::symbols::WARNING,
resource
);
wrkflw_logging::warning(&error_msg);
@@ -131,7 +146,8 @@ impl ContainerRuntime for SecureEmulationRuntime {
async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
wrkflw_logging::info(&format!(
"🔒 Secure emulation: Pretending to pull image {}",
"{} Secure emulation: Pretending to pull image {}",
wrkflw_logging::symbols::LOCK,
image
));
Ok(())
@@ -144,7 +160,8 @@ impl ContainerRuntime for SecureEmulationRuntime {
_context_dir: &Path,
) -> Result<(), ContainerError> {
wrkflw_logging::info(&format!(
"🔒 Secure emulation: Pretending to build image {} from {}",
"{} Secure emulation: Pretending to build image {} from {}",
wrkflw_logging::symbols::LOCK,
tag,
dockerfile.display()
));
@@ -200,58 +217,86 @@ pub async fn handle_special_action_secure(action: &str) -> Result<(), ContainerE
};
wrkflw_logging::info(&format!(
"🔒 Processing action in secure mode: {} @ {}",
action_name, action_version
"{} Processing action in secure mode: {} @ {}",
wrkflw_logging::symbols::LOCK,
action_name,
action_version
));
// In secure mode, we're more restrictive about what actions we allow
match action_name {
// Core GitHub actions that are generally safe
name if name.starts_with("actions/checkout") => {
wrkflw_logging::info("✅ Checkout action - workspace files are prepared securely");
wrkflw_logging::info(&format!(
"{} Checkout action - workspace files are prepared securely",
wrkflw_logging::symbols::SUCCESS
));
}
name if name.starts_with("actions/setup-node") => {
wrkflw_logging::info("🟡 Node.js setup - using system Node.js in secure mode");
wrkflw_logging::info(&format!(
"{} Node.js setup - using system Node.js in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("node", "Node.js", "https://nodejs.org/");
}
name if name.starts_with("actions/setup-python") => {
wrkflw_logging::info("🟡 Python setup - using system Python in secure mode");
wrkflw_logging::info(&format!(
"{} Python setup - using system Python in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("python", "Python", "https://www.python.org/downloads/");
}
name if name.starts_with("actions/setup-java") => {
wrkflw_logging::info("🟡 Java setup - using system Java in secure mode");
wrkflw_logging::info(&format!(
"{} Java setup - using system Java in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("java", "Java", "https://adoptium.net/");
}
name if name.starts_with("actions/cache") => {
wrkflw_logging::info("🟡 Cache action - caching disabled in secure emulation mode");
wrkflw_logging::info(&format!(
"{} Cache action - caching disabled in secure emulation mode",
wrkflw_logging::symbols::WARNING
));
}
// Rust-specific actions
name if name.starts_with("actions-rs/cargo") => {
wrkflw_logging::info("🟡 Rust cargo action - using system Rust in secure mode");
wrkflw_logging::info(&format!(
"{} Rust cargo action - using system Rust in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("cargo", "Rust/Cargo", "https://rustup.rs/");
}
name if name.starts_with("actions-rs/toolchain") => {
wrkflw_logging::info("🟡 Rust toolchain action - using system Rust in secure mode");
wrkflw_logging::info(&format!(
"{} Rust toolchain action - using system Rust in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("rustc", "Rust", "https://rustup.rs/");
}
name if name.starts_with("actions-rs/fmt") => {
wrkflw_logging::info("🟡 Rust formatter action - using system rustfmt in secure mode");
wrkflw_logging::info(&format!(
"{} Rust formatter action - using system rustfmt in secure mode",
wrkflw_logging::symbols::WARNING
));
check_command_available_secure("rustfmt", "rustfmt", "rustup component add rustfmt");
}
// Potentially dangerous actions that we warn about
name if name.contains("docker") || name.contains("container") => {
wrkflw_logging::warning(&format!(
"🚫 Docker/container action '{}' is not supported in secure emulation mode. \
"{} Docker/container action '{}' is not supported in secure emulation mode. \
Use Docker or Podman mode for container actions.",
wrkflw_logging::symbols::BLOCKED,
action_name
));
}
name if name.contains("ssh") || name.contains("deploy") => {
wrkflw_logging::warning(&format!(
"🚫 SSH/deployment action '{}' is restricted in secure emulation mode. \
"{} SSH/deployment action '{}' is restricted in secure emulation mode. \
Use Docker or Podman mode for deployment actions.",
wrkflw_logging::symbols::BLOCKED,
action_name
));
}
@@ -259,8 +304,9 @@ pub async fn handle_special_action_secure(action: &str) -> Result<(), ContainerE
// Unknown actions
_ => {
wrkflw_logging::warning(&format!(
"🟡 Unknown action '{}' in secure emulation mode. \
"{} Unknown action '{}' in secure emulation mode. \
Some functionality may be limited or unavailable.",
wrkflw_logging::symbols::WARNING,
action_name
));
}
@@ -298,7 +344,8 @@ fn check_command_available_secure(command: &str, name: &str, install_url: &str)
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
wrkflw_logging::info(&format!(
" Using system {} in secure mode: {}",
"{} Using system {} in secure mode: {}",
wrkflw_logging::symbols::SUCCESS,
name,
version.trim()
));

View File

@@ -26,6 +26,7 @@ wrkflw-github.workspace = true
# External dependencies
chrono.workspace = true
colored.workspace = true
crossterm = { workspace = true, optional = true }
ratatui = { workspace = true, optional = true }
serde.workspace = true

View File

@@ -36,6 +36,9 @@ pub async fn run_wrkflw_tui(
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Suppress logging to stdout/stderr while TUI owns the terminal
wrkflw_logging::set_quiet_mode(true);
// Set up channel for async communication
let (tx, rx): (
mpsc::Sender<ExecutionResultMsg>,
@@ -104,6 +107,7 @@ pub async fn run_wrkflw_tui(
let result = run_tui_event_loop(&mut terminal, &mut app, &tx_clone, &rx, verbose);
// Clean up terminal
wrkflw_logging::set_quiet_mode(false);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
@@ -463,7 +467,7 @@ fn run_tui_event_loop(
|| workflow.status == WorkflowStatus::Skipped;
// Now set the status message (mutable borrow)
app.set_status_message(format!(
app.set_error_message(format!(
"Cannot trigger workflow '{}' in {} state. Press Shift+R to reset.",
workflow_name,
status_text

View File

@@ -1,8 +1,8 @@
// App state for the UI
use crate::log_processor::{LogProcessingRequest, LogProcessor, ProcessedLogEntry};
use crate::models::{
ExecutionResultMsg, JobExecution, LogFilterLevel, QueuedExecution, StepExecution, Workflow,
WorkflowExecution, WorkflowStatus,
ExecutionResultMsg, JobExecution, LogFilterLevel, QueuedExecution, StatusSeverity,
StepExecution, Workflow, WorkflowExecution, WorkflowStatus,
};
use chrono::Local;
use crossterm::event::KeyCode;
@@ -24,17 +24,19 @@ pub struct App {
pub show_action_messages: bool,
pub execution_queue: Vec<QueuedExecution>, // Workflows queued for execution
pub current_execution: Option<usize>,
pub logs: Vec<String>, // Overall execution logs
pub log_scroll: usize, // Scrolling position for logs
pub job_list_state: ListState, // For viewing job details
pub detailed_view: bool, // Whether we're in detailed view mode
pub step_list_state: ListState, // For selecting steps in detailed view
pub step_table_state: TableState, // For the steps table in detailed view
pub last_tick: Instant, // For UI animations and updates
pub tick_rate: Duration, // How often to update the UI
pub tx: mpsc::Sender<ExecutionResultMsg>, // Channel for async communication
pub status_message: Option<String>, // Temporary status message to display
pub status_message_time: Option<Instant>, // When the message was set
pub logs: Vec<String>, // Overall execution logs
pub log_scroll: usize, // Scrolling position for logs
pub job_list_state: ListState, // For viewing job details
pub detailed_view: bool, // Whether we're in detailed view mode
pub step_list_state: ListState, // For selecting steps in detailed view
pub step_table_state: TableState, // For the steps table in detailed view
pub last_tick: Instant, // For UI animations and updates
pub tick_rate: Duration, // How often to update the UI
pub spinner_frame: usize, // Current spinner animation frame
pub tx: mpsc::Sender<ExecutionResultMsg>, // Channel for async communication
pub status_message: Option<String>, // Temporary status message to display
pub status_message_severity: StatusSeverity, // Severity of the current status message
pub status_message_time: Option<Instant>, // When the message was set
// Search and filter functionality
pub log_search_query: String, // Current search query for logs
@@ -56,6 +58,10 @@ pub struct App {
pub job_selection_mode: bool, // Are we viewing jobs of a workflow?
pub available_jobs: Vec<String>, // Job names from selected workflow
pub selected_job_index: usize, // Cursor in job selection list
// Cached container runtime availability (avoids re-checking every render frame)
pub runtime_available: bool,
pub last_availability_check: Instant,
}
impl App {
@@ -188,6 +194,9 @@ impl App {
RuntimeType::SecureEmulation => RuntimeType::SecureEmulation,
};
// If we're still Docker/Podman after the availability check above, it was available
let runtime_available = matches!(runtime_type, RuntimeType::Docker | RuntimeType::Podman);
App {
workflows: Vec::new(),
workflow_list_state,
@@ -208,8 +217,10 @@ impl App {
step_table_state,
last_tick: Instant::now(),
tick_rate: Duration::from_millis(250), // Update 4 times per second
spinner_frame: 0,
tx,
status_message: None,
status_message_severity: StatusSeverity::default(),
status_message_time: None,
// Search and filter functionality
@@ -230,6 +241,9 @@ impl App {
job_selection_mode: false,
available_jobs: Vec::new(),
selected_job_index: 0,
runtime_available,
last_availability_check: Instant::now(),
}
}
@@ -249,6 +263,13 @@ impl App {
RuntimeType::SecureEmulation => RuntimeType::Emulation,
RuntimeType::Emulation => RuntimeType::Docker,
};
// Re-check availability for the new runtime immediately
self.runtime_available = match self.runtime_type {
RuntimeType::Docker => wrkflw_executor::docker::is_available(),
RuntimeType::Podman => wrkflw_executor::podman::is_available(),
_ => false,
};
self.last_availability_check = Instant::now();
self.logs
.push(format!("Switched to {} mode", self.runtime_type_name()));
}
@@ -825,7 +846,7 @@ impl App {
self.log_scroll = idx;
if !self.log_search_query.is_empty() {
self.set_status_message(format!(
self.set_success_message(format!(
"Found {} matches for '{}'",
self.log_search_matches.len(),
self.log_search_query
@@ -834,7 +855,7 @@ impl App {
}
} else if !self.log_search_query.is_empty() {
// No matches found
self.set_status_message(format!("No matches found for '{}'", self.log_search_query));
self.set_warning_message(format!("No matches found for '{}'", self.log_search_query));
}
}
@@ -847,7 +868,7 @@ impl App {
self.log_scroll = idx;
// Set status message showing which match we're on
self.set_status_message(format!(
self.set_success_message(format!(
"Search match {}/{} for '{}'",
self.log_search_match_idx + 1,
self.log_search_matches.len(),
@@ -869,7 +890,7 @@ impl App {
self.log_scroll = idx;
// Set status message showing which match we're on
self.set_status_message(format!(
self.set_success_message(format!(
"Search match {}/{} for '{}'",
self.log_search_match_idx + 1,
self.log_search_matches.len(),
@@ -917,9 +938,31 @@ impl App {
}
}
// Set a temporary status message to be displayed in the UI
pub fn set_status_message(&mut self, message: String) {
// Set a temporary error status message to be displayed in the UI
pub fn set_error_message(&mut self, message: String) {
self.status_message = Some(message);
self.status_message_severity = StatusSeverity::Error;
self.status_message_time = Some(Instant::now());
}
// Set a temporary warning status message
pub fn set_warning_message(&mut self, message: String) {
self.status_message = Some(message);
self.status_message_severity = StatusSeverity::Warning;
self.status_message_time = Some(Instant::now());
}
// Set a temporary info status message
pub fn set_info_message(&mut self, message: String) {
self.status_message = Some(message);
self.status_message_severity = StatusSeverity::Info;
self.status_message_time = Some(Instant::now());
}
// Set a temporary success status message
pub fn set_success_message(&mut self, message: String) {
self.status_message = Some(message);
self.status_message_severity = StatusSeverity::Success;
self.status_message_time = Some(Instant::now());
}
@@ -937,6 +980,18 @@ impl App {
if now.duration_since(self.last_tick) >= self.tick_rate {
self.last_tick = now;
self.spinner_frame = (self.spinner_frame + 1) % crate::theme::symbols::SPINNER.len();
// Refresh container runtime availability every 30 seconds
if now.duration_since(self.last_availability_check) >= Duration::from_secs(30) {
self.last_availability_check = now;
self.runtime_available = match self.runtime_type {
RuntimeType::Docker => wrkflw_executor::docker::is_available(),
RuntimeType::Podman => wrkflw_executor::podman::is_available(),
_ => false,
};
}
true
} else {
false
@@ -1066,7 +1121,7 @@ impl App {
));
// Set a success status message
self.set_status_message(format!("Workflow '{}' has been reset!", workflow_name));
self.set_success_message(format!("Workflow '{}' has been reset!", workflow_name));
}
}
}

View File

@@ -0,0 +1,78 @@
// CLI output styling using the colored crate
// Used for non-TUI terminal output (validate, execute, list commands)
use colored::Colorize;
use wrkflw_logging::symbols;
pub fn success(text: &str) -> String {
format!("{} {}", symbols::SUCCESS.green(), text)
}
pub fn error(text: &str) -> String {
format!("{} {}", symbols::FAILURE.red(), text)
}
pub fn warning(text: &str) -> String {
format!("{} {}", symbols::WARNING.yellow(), text)
}
pub fn info(text: &str) -> String {
format!("{} {}", symbols::INFO.cyan(), text)
}
pub fn skipped(text: &str) -> String {
format!("{} {}", symbols::SKIPPED.dimmed(), text)
}
pub fn section(text: &str) -> String {
format!("\n{}", text.bold().underline())
}
pub fn separator() -> String {
format!("{}", symbols::HRULE.repeat(40).dimmed())
}
pub fn dim(text: &str) -> String {
format!("{}", text.dimmed())
}
pub fn job_success(name: &str) -> String {
format!(
"{} Job succeeded: {}",
symbols::SUCCESS.green(),
name.bold()
)
}
pub fn job_failure(name: &str) -> String {
format!("{} Job failed: {}", symbols::FAILURE.red(), name.bold())
}
pub fn job_skipped(name: &str) -> String {
format!("{} Job skipped: {}", symbols::SKIPPED.dimmed(), name.bold())
}
pub fn step_success(name: &str) -> String {
format!(" {} {}", symbols::SUCCESS.green(), name)
}
pub fn step_failure(name: &str) -> String {
format!(" {} {}", symbols::FAILURE.red(), name)
}
pub fn step_skipped(name: &str) -> String {
format!(
" {} {} {}",
symbols::SKIPPED.dimmed(),
name,
"(skipped)".dimmed()
)
}
pub fn indent(text: &str) -> String {
format!(" {}", text.dimmed())
}
pub fn key_value(key: &str, value: &str) -> String {
format!("{} {}", format!("{}:", key).cyan(), value)
}

View File

@@ -1,6 +1,7 @@
// Button component
use crate::theme::COLORS;
use ratatui::{
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
@@ -37,10 +38,10 @@ impl Button {
/// Render the button
pub fn render(&self) -> Paragraph<'_> {
let (fg, bg) = match (self.is_selected, self.is_active) {
(true, true) => (Color::Black, Color::Yellow),
(true, false) => (Color::Black, Color::DarkGray),
(false, true) => (Color::White, Color::Blue),
(false, false) => (Color::DarkGray, Color::Black),
(true, true) => (COLORS.bg_dark, COLORS.highlight),
(true, false) => (COLORS.bg_dark, COLORS.bg_bar),
(false, true) => (COLORS.text, COLORS.runtime_docker),
(false, false) => (COLORS.text_muted, COLORS.bg_dark),
};
let style = Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD);

View File

@@ -1,6 +1,7 @@
// Checkbox component
use crate::theme::{self, COLORS};
use ratatui::{
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
@@ -41,14 +42,18 @@ impl Checkbox {
/// Render the checkbox
pub fn render(&self) -> Paragraph<'_> {
let checkbox = if self.is_checked { "[✓]" } else { "[ ]" };
let checkbox = if self.is_checked {
theme::symbols::CHECKBOX_ON
} else {
theme::symbols::CHECKBOX_OFF
};
let style = if self.is_selected {
Style::default()
.fg(Color::Yellow)
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
Style::default().fg(COLORS.text)
};
Paragraph::new(Line::from(vec![

View File

@@ -1,4 +1,5 @@
// Progress bar component
use crate::theme::COLORS;
use ratatui::{
style::{Color, Style},
widgets::Gauge,
@@ -17,7 +18,7 @@ impl ProgressBar {
ProgressBar {
progress: progress.clamp(0.0, 1.0),
label: None,
color: Color::Blue,
color: COLORS.accent,
}
}
@@ -46,7 +47,7 @@ impl ProgressBar {
};
Gauge::default()
.gauge_style(Style::default().fg(self.color).bg(Color::Black))
.gauge_style(Style::default().fg(self.color).bg(COLORS.bg_dark))
.label(label)
.ratio(self.progress)
}

View File

@@ -1,4 +1,5 @@
// Workflow handlers
use crate::cli_style;
use std::io;
use std::path::{Path, PathBuf};
use wrkflw_evaluator::evaluate_workflow_file;
@@ -39,32 +40,55 @@ pub fn validate_workflow(path: &Path, verbose: bool) -> io::Result<()> {
let mut valid_count = 0;
let mut invalid_count = 0;
println!("Validating {} workflow file(s)...", workflows.len());
println!(
"{}",
cli_style::info(&format!(
"Validating {} workflow file(s)...",
workflows.len()
))
);
for workflow_path in workflows {
match evaluate_workflow_file(&workflow_path, verbose) {
Ok(result) => {
if result.is_valid {
println!("✅ Valid: {}", workflow_path.display());
println!(
"{}",
cli_style::success(&format!(
"Valid: {}",
cli_style::dim(&workflow_path.display().to_string())
))
);
valid_count += 1;
} else {
println!("❌ Invalid: {}", workflow_path.display());
println!(
"{}",
cli_style::error(&format!("Invalid: {}", workflow_path.display()))
);
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
println!("{}", cli_style::indent(&format!("{}. {}", i + 1, issue)));
}
invalid_count += 1;
}
}
Err(e) => {
println!("❌ Error processing {}: {}", workflow_path.display(), e);
println!(
"{}",
cli_style::error(&format!(
"Error processing {}: {}",
workflow_path.display(),
e
))
);
invalid_count += 1;
}
}
}
use colored::Colorize;
println!(
"\nSummary: {} valid, {} invalid",
valid_count, invalid_count
"\n{}",
format!("Summary: {} valid, {} invalid", valid_count, invalid_count).bold()
);
Ok(())
@@ -84,13 +108,19 @@ pub async fn execute_workflow_cli(
));
}
println!("Validating workflow...");
println!("{}", cli_style::info("Validating workflow..."));
match evaluate_workflow_file(path, false) {
Ok(result) => {
if !result.is_valid {
println!("❌ Cannot execute invalid workflow: {}", path.display());
println!(
"{}",
cli_style::error(&format!(
"Cannot execute invalid workflow: {}",
path.display()
))
);
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
println!("{}", cli_style::indent(&format!("{}. {}", i + 1, issue)));
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -110,7 +140,10 @@ pub async fn execute_workflow_cli(
let runtime_type = match runtime_type {
RuntimeType::Docker => {
if !wrkflw_executor::docker::is_available() {
println!("⚠️ Docker is not available. Using emulation mode instead.");
println!(
"{}",
cli_style::warning("Docker is not available. Using emulation mode instead.")
);
wrkflw_logging::warning("Docker is not available. Using emulation mode instead.");
RuntimeType::Emulation
} else {
@@ -119,7 +152,10 @@ pub async fn execute_workflow_cli(
}
RuntimeType::Podman => {
if !wrkflw_executor::podman::is_available() {
println!("⚠️ Podman is not available. Using emulation mode instead.");
println!(
"{}",
cli_style::warning("Podman is not available. Using emulation mode instead.")
);
wrkflw_logging::warning("Podman is not available. Using emulation mode instead.");
RuntimeType::Emulation
} else {
@@ -130,8 +166,14 @@ pub async fn execute_workflow_cli(
RuntimeType::Emulation => RuntimeType::Emulation,
};
println!("Executing workflow: {}", path.display());
println!("Runtime mode: {:?}", runtime_type);
println!(
"{}",
cli_style::key_value("Workflow", &path.display().to_string())
);
println!(
"{}",
cli_style::key_value("Runtime", &format!("{:?}", runtime_type))
);
// Log the start of the execution in debug mode with more details
wrkflw_logging::debug(&format!(
@@ -152,64 +194,53 @@ pub async fn execute_workflow_cli(
match wrkflw_executor::execute_workflow(path, config).await {
Ok(result) => {
println!("\nWorkflow execution results:");
println!("{}", cli_style::section("Workflow execution results"));
// Track if the workflow had any failures
let mut any_job_failed = false;
for job in &result.jobs {
match job.status {
JobStatus::Success => {
println!("\n✅ Job succeeded: {}", job.name);
}
JobStatus::Success => println!("\n{}", cli_style::job_success(&job.name)),
JobStatus::Failure => {
println!("\n❌ Job failed: {}", job.name);
println!("\n{}", cli_style::job_failure(&job.name));
any_job_failed = true;
}
JobStatus::Skipped => {
println!("\n⏭️ Job skipped: {}", job.name);
}
JobStatus::Skipped => println!("\n{}", cli_style::job_skipped(&job.name)),
}
println!("-------------------------");
println!("{}", cli_style::separator());
// Log the job details for debug purposes
wrkflw_logging::debug(&format!("Job: {}, Status: {:?}", job.name, job.status));
for step in job.steps.iter() {
match step.status {
StepStatus::Success => {
println!("{}", step.name);
println!("{}", cli_style::step_success(&step.name));
// Check if this is a GitHub action output that should be hidden
let should_hide = std::env::var("WRKFLW_HIDE_ACTION_MESSAGES")
.map(|val| val == "true")
.unwrap_or(false)
&& step.output.contains("Would execute GitHub action:");
// Only show output if not hidden and it's short
if !should_hide
&& !step.output.trim().is_empty()
&& step.output.lines().count() <= 3
{
// For short outputs, show directly
println!(" {}", step.output.trim());
println!("{}", cli_style::indent(step.output.trim()));
}
}
StepStatus::Failure => {
println!("{}", step.name);
println!("{}", cli_style::step_failure(&step.name));
// Ensure we capture and show exit code
if let Some(exit_code) = step
.output
.lines()
.find(|line| line.trim().starts_with("Exit code:"))
.map(|line| line.trim().to_string())
{
println!(" {}", exit_code);
println!("{}", cli_style::indent(&exit_code));
}
// Show command/run details in debug mode
if wrkflw_logging::get_log_level() <= wrkflw_logging::LogLevel::Debug {
if let Some(cmd_output) = step
.output
@@ -218,11 +249,16 @@ pub async fn execute_workflow_cli(
.take(1)
.next()
{
println!(" Command: {}", cmd_output.trim());
println!(
"{}",
cli_style::indent(&format!(
"Command: {}",
cmd_output.trim()
))
);
}
}
// Always show error output from failed steps, but keep it to a reasonable length
let output_lines: Vec<&str> = step
.output
.lines()
@@ -230,26 +266,31 @@ pub async fn execute_workflow_cli(
.collect();
if !output_lines.is_empty() {
println!(" Error output:");
println!("{}", cli_style::indent("Error output:"));
for line in output_lines.iter().take(10) {
println!(" {}", line.trim().replace('\n', "\n "));
println!("{}", cli_style::indent(line.trim()));
}
if output_lines.len() > 10 {
println!(
" ... (and {} more lines)",
output_lines.len() - 10
"{}",
cli_style::indent(&format!(
"... (and {} more lines)",
output_lines.len() - 10
))
);
println!(
"{}",
cli_style::indent("Use --debug to see full output")
);
println!(" Use --debug to see full output");
}
}
}
StepStatus::Skipped => {
println!(" ⏭️ {} (skipped)", step.name);
println!("{}", cli_style::step_skipped(&step.name));
}
}
// Always log the step details for debug purposes
wrkflw_logging::debug(&format!(
"Step: {}, Status: {:?}, Output length: {} lines",
step.name,
@@ -257,7 +298,6 @@ pub async fn execute_workflow_cli(
step.output.lines().count()
));
// In debug mode, log all step output
if wrkflw_logging::get_log_level() == wrkflw_logging::LogLevel::Debug
&& !step.output.trim().is_empty()
{
@@ -270,20 +310,27 @@ pub async fn execute_workflow_cli(
}
if any_job_failed {
println!("\nWorkflow completed with failures");
// In the case of failure, we'll also inform the user about the debug option
// if they're not already using it
println!("\n{}", cli_style::error("Workflow completed with failures"));
if wrkflw_logging::get_log_level() > wrkflw_logging::LogLevel::Debug {
println!(" Run with --debug for more detailed output");
println!(
"{}",
cli_style::indent("Run with --debug for more detailed output")
);
}
} else {
println!("\n✅ Workflow completed successfully!");
println!(
"\n{}",
cli_style::success("Workflow completed successfully!")
);
}
Ok(())
}
Err(e) => {
println!("❌ Failed to execute workflow: {}", e);
println!(
"{}",
cli_style::error(&format!("Failed to execute workflow: {}", e))
);
wrkflw_logging::error(&format!("Failed to execute workflow: {}", e));
Err(io::Error::other(e))
}

View File

@@ -9,6 +9,7 @@
// - views: Contains UI rendering code
// Always-available modules (CLI validation/execution)
pub mod cli_style;
pub mod handlers;
// TUI-specific modules (require ratatui/crossterm)
@@ -21,6 +22,8 @@ pub mod log_processor;
#[cfg(feature = "tui")]
pub mod models;
#[cfg(feature = "tui")]
pub mod theme;
#[cfg(feature = "tui")]
pub mod utils;
#[cfg(feature = "tui")]
pub mod views;

View File

@@ -1,7 +1,8 @@
// Background log processor for asynchronous log filtering and formatting
use crate::models::LogFilterLevel;
use crate::theme;
use ratatui::{
style::{Color, Style},
style::Style,
text::{Line, Span},
widgets::{Cell, Row},
};
@@ -22,8 +23,8 @@ impl ProcessedLogEntry {
/// Convert to a table row for rendering
pub fn to_row(&self) -> Row<'static> {
Row::new(vec![
Cell::from(self.timestamp.clone()),
Cell::from(self.log_type.clone()).style(self.log_style),
Cell::from(self.timestamp.clone()).style(theme::muted_style()),
Cell::from(format!(" {} ", self.log_type)).style(self.log_style),
Cell::from(Line::from(self.content_spans.clone())),
])
}
@@ -97,19 +98,16 @@ impl LogProcessor {
let mut cached_system_logs_count = 0;
loop {
// Check for new requests with a timeout to allow periodic processing
let request = match request_rx.recv_timeout(Duration::from_millis(100)) {
Ok(req) => Some(req),
Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
// Update request if we received one
if let Some(req) = request {
last_request = Some(req);
}
// Process if we have a request and enough time has passed since last processing
if let Some(ref req) = last_request {
let should_process = last_processed_time.elapsed() > Duration::from_millis(50)
&& (cached_app_logs_count != req.app_logs_count
@@ -117,7 +115,6 @@ impl LogProcessor {
|| cached_logs.is_empty());
if should_process {
// Refresh log cache if log counts changed
if cached_app_logs_count != req.app_logs_count
|| cached_system_logs_count != req.system_logs_count
|| cached_logs.is_empty()
@@ -130,7 +127,7 @@ impl LogProcessor {
let response = Self::process_logs(&cached_logs, req);
if response_tx.send(response).is_err() {
break; // Receiver disconnected
break;
}
last_processed_time = Instant::now();
@@ -143,12 +140,10 @@ impl LogProcessor {
fn get_combined_logs(app_logs: &[String]) -> Vec<String> {
let mut all_logs = Vec::new();
// Add app logs
for log in app_logs {
all_logs.push(log.clone());
}
// Add system logs
for log in wrkflw_logging::get_logs() {
all_logs.push(log.clone());
}
@@ -158,7 +153,6 @@ impl LogProcessor {
/// Process logs according to search and filter criteria
fn process_logs(all_logs: &[String], request: &LogProcessingRequest) -> LogProcessingResponse {
// Filter logs based on search query and filter level
let mut filtered_logs = Vec::new();
let mut search_matches = Vec::new();
@@ -183,7 +177,6 @@ impl LogProcessor {
}
}
// Process filtered logs into display format
let processed_logs: Vec<ProcessedLogEntry> = filtered_logs
.iter()
.map(|(_, log_line)| Self::process_log_entry(log_line, &request.search_query))
@@ -211,31 +204,32 @@ impl LogProcessor {
"??:??:??".to_string()
};
// Determine log type and style
let (log_type, log_style) =
if log_line.contains("Error") || log_line.contains("error") || log_line.contains("")
{
("ERROR", Style::default().fg(Color::Red))
} else if log_line.contains("Warning")
|| log_line.contains("warning")
|| log_line.contains("⚠️")
{
("WARN", Style::default().fg(Color::Yellow))
} else if log_line.contains("Success")
|| log_line.contains("success")
|| log_line.contains("")
{
("SUCCESS", Style::default().fg(Color::Green))
} else if log_line.contains("Running")
|| log_line.contains("running")
|| log_line.contains("")
{
("INFO", Style::default().fg(Color::Cyan))
} else if log_line.contains("Triggering") || log_line.contains("triggered") {
("TRIG", Style::default().fg(Color::Magenta))
} else {
("INFO", Style::default().fg(Color::Gray))
};
// Determine log type and style using theme badge styles
let (log_type, log_style) = if log_line.contains("Error")
|| log_line.contains("error")
|| log_line.contains(theme::symbols::FAILURE)
{
("ERROR", theme::log_badge("ERROR"))
} else if log_line.contains("Warning")
|| log_line.contains("warning")
|| log_line.contains(theme::symbols::WARNING)
{
("WARN", theme::log_badge("WARN"))
} else if log_line.contains("Success")
|| log_line.contains("success")
|| log_line.contains(theme::symbols::SUCCESS)
{
("SUCCESS", theme::log_badge("SUCCESS"))
} else if log_line.contains("Running")
|| log_line.contains("running")
|| log_line.contains(theme::symbols::RUNNING)
{
("INFO", theme::log_badge("INFO"))
} else if log_line.contains("Triggering") || log_line.contains("triggered") {
("TRIG", theme::log_badge("TRIG"))
} else {
("INFO", theme::log_badge(""))
};
// Extract content after timestamp
let content = if log_line.starts_with('[') && log_line.contains(']') {
@@ -275,22 +269,19 @@ impl LogProcessor {
while let Some(idx) = lowercase_content[last_idx..].find(&lowercase_query) {
let real_idx = last_idx + idx;
// Add text before match
if real_idx > last_idx {
spans.push(Span::raw(content[last_idx..real_idx].to_string()));
}
// Add matched text with highlight
let match_end = real_idx + search_query.len();
spans.push(Span::styled(
content[real_idx..match_end].to_string(),
Style::default().bg(Color::Yellow).fg(Color::Black),
theme::search_highlight(),
));
last_idx = match_end;
}
// Add remaining text after last match
if last_idx < content.len() {
spans.push(Span::raw(content[last_idx..].to_string()));
}

View File

@@ -2,6 +2,7 @@
use chrono::Local;
use std::path::PathBuf;
use wrkflw_executor::{JobStatus, StepStatus};
use wrkflw_logging::symbols;
/// Type alias for the complex execution result type
pub type ExecutionResultMsg = (usize, Result<(Vec<wrkflw_executor::JobResult>, ()), String>);
@@ -56,6 +57,16 @@ pub struct StepExecution {
pub output: String,
}
/// Severity level for status bar toast messages
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum StatusSeverity {
Success,
Info,
Warning,
#[default]
Error,
}
/// Log filter levels
#[derive(Debug, Clone, PartialEq)]
pub enum LogFilterLevel {
@@ -71,11 +82,13 @@ impl LogFilterLevel {
pub fn matches(&self, log: &str) -> bool {
match self {
LogFilterLevel::Info => {
log.contains("") || (log.contains("INFO") && !log.contains("SUCCESS"))
log.contains(symbols::INFO) || (log.contains("INFO") && !log.contains("SUCCESS"))
}
LogFilterLevel::Warning => log.contains(symbols::WARNING) || log.contains("WARN"),
LogFilterLevel::Error => log.contains(symbols::FAILURE) || log.contains("ERROR"),
LogFilterLevel::Success => {
log.contains(symbols::SUCCESS) || log.contains("SUCCESS") || log.contains("success")
}
LogFilterLevel::Warning => log.contains("⚠️") || log.contains("WARN"),
LogFilterLevel::Error => log.contains("") || log.contains("ERROR"),
LogFilterLevel::Success => log.contains("SUCCESS") || log.contains("success"),
LogFilterLevel::Trigger => {
log.contains("Triggering") || log.contains("triggered") || log.contains("TRIG")
}

230
crates/ui/src/theme.rs Normal file
View File

@@ -0,0 +1,230 @@
// Centralized theme for wrkflw TUI
//
// All colors, styles, and symbols are defined here.
// View files import from this module instead of hardcoding.
use ratatui::{
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, BorderType, Borders},
};
// ── Color Palette ──────────────────────────────────────────────────
pub struct Colors {
// Brand / accent
pub accent: Color,
pub highlight: Color,
// Semantic status
pub success: Color,
pub error: Color,
pub warning: Color,
pub info: Color,
pub trigger: Color,
// Text hierarchy
pub text: Color,
pub text_dim: Color,
pub text_muted: Color,
// Borders
pub border: Color,
pub border_focused: Color,
// Backgrounds
pub bg_selected: Color,
pub bg_bar: Color,
pub bg_dark: Color,
// Runtime badges
pub runtime_docker: Color,
pub runtime_podman: Color,
pub runtime_emulation: Color,
pub runtime_secure: Color,
}
pub const COLORS: Colors = Colors {
accent: Color::Cyan,
highlight: Color::Yellow,
success: Color::Green,
error: Color::Red,
warning: Color::Yellow,
info: Color::Cyan,
trigger: Color::Magenta,
text: Color::White,
text_dim: Color::Gray,
text_muted: Color::DarkGray,
border: Color::DarkGray,
border_focused: Color::Cyan,
bg_selected: Color::Rgb(40, 44, 52),
bg_bar: Color::DarkGray,
bg_dark: Color::Black,
runtime_docker: Color::Blue,
runtime_podman: Color::Cyan,
runtime_emulation: Color::Red,
runtime_secure: Color::Green,
};
// ── Symbols ────────────────────────────────────────────────────────
/// Re-export the shared symbol constants from `wrkflw_logging::symbols`.
/// All crates use a single source of truth for Unicode symbols.
pub use wrkflw_logging::symbols;
// ── Style Helpers ──────────────────────────────────────────────────
/// Style for section/block titles
pub fn title_style() -> Style {
Style::default()
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD)
}
/// Style for the wrkflw brand title
pub fn brand_style() -> Style {
Style::default()
.fg(COLORS.accent)
.add_modifier(Modifier::BOLD)
}
/// Style for field labels ("Workflow:", "Status:", etc.)
pub fn label_style() -> Style {
Style::default().fg(COLORS.accent)
}
/// Style for selected/highlighted rows
pub fn selected_style() -> Style {
Style::default()
.bg(COLORS.bg_selected)
.add_modifier(Modifier::BOLD)
}
/// Style for table/column headers
pub fn header_style() -> Style {
Style::default()
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
/// Style for search match highlighting
pub fn search_highlight() -> Style {
Style::default()
.bg(COLORS.highlight)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
}
/// Style for dimmed/secondary text
pub fn dim_style() -> Style {
Style::default().fg(COLORS.text_dim)
}
/// Style for muted text (paths, timestamps)
pub fn muted_style() -> Style {
Style::default().fg(COLORS.text_muted)
}
/// Style for key hints in status bar ("[Enter]", "[Space]")
pub fn key_style() -> Style {
Style::default().fg(COLORS.highlight)
}
/// Style for hint descriptions in status bar
pub fn hint_style() -> Style {
Style::default().fg(COLORS.text_dim)
}
// ── Status Styles ──────────────────────────────────────────────────
use crate::models::WorkflowStatus;
use wrkflw_executor::{JobStatus, StepStatus};
/// Get symbol and style for a WorkflowStatus
pub fn workflow_status(status: &WorkflowStatus) -> (&'static str, Style) {
match status {
WorkflowStatus::NotStarted => (symbols::NOT_STARTED, Style::default().fg(COLORS.text_dim)),
WorkflowStatus::Running => (symbols::RUNNING, Style::default().fg(COLORS.info)),
WorkflowStatus::Success => (symbols::SUCCESS, Style::default().fg(COLORS.success)),
WorkflowStatus::Failed => (symbols::FAILURE, Style::default().fg(COLORS.error)),
WorkflowStatus::Skipped => (symbols::SKIPPED, Style::default().fg(COLORS.warning)),
}
}
/// Get animated spinner symbol for running state
pub fn spinner(frame: usize) -> &'static str {
symbols::SPINNER[frame % symbols::SPINNER.len()]
}
/// Get symbol and style for a WorkflowStatus with spinner animation
pub fn workflow_status_animated(
status: &WorkflowStatus,
spinner_frame: usize,
) -> (&'static str, Style) {
match status {
WorkflowStatus::Running => (spinner(spinner_frame), Style::default().fg(COLORS.info)),
other => workflow_status(other),
}
}
/// Get symbol and style for a JobStatus
pub fn job_status(status: &JobStatus) -> (&'static str, Style) {
match status {
JobStatus::Success => (symbols::SUCCESS, Style::default().fg(COLORS.success)),
JobStatus::Failure => (symbols::FAILURE, Style::default().fg(COLORS.error)),
JobStatus::Skipped => (symbols::SKIPPED, Style::default().fg(COLORS.text_dim)),
}
}
/// Get symbol and style for a StepStatus
pub fn step_status(status: &StepStatus) -> (&'static str, Style) {
match status {
StepStatus::Success => (symbols::SUCCESS, Style::default().fg(COLORS.success)),
StepStatus::Failure => (symbols::FAILURE, Style::default().fg(COLORS.error)),
StepStatus::Skipped => (symbols::SKIPPED, Style::default().fg(COLORS.text_dim)),
}
}
// ── Block Helpers ──────────────────────────────────────────────────
/// Create a styled block with rounded borders and a title
pub fn block<'a>(title: &'a str) -> Block<'a> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLORS.border))
.title(Span::styled(format!(" {} ", title), title_style()))
}
/// Create a styled block with focused (accent) border
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))
.title(Span::styled(format!(" {} ", title), title_style()))
}
// ── Badge Helpers ──────────────────────────────────────────────────
/// Create a styled badge span (text with colored background)
pub fn badge<'a>(text: &'a str, bg: Color, fg: Color) -> Span<'a> {
Span::styled(format!(" {} ", text), Style::default().bg(bg).fg(fg))
}
/// Log level badge styles
pub fn log_badge(level: &str) -> Style {
match level {
"ERROR" => Style::default().bg(COLORS.error).fg(COLORS.text),
"WARN" => Style::default().bg(COLORS.warning).fg(Color::Black),
"SUCCESS" => Style::default().fg(COLORS.success),
"INFO" => Style::default().fg(COLORS.info),
"TRIG" => Style::default().fg(COLORS.trigger),
_ => Style::default().fg(COLORS.text_dim),
}
}

View File

@@ -1,23 +1,17 @@
// Execution tab rendering
use crate::app::App;
use crate::models::WorkflowStatus;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph},
widgets::{Block, Gauge, List, ListItem, Paragraph},
Frame,
};
use std::io;
// Render the execution tab
pub fn render_execution_tab(
f: &mut Frame<CrosstermBackend<io::Stdout>>,
app: &mut App,
area: Rect,
) {
// Get the workflow index either from current_execution or selected workflow
pub fn render_execution_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) {
let current_workflow_idx = app
.current_execution
.or_else(|| app.workflow_list_state.selected())
@@ -26,13 +20,12 @@ pub fn render_execution_tab(
if let Some(idx) = current_workflow_idx {
let workflow = &app.workflows[idx];
// Split the area into sections
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(5), // Workflow info with progress bar
Constraint::Min(5), // Jobs list or Remote execution info
Constraint::Min(5), // Jobs list
Constraint::Length(7), // Execution info
]
.as_ref(),
@@ -41,49 +34,38 @@ pub fn render_execution_tab(
.split(area);
// Workflow info section
let status_text = match workflow.status {
WorkflowStatus::NotStarted => "Not Started",
WorkflowStatus::Running => "Running",
WorkflowStatus::Success => "Success",
WorkflowStatus::Failed => "Failed",
WorkflowStatus::Skipped => "Skipped",
};
let status_style = match workflow.status {
WorkflowStatus::NotStarted => Style::default().fg(Color::Gray),
WorkflowStatus::Running => Style::default().fg(Color::Cyan),
WorkflowStatus::Success => Style::default().fg(Color::Green),
WorkflowStatus::Failed => Style::default().fg(Color::Red),
WorkflowStatus::Skipped => Style::default().fg(Color::Yellow),
let (status_text, status_style) = match workflow.status {
WorkflowStatus::NotStarted => ("Not Started", Style::default().fg(COLORS.text_dim)),
WorkflowStatus::Running => ("Running", Style::default().fg(COLORS.info)),
WorkflowStatus::Success => ("Success", Style::default().fg(COLORS.success)),
WorkflowStatus::Failed => ("Failed", Style::default().fg(COLORS.error)),
WorkflowStatus::Skipped => ("Skipped", Style::default().fg(COLORS.warning)),
};
let mut workflow_info = vec![
Line::from(vec![
Span::styled("Workflow: ", Style::default().fg(Color::Blue)),
Span::styled("Workflow: ", theme::label_style()),
Span::styled(
workflow.name.clone(),
Style::default()
.fg(Color::White)
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::Blue)),
Span::styled("Status: ", theme::label_style()),
Span::styled(status_text, status_style),
]),
];
// Add progress bar for running workflows or workflows with execution details
if let Some(execution) = &workflow.execution_details {
// Calculate progress
let progress = execution.progress;
// Add progress bar
let gauge_color = match workflow.status {
WorkflowStatus::Running => Color::Cyan,
WorkflowStatus::Success => Color::Green,
WorkflowStatus::Failed => Color::Red,
_ => Color::Gray,
WorkflowStatus::Running => COLORS.info,
WorkflowStatus::Success => COLORS.success,
WorkflowStatus::Failed => COLORS.error,
_ => COLORS.text_dim,
};
let progress_text = match workflow.status {
@@ -93,35 +75,24 @@ pub fn render_execution_tab(
_ => "Not started".to_string(),
};
// Add empty line before progress bar
workflow_info.push(Line::from(""));
// Add the gauge widget to the paragraph data
workflow_info.push(Line::from(vec![Span::styled(
format!("Progress: {}", progress_text),
Style::default().fg(Color::Blue),
)]));
workflow_info.push(Line::from(vec![
Span::styled("Progress: ", theme::label_style()),
Span::styled(progress_text, Style::default().fg(COLORS.text_dim)),
]));
let gauge = Gauge::default()
.block(Block::default())
.gauge_style(Style::default().fg(gauge_color).bg(Color::Black))
.gauge_style(Style::default().fg(gauge_color).bg(COLORS.bg_dark))
.percent((progress * 100.0) as u16);
// Render gauge separately after the paragraph
let workflow_info_widget = Paragraph::new(workflow_info).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Workflow Information ",
Style::default().fg(Color::Yellow),
)),
);
let workflow_info_widget =
Paragraph::new(workflow_info).block(theme::block("Workflow Information"));
let gauge_area = Rect {
x: chunks[0].x + 2,
y: chunks[0].y + 4,
width: chunks[0].width - 4,
width: chunks[0].width.saturating_sub(4),
height: 1,
};
@@ -131,12 +102,7 @@ pub fn render_execution_tab(
// Jobs list section
if execution.jobs.is_empty() {
let placeholder = Paragraph::new("No jobs have started execution yet...")
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Jobs ", Style::default().fg(Color::Yellow))),
)
.block(theme::block("Jobs"))
.alignment(Alignment::Center);
f.render_widget(placeholder, chunks[1]);
} else {
@@ -144,21 +110,8 @@ pub fn render_execution_tab(
.jobs
.iter()
.map(|job| {
let status_symbol = match job.status {
wrkflw_executor::JobStatus::Success => "",
wrkflw_executor::JobStatus::Failure => "",
wrkflw_executor::JobStatus::Skipped => "",
};
let (status_symbol, status_style) = theme::job_status(&job.status);
let status_style = match job.status {
wrkflw_executor::JobStatus::Success => {
Style::default().fg(Color::Green)
}
wrkflw_executor::JobStatus::Failure => Style::default().fg(Color::Red),
wrkflw_executor::JobStatus::Skipped => Style::default().fg(Color::Gray),
};
// Count completed and total steps
let total_steps = job.steps.len();
let completed_steps = job
.steps
@@ -174,26 +127,22 @@ pub fn render_execution_tab(
ListItem::new(Line::from(vec![
Span::styled(status_symbol, status_style),
Span::raw(" "),
Span::styled(&job.name, Style::default().fg(Color::White)),
Span::styled(
&job.name,
Style::default()
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(steps_info, Style::default().fg(Color::DarkGray)),
Span::styled(steps_info, theme::muted_style()),
]))
})
.collect();
let jobs_list = List::new(job_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Jobs ", Style::default().fg(Color::Yellow))),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("» ");
.block(theme::block("Jobs"))
.highlight_style(theme::selected_style())
.highlight_symbol(theme::symbols::SELECTED);
f.render_stateful_widget(jobs_list, chunks[1], &mut app.job_list_state);
}
@@ -202,158 +151,135 @@ pub fn render_execution_tab(
let mut execution_info = Vec::new();
execution_info.push(Line::from(vec![
Span::styled("Started: ", Style::default().fg(Color::Blue)),
Span::styled("Started: ", theme::label_style()),
Span::styled(
execution.start_time.format("%Y-%m-%d %H:%M:%S").to_string(),
Style::default().fg(Color::White),
Style::default().fg(COLORS.text),
),
]));
if let Some(end_time) = execution.end_time {
execution_info.push(Line::from(vec![
Span::styled("Finished: ", Style::default().fg(Color::Blue)),
Span::styled("Finished: ", theme::label_style()),
Span::styled(
end_time.format("%Y-%m-%d %H:%M:%S").to_string(),
Style::default().fg(Color::White),
Style::default().fg(COLORS.text),
),
]));
// Calculate duration
let duration = end_time.signed_duration_since(execution.start_time);
execution_info.push(Line::from(vec![
Span::styled("Duration: ", Style::default().fg(Color::Blue)),
Span::styled("Duration: ", theme::label_style()),
Span::styled(
format!(
"{}m {}s",
duration.num_minutes(),
duration.num_seconds() % 60
),
Style::default().fg(Color::White),
Style::default().fg(COLORS.text),
),
]));
} else {
// Show running time for active workflows
let current_time = chrono::Local::now();
let running_time = current_time.signed_duration_since(execution.start_time);
execution_info.push(Line::from(vec![
Span::styled("Running for: ", Style::default().fg(Color::Blue)),
Span::styled("Running for: ", theme::label_style()),
Span::styled(
format!(
"{}m {}s",
running_time.num_minutes(),
running_time.num_seconds() % 60
),
Style::default().fg(Color::White),
Style::default().fg(COLORS.text),
),
]));
}
// Add hint for Enter key to see details
execution_info.push(Line::from(""));
execution_info.push(Line::from(vec![
Span::styled("Press ", Style::default().fg(Color::DarkGray)),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::styled(" to view job details", Style::default().fg(Color::DarkGray)),
Span::styled("Press ", theme::muted_style()),
Span::styled("Enter", theme::key_style()),
Span::styled(" to view job details", theme::muted_style()),
]));
let info_widget = Paragraph::new(execution_info).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Execution Information ",
Style::default().fg(Color::Yellow),
)),
);
let info_widget =
Paragraph::new(execution_info).block(theme::block("Execution Information"));
f.render_widget(info_widget, chunks[2]);
} else {
// No workflow execution to display
let workflow_info_widget = Paragraph::new(workflow_info).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Workflow Information ",
Style::default().fg(Color::Yellow),
)),
);
// No execution details
let workflow_info_widget =
Paragraph::new(workflow_info).block(theme::block("Workflow Information"));
f.render_widget(workflow_info_widget, chunks[0]);
// No execution details to display
let placeholder = Paragraph::new(vec![
Line::from(""),
Line::from(vec![Span::styled(
Line::from(Span::styled(
"No execution data available.",
Style::default()
.fg(Color::Yellow)
.fg(COLORS.warning)
.add_modifier(Modifier::BOLD),
)]),
)),
Line::from(""),
Line::from("Press 'Enter' to run this workflow."),
Line::from(vec![
Span::styled("Press ", theme::muted_style()),
Span::styled("Enter", theme::key_style()),
Span::styled(" to run this workflow.", theme::muted_style()),
]),
Line::from(""),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Jobs ", Style::default().fg(Color::Yellow))),
)
.block(theme::block("Jobs"))
.alignment(Alignment::Center);
f.render_widget(placeholder, chunks[1]);
// Execution information
let info_widget = Paragraph::new(vec![
Line::from(""),
Line::from(vec![Span::styled(
Line::from(Span::styled(
"No execution has been started.",
Style::default().fg(Color::Yellow),
)]),
Style::default().fg(COLORS.warning),
)),
Line::from(""),
Line::from("Press 'Enter' in the Workflows tab to run,"),
Line::from("or 't' to trigger on GitHub."),
Line::from(vec![
Span::styled("Press ", theme::muted_style()),
Span::styled("Enter", theme::key_style()),
Span::styled(" in the Workflows tab to run, or ", theme::muted_style()),
Span::styled("t", theme::key_style()),
Span::styled(" to trigger on GitHub.", theme::muted_style()),
]),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Execution Information ",
Style::default().fg(Color::Yellow),
)),
)
.block(theme::block("Execution Information"))
.alignment(Alignment::Center);
f.render_widget(info_widget, chunks[2]);
}
} else {
// No workflow execution to display
// No workflow selected
let placeholder = Paragraph::new(vec![
Line::from(""),
Line::from(vec![Span::styled(
Line::from(Span::styled(
"No workflow execution data available.",
Style::default()
.fg(Color::Yellow)
.fg(COLORS.warning)
.add_modifier(Modifier::BOLD),
)]),
)),
Line::from(""),
Line::from("Select workflows in the Workflows tab and press 'r' to run them."),
Line::from(vec![
Span::styled("Select workflows in the ", theme::muted_style()),
Span::styled("Workflows", Style::default().fg(COLORS.accent)),
Span::styled(" tab and press ", theme::muted_style()),
Span::styled("r", theme::key_style()),
Span::styled(" to run them.", theme::muted_style()),
]),
Line::from(""),
Line::from("Or press Enter on a selected workflow to run it directly."),
Line::from(""),
Line::from("You can also press 't' to trigger a workflow on GitHub remotely."),
Line::from(vec![
Span::styled("Or press ", theme::muted_style()),
Span::styled("t", theme::key_style()),
Span::styled(" to trigger a workflow on GitHub.", theme::muted_style()),
]),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Execution ",
Style::default().fg(Color::Yellow),
)),
)
.block(theme::block("Execution"))
.alignment(Alignment::Center);
f.render_widget(placeholder, area);

View File

@@ -1,452 +1,250 @@
// Help overlay rendering
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use std::io;
fn section_header<'a>(title: &'a str) -> Vec<Line<'a>> {
vec![
Line::from(Span::styled(
title,
Style::default()
.fg(COLORS.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)),
Line::from(Span::styled(
theme::symbols::HRULE.repeat(title.len()),
Style::default().fg(COLORS.text_muted),
)),
]
}
fn key_line<'a>(key: &'a str, desc: &'a str) -> Line<'a> {
Line::from(vec![
Span::styled(
format!(" {:16}", key),
Style::default()
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD),
),
Span::styled(desc, Style::default().fg(COLORS.text_dim)),
])
}
// Render the help tab with scroll support
pub fn render_help_content(
f: &mut Frame<CrosstermBackend<io::Stdout>>,
area: Rect,
scroll_offset: usize,
) {
// Split the area into columns for better organization
pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(area);
// Left column content
let left_help_text = vec![
Line::from(Span::styled(
"🗂 NAVIGATION",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"Tab / Shift+Tab",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Switch between tabs"),
]),
Line::from(vec![
Span::styled(
"1-4 / w,x,l,h",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Jump to specific tab"),
]),
Line::from(vec![
Span::styled(
"↑/↓ or k/j",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Navigate lists"),
]),
Line::from(vec![
Span::styled(
"Enter",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Select/View details"),
]),
Line::from(vec![
Span::styled(
"Esc",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Back/Exit help"),
]),
Line::from(""),
Line::from(Span::styled(
"🚀 WORKFLOW MANAGEMENT",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"Space",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle workflow selection"),
]),
Line::from(vec![
Span::styled(
"r",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Run selected workflows"),
]),
Line::from(vec![
Span::styled(
"a",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Select all workflows"),
]),
Line::from(vec![
Span::styled(
"n",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Deselect all workflows"),
]),
Line::from(vec![
Span::styled(
"Shift+R",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Reset workflow status"),
]),
Line::from(vec![
Span::styled(
"t",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Trigger remote workflow"),
]),
Line::from(""),
Line::from(Span::styled(
"🎯 JOB SELECTION",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"Shift+J",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - View jobs in workflow"),
]),
Line::from(vec![
Span::styled(
"Enter (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Run selected job"),
]),
Line::from(vec![
Span::styled(
"a (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Run all jobs"),
]),
Line::from(vec![
Span::styled(
"Esc (in jobs)",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Back to workflow list"),
]),
Line::from(""),
Line::from(Span::styled(
"🔧 EXECUTION MODES",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"e",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle emulation mode"),
]),
Line::from(vec![
Span::styled(
"v",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle validation mode"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Runtime Modes:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::raw(""),
Span::styled("Docker", Style::default().fg(Color::Blue)),
Span::raw(" - Container isolation (default)"),
]),
Line::from(vec![
Span::raw(""),
Span::styled("Podman", Style::default().fg(Color::Blue)),
Span::raw(" - Rootless containers"),
]),
Line::from(vec![
Span::raw(""),
Span::styled("Emulation", Style::default().fg(Color::Red)),
Span::raw(" - Process mode (UNSAFE)"),
]),
Line::from(vec![
Span::raw(""),
Span::styled("Secure Emulation", Style::default().fg(Color::Yellow)),
Span::raw(" - Sandboxed processes"),
]),
];
// Left column
let mut left_lines: Vec<Line> = Vec::new();
// Right column content
let right_help_text = vec![
Line::from(Span::styled(
"📄 LOGS & SEARCH",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"s",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle log search"),
]),
Line::from(vec![
Span::styled(
"f",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle log filter"),
]),
Line::from(vec![
Span::styled(
"c",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Clear search & filter"),
]),
Line::from(vec![
Span::styled(
"n",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Next search match"),
]),
Line::from(vec![
Span::styled(
"↑/↓",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Scroll logs/Navigate"),
]),
Line::from(""),
Line::from(Span::styled(
" TAB OVERVIEW",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"1. Workflows",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Browse & select workflows"),
]),
Line::from(vec![Span::raw(" • View workflow files")]),
Line::from(vec![Span::raw(" • Select multiple for batch execution")]),
Line::from(vec![Span::raw(" • Trigger remote workflows")]),
Line::from(""),
Line::from(vec![
Span::styled(
"2. Execution",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Monitor job progress"),
]),
Line::from(vec![Span::raw(" • View job status and details")]),
Line::from(vec![Span::raw(" • Enter job details with Enter")]),
Line::from(vec![Span::raw(" • Navigate step execution")]),
Line::from(""),
Line::from(vec![
Span::styled(
"3. Logs",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - View execution logs"),
]),
Line::from(vec![Span::raw(" • Search and filter logs")]),
Line::from(vec![Span::raw(" • Real-time log streaming")]),
Line::from(vec![Span::raw(" • Navigate search results")]),
Line::from(""),
Line::from(vec![
Span::styled(
"4. Help",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - This comprehensive guide"),
]),
Line::from(""),
Line::from(Span::styled(
"🎯 QUICK ACTIONS",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
"?",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Toggle help overlay"),
]),
Line::from(vec![
Span::styled(
"q",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" - Quit application"),
]),
Line::from(""),
Line::from(Span::styled(
"💡 TIPS",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::raw("• Use "),
Span::styled("emulation mode", Style::default().fg(Color::Red)),
Span::raw(" when containers"),
]),
Line::from(vec![Span::raw(" are unavailable or for quick testing")]),
Line::from(""),
Line::from(vec![
Span::raw(""),
Span::styled("Secure emulation", Style::default().fg(Color::Yellow)),
Span::raw(" provides sandboxing"),
]),
Line::from(vec![Span::raw(" for untrusted workflows")]),
Line::from(""),
Line::from(vec![
Span::raw("• Use "),
Span::styled("validation mode", Style::default().fg(Color::Green)),
Span::raw(" to check"),
]),
Line::from(vec![Span::raw(" workflows without execution")]),
Line::from(""),
Line::from(vec![
Span::raw(""),
Span::styled("Preserve containers", Style::default().fg(Color::Blue)),
Span::raw(" on failure"),
]),
Line::from(vec![Span::raw(" for debugging (Docker/Podman only)")]),
];
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("\u{2191}/\u{2193} or k/j", "Navigate lists"));
left_lines.push(key_line("Enter", "Select / View details"));
left_lines.push(key_line("Esc", "Back / Exit help"));
left_lines.push(Line::from(""));
// Apply scroll offset to the content
let left_help_text = if scroll_offset < left_help_text.len() {
left_help_text.into_iter().skip(scroll_offset).collect()
left_lines.extend(section_header("WORKFLOW MANAGEMENT"));
left_lines.push(Line::from(""));
left_lines.push(key_line("Space", "Toggle workflow selection"));
left_lines.push(key_line("r", "Run selected workflows"));
left_lines.push(key_line("a", "Select all workflows"));
left_lines.push(key_line("n", "Deselect all workflows"));
left_lines.push(key_line("Shift+R", "Reset workflow status"));
left_lines.push(key_line("t", "Trigger remote workflow"));
left_lines.push(Line::from(""));
left_lines.extend(section_header("JOB SELECTION"));
left_lines.push(Line::from(""));
left_lines.push(key_line("Shift+J", "View jobs in workflow"));
left_lines.push(key_line("Enter (in jobs)", "Run selected job"));
left_lines.push(key_line("a (in jobs)", "Run all jobs"));
left_lines.push(key_line("Esc (in jobs)", "Back to workflow list"));
left_lines.push(Line::from(""));
left_lines.extend(section_header("EXECUTION MODES"));
left_lines.push(Line::from(""));
left_lines.push(key_line("e", "Toggle emulation mode"));
left_lines.push(key_line("v", "Toggle validation mode"));
left_lines.push(Line::from(""));
left_lines.push(Line::from(Span::styled(
"Runtime Modes:",
Style::default()
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
)));
left_lines.push(Line::from(vec![
Span::raw(" \u{2022} "),
Span::styled("Docker", Style::default().fg(COLORS.runtime_docker)),
Span::styled(
" \u{2500} Container isolation (default)",
theme::dim_style(),
),
]));
left_lines.push(Line::from(vec![
Span::raw(" \u{2022} "),
Span::styled("Podman", Style::default().fg(COLORS.runtime_podman)),
Span::styled(" \u{2500} Rootless containers", theme::dim_style()),
]));
left_lines.push(Line::from(vec![
Span::raw(" \u{2022} "),
Span::styled("Emulation", Style::default().fg(COLORS.runtime_emulation)),
Span::styled(" \u{2500} Process mode (UNSAFE)", theme::dim_style()),
]));
left_lines.push(Line::from(vec![
Span::raw(" \u{2022} "),
Span::styled(
"Secure Emulation",
Style::default().fg(COLORS.runtime_secure),
),
Span::styled(" \u{2500} Sandboxed processes", theme::dim_style()),
]));
// Right column
let mut right_lines: Vec<Line> = Vec::new();
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("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("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),
),
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),
),
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()),
]));
right_lines.push(Line::from(""));
right_lines.extend(section_header("QUICK ACTIONS"));
right_lines.push(Line::from(""));
right_lines.push(key_line("?", "Toggle help overlay"));
right_lines.push(key_line("q", "Quit application"));
right_lines.push(Line::from(""));
right_lines.extend(section_header("TIPS"));
right_lines.push(Line::from(""));
right_lines.push(Line::from(vec![
Span::raw("\u{2022} Use "),
Span::styled(
"emulation mode",
Style::default().fg(COLORS.runtime_emulation),
),
Span::styled(" when containers are unavailable", theme::dim_style()),
]));
right_lines.push(Line::from(vec![
Span::raw("\u{2022} "),
Span::styled(
"Secure emulation",
Style::default().fg(COLORS.runtime_secure),
),
Span::styled(
" provides sandboxing for untrusted workflows",
theme::dim_style(),
),
]));
right_lines.push(Line::from(vec![
Span::raw("\u{2022} Use "),
Span::styled("validation mode", Style::default().fg(COLORS.success)),
Span::styled(" to check workflows without execution", theme::dim_style()),
]));
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(
"\u{2191}\u{2193} scroll \u{2502} ? close",
theme::muted_style(),
)));
// Apply scroll offset
let left_lines: Vec<Line> = if scroll_offset < left_lines.len() {
left_lines.into_iter().skip(scroll_offset).collect()
} else {
vec![Line::from("")]
};
let right_help_text = if scroll_offset < right_help_text.len() {
right_help_text.into_iter().skip(scroll_offset).collect()
let right_lines: Vec<Line> = if scroll_offset < right_lines.len() {
right_lines.into_iter().skip(scroll_offset).collect()
} else {
vec![Line::from("")]
};
// Render left column
let left_widget = Paragraph::new(left_help_text)
let left_widget = Paragraph::new(left_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" WRKFLW Help - Controls & Features ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
.border_style(Style::default().fg(COLORS.border))
.title(Span::styled(" Controls & Features ", theme::title_style())),
)
.wrap(Wrap { trim: true });
// Render right column
let right_widget = Paragraph::new(right_help_text)
let right_widget = Paragraph::new(right_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Interface Guide & Tips ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
.border_style(Style::default().fg(COLORS.border))
.title(Span::styled(" Interface Guide ", theme::title_style())),
)
.wrap(Wrap { trim: true });
@@ -455,12 +253,11 @@ pub fn render_help_content(
}
// Render a help overlay
pub fn render_help_overlay(f: &mut Frame<CrosstermBackend<io::Stdout>>, scroll_offset: usize) {
let size = f.size();
pub fn render_help_overlay(f: &mut Frame<'_>, scroll_offset: usize) {
let size = f.area();
// Create a larger centered modal to accommodate comprehensive help content
let width = (size.width * 9 / 10).min(120); // Use 90% of width, max 120 chars
let height = (size.height * 9 / 10).min(40); // Use 90% of height, max 40 lines
let width = (size.width * 9 / 10).min(120);
let height = (size.height * 9 / 10).min(40);
let x = (size.width - width) / 2;
let y = (size.height - height) / 2;
@@ -471,25 +268,22 @@ pub fn render_help_overlay(f: &mut Frame<CrosstermBackend<io::Stdout>>, scroll_o
height,
};
// Create a semi-transparent dark background for better visibility
// Dark background
let clear = Block::default().style(Style::default().bg(Color::Black));
f.render_widget(clear, size);
// Add a border around the entire overlay for better visual separation
// Overlay border
let overlay_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.style(Style::default().bg(Color::Black).fg(Color::White))
.style(Style::default().bg(Color::Black).fg(COLORS.border_focused))
.title(Span::styled(
" Press ? or Esc to close help ",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
" Press ? or Esc to close ",
theme::muted_style(),
));
f.render_widget(overlay_block, help_area);
// Create inner area for content
let inner_area = Rect {
x: help_area.x + 1,
y: help_area.y + 1,
@@ -497,6 +291,5 @@ pub fn render_help_overlay(f: &mut Frame<CrosstermBackend<io::Stdout>>, scroll_o
height: help_area.height.saturating_sub(2),
};
// Render the help content with scroll support
render_help_content(f, inner_area, scroll_offset);
}

View File

@@ -1,41 +1,33 @@
// Job detail view rendering
use crate::app::App;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Row, Table},
widgets::{Paragraph, Row, Table, Wrap},
Frame,
};
use std::io;
// Render the job detail view
pub fn render_job_detail_view(
f: &mut Frame<CrosstermBackend<io::Stdout>>,
app: &mut App,
area: Rect,
) {
// Get the workflow index either from current_execution or selected workflow
pub fn render_job_detail_view(f: &mut Frame<'_>, app: &mut App, area: Rect) {
let current_workflow_idx = app
.current_execution
.or_else(|| app.workflow_list_state.selected())
.filter(|&idx| idx < app.workflows.len());
if let Some(workflow_idx) = current_workflow_idx {
// Only proceed if we have execution details
if let Some(execution) = &app.workflows[workflow_idx].execution_details {
// Only proceed if we have a valid job selection
if let Some(job_idx) = app.job_list_state.selected() {
if job_idx < execution.jobs.len() {
let job = &execution.jobs[job_idx];
let workflow_name = &app.workflows[workflow_idx].name;
// Split the area into sections
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Job title
Constraint::Length(4), // Job title + breadcrumb
Constraint::Min(5), // Steps table
Constraint::Length(8), // Step details
]
@@ -44,104 +36,70 @@ pub fn render_job_detail_view(
.margin(1)
.split(area);
// Job title section
// Job title with breadcrumb
let (status_symbol, status_style) = theme::job_status(&job.status);
let status_text = match job.status {
wrkflw_executor::JobStatus::Success => "Success",
wrkflw_executor::JobStatus::Failure => "Failed",
wrkflw_executor::JobStatus::Skipped => "Skipped",
};
let status_style = match job.status {
wrkflw_executor::JobStatus::Success => Style::default().fg(Color::Green),
wrkflw_executor::JobStatus::Failure => Style::default().fg(Color::Red),
wrkflw_executor::JobStatus::Skipped => Style::default().fg(Color::Yellow),
};
let job_title = Paragraph::new(vec![
// Breadcrumb
Line::from(vec![
Span::styled("Job: ", Style::default().fg(Color::Blue)),
Span::styled(workflow_name, theme::muted_style()),
Span::styled(
job.name.clone(),
format!(" {} ", theme::symbols::ARROW),
theme::muted_style(),
),
Span::styled(
&job.name,
Style::default()
.fg(Color::White)
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" ("),
Span::styled(status_text, status_style),
Span::raw(")"),
]),
Line::from(vec![
Span::styled("Steps: ", Style::default().fg(Color::Blue)),
Span::styled(status_symbol, status_style),
Span::raw(" "),
Span::styled(status_text, status_style),
Span::styled(
format!("{}", job.steps.len()),
Style::default().fg(Color::White),
format!(" {} steps", job.steps.len()),
theme::muted_style(),
),
]),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Job Details ",
Style::default().fg(Color::Yellow),
)),
);
.block(theme::block("Job Details"));
f.render_widget(job_title, chunks[0]);
// Steps section
let header_cells = ["Status", "Step Name"].iter().map(|h| {
ratatui::widgets::Cell::from(*h).style(Style::default().fg(Color::Yellow))
});
let header_cells = ["Status", "Step Name"]
.iter()
.map(|h| ratatui::widgets::Cell::from(*h).style(theme::header_style()));
let header = Row::new(header_cells)
.style(Style::default().add_modifier(Modifier::BOLD))
.height(1);
let header = Row::new(header_cells).height(1);
let rows = job.steps.iter().map(|step| {
let status_symbol = match step.status {
wrkflw_executor::StepStatus::Success => "",
wrkflw_executor::StepStatus::Failure => "",
wrkflw_executor::StepStatus::Skipped => "",
};
let status_style = match step.status {
wrkflw_executor::StepStatus::Success => {
Style::default().fg(Color::Green)
}
wrkflw_executor::StepStatus::Failure => Style::default().fg(Color::Red),
wrkflw_executor::StepStatus::Skipped => {
Style::default().fg(Color::Gray)
}
};
let (status_symbol, status_style) = theme::step_status(&step.status);
Row::new(vec![
ratatui::widgets::Cell::from(status_symbol).style(status_style),
ratatui::widgets::Cell::from(step.name.clone()),
ratatui::widgets::Cell::from(step.name.clone())
.style(Style::default().fg(COLORS.text)),
])
});
let steps_table = Table::new(rows)
let widths = [
Constraint::Length(4), // Status icon column
Constraint::Percentage(92), // Name column
];
let steps_table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Steps ", Style::default().fg(Color::Yellow))),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("» ")
.widths(&[
Constraint::Length(8), // Status icon column
Constraint::Percentage(92), // Name column
]);
.block(theme::block("Steps"))
.highlight_style(theme::selected_style())
.highlight_symbol(theme::symbols::SELECTED);
// We need to use the table state from the app
f.render_stateful_widget(steps_table, chunks[1], &mut app.step_table_state);
// Step detail section
@@ -149,57 +107,39 @@ pub fn render_job_detail_view(
if step_idx < job.steps.len() {
let step = &job.steps[step_idx];
// Show step output with proper styling
let (step_symbol, step_style) = theme::step_status(&step.status);
let status_text = match step.status {
wrkflw_executor::StepStatus::Success => "Success",
wrkflw_executor::StepStatus::Failure => "Failed",
wrkflw_executor::StepStatus::Skipped => "Skipped",
};
let status_style = match step.status {
wrkflw_executor::StepStatus::Success => {
Style::default().fg(Color::Green)
}
wrkflw_executor::StepStatus::Failure => {
Style::default().fg(Color::Red)
}
wrkflw_executor::StepStatus::Skipped => {
Style::default().fg(Color::Yellow)
}
};
let mut output_text = step.output.clone();
// Truncate if too long
if output_text.len() > 1000 {
output_text = format!("{}... [truncated]", &output_text[..1000]);
if output_text.len() > 5000 {
output_text =
format!("{}\u{2026} [truncated]", &output_text[..5000]);
}
let step_detail = Paragraph::new(vec![
Line::from(vec![
Span::styled("Step: ", Style::default().fg(Color::Blue)),
Span::styled(step_symbol, step_style),
Span::raw(" "),
Span::styled(
step.name.clone(),
Style::default()
.fg(Color::White)
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" ("),
Span::styled(status_text, status_style),
Span::raw(")"),
Span::styled(format!(" ({})", status_text), step_style),
]),
Line::from(""),
Line::from(output_text),
Line::from(Span::styled(
output_text,
Style::default().fg(COLORS.text_dim),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Step Output ",
Style::default().fg(Color::Yellow),
)),
)
.wrap(ratatui::widgets::Wrap { trim: false });
.block(theme::block("Step Output"))
.wrap(Wrap { trim: false });
f.render_widget(step_detail, chunks[2]);
}

View File

@@ -1,91 +1,35 @@
// Logs tab rendering
use crate::app::App;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
style::Style,
text::{Line, Span},
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table, TableState},
widgets::{Cell, Paragraph, Row, Table, TableState},
Frame,
};
use std::io;
// Render the logs tab
pub fn render_logs_tab(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App, area: Rect) {
// Split the area into header, search bar (optionally shown), and log content
pub fn render_logs_tab(f: &mut Frame<'_>, app: &App, area: Rect) {
let show_search_bar =
app.log_search_active || !app.log_search_query.is_empty() || app.log_filter_level.is_some();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Header with instructions
Constraint::Length(
if app.log_search_active
|| !app.log_search_query.is_empty()
|| app.log_filter_level.is_some()
{
3
} else {
0
},
), // Search bar (optional)
Constraint::Min(3), // Logs content
Constraint::Length(if show_search_bar { 3 } else { 0 }), // Search bar (optional)
Constraint::Min(3), // Logs content
]
.as_ref(),
)
.margin(1)
.split(area);
// Determine if search/filter bar should be shown
let show_search_bar =
app.log_search_active || !app.log_search_query.is_empty() || app.log_filter_level.is_some();
// Render header with instructions
let mut header_text = vec![
Line::from(vec![Span::styled(
"Execution and System Logs",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" or "),
Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(": Navigate logs/matches "),
Span::styled("s", Style::default().fg(Color::Cyan)),
Span::raw(": Search "),
Span::styled("f", Style::default().fg(Color::Cyan)),
Span::raw(": Filter "),
Span::styled("Tab", Style::default().fg(Color::Cyan)),
Span::raw(": Switch tabs"),
]),
];
if show_search_bar {
header_text.push(Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Apply search "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(": Clear search "),
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(": Clear all filters"),
]));
}
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.alignment(Alignment::Center);
f.render_widget(header, chunks[0]);
// Render search bar if active or has content
// Render search bar if active
if show_search_bar {
let search_text = if app.log_search_active {
format!("Search: {}", app.log_search_query)
format!("Search: {}\u{2588}", app.log_search_query) // █ cursor
} else {
format!("Search: {}", app.log_search_query)
};
@@ -104,105 +48,78 @@ pub fn render_logs_tab(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App, a
} else if !app.log_search_query.is_empty() {
"No matches".to_string()
} else {
"".to_string()
String::new()
};
let filter_style = match &app.log_filter_level {
Some(crate::models::LogFilterLevel::Error) => Style::default().fg(COLORS.error),
Some(crate::models::LogFilterLevel::Warning) => Style::default().fg(COLORS.warning),
Some(crate::models::LogFilterLevel::Info) => Style::default().fg(COLORS.info),
Some(crate::models::LogFilterLevel::Success) => Style::default().fg(COLORS.success),
Some(crate::models::LogFilterLevel::Trigger) => Style::default().fg(COLORS.trigger),
Some(crate::models::LogFilterLevel::All) | None => theme::dim_style(),
};
let search_info = Line::from(vec![
Span::raw(search_text),
Span::styled(&search_text, Style::default().fg(COLORS.text)),
Span::raw(" "),
Span::styled(
filter_text,
Style::default().fg(match &app.log_filter_level {
Some(crate::models::LogFilterLevel::Error) => Color::Red,
Some(crate::models::LogFilterLevel::Warning) => Color::Yellow,
Some(crate::models::LogFilterLevel::Info) => Color::Cyan,
Some(crate::models::LogFilterLevel::Success) => Color::Green,
Some(crate::models::LogFilterLevel::Trigger) => Color::Magenta,
Some(crate::models::LogFilterLevel::All) | None => Color::Gray,
}),
),
Span::styled(filter_text, filter_style),
Span::raw(" "),
Span::styled(match_info, Style::default().fg(Color::Magenta)),
Span::styled(match_info, Style::default().fg(COLORS.trigger)),
]);
let search_block = Paragraph::new(search_info)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Search & Filter ",
Style::default().fg(Color::Yellow),
)),
)
let search_block = if app.log_search_active {
theme::block_focused("Search & Filter")
} else {
theme::block("Search & Filter")
};
let search_widget = Paragraph::new(search_info)
.block(search_block)
.alignment(Alignment::Left);
f.render_widget(search_block, chunks[1]);
f.render_widget(search_widget, chunks[0]);
}
// Use processed logs from background thread instead of processing on every frame
// Log table
let filtered_logs = &app.processed_logs;
// Create a table for logs for better organization
let header_cells = ["Time", "Type", "Message"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
.map(|h| Cell::from(*h).style(theme::header_style()));
let header = Row::new(header_cells)
.style(Style::default().add_modifier(Modifier::BOLD))
.height(1);
let header = Row::new(header_cells).height(1);
// Convert processed logs to table rows - this is now very fast since logs are pre-processed
let rows = filtered_logs
.iter()
.map(|processed_log| processed_log.to_row());
let content_idx = if show_search_bar { 2 } else { 1 };
let content_idx = if show_search_bar { 1 } else { 0 };
let log_table = Table::new(rows)
let log_title = format!(
"Logs ({}/{})",
if filtered_logs.is_empty() {
0
} else {
app.log_scroll + 1
},
filtered_logs.len()
);
let widths = [
Constraint::Length(10), // Timestamp column
Constraint::Length(9), // Log type column (wider for badges)
Constraint::Percentage(80), // Message column
];
let log_table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
format!(
" Logs ({}/{}) ",
if filtered_logs.is_empty() {
0
} else {
app.log_scroll + 1
},
filtered_logs.len()
),
Style::default().fg(Color::Yellow),
)),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.widths(&[
Constraint::Length(10), // Timestamp column
Constraint::Length(7), // Log type column
Constraint::Percentage(80), // Message column
]);
.block(theme::block(&log_title))
.highlight_style(theme::selected_style());
// We need to convert log_scroll index to a TableState
let mut log_table_state = TableState::default();
if !filtered_logs.is_empty() {
// If we have search matches, use the match index as the selected row
if !app.log_search_matches.is_empty() {
// Make sure we're within bounds
let _match_index = app
.log_search_match_idx
.min(app.log_search_matches.len() - 1);
// This would involve more complex logic to go from search matches to the filtered logs
// For simplicity in this placeholder, we'll just use the scroll position
log_table_state.select(Some(app.log_scroll.min(filtered_logs.len() - 1)));
} else {
// No search matches, use regular scroll position
log_table_state.select(Some(app.log_scroll.min(filtered_logs.len() - 1)));
}
log_table_state.select(Some(app.log_scroll.min(filtered_logs.len() - 1)));
}
f.render_stateful_widget(log_table, chunks[content_idx], &mut log_table_state);

View File

@@ -8,18 +8,17 @@ mod title_bar;
mod workflows_tab;
use crate::app::App;
use ratatui::{backend::CrosstermBackend, Frame};
use std::io;
use ratatui::Frame;
// Main render function for the UI
pub fn render_ui(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App) {
pub fn render_ui(f: &mut Frame<'_>, app: &mut App) {
// Check if help should be shown as an overlay
if app.show_help {
help_overlay::render_help_overlay(f, app.help_scroll);
return;
}
let size = f.size();
let size = f.area();
// Create main layout
let main_chunks = ratatui::layout::Layout::default()

View File

@@ -1,29 +1,33 @@
// Status bar rendering
use crate::app::App;
use crate::models::StatusSeverity;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Rect},
style::{Color, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use std::io;
use wrkflw_executor::RuntimeType;
// Render the status bar
pub fn render_status_bar(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App, area: Rect) {
// If we have a status message, show it instead of the normal status bar
pub fn render_status_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
// If we have a status message, show it as a toast
if let Some(message) = &app.status_message {
// Determine if this is a success message (starts with ✅)
let is_success = message.starts_with("");
let bg = match app.status_message_severity {
StatusSeverity::Success => COLORS.success,
StatusSeverity::Info => COLORS.info,
StatusSeverity::Warning => COLORS.warning,
StatusSeverity::Error => COLORS.error,
};
let status_message = Paragraph::new(Line::from(vec![Span::styled(
format!(" {} ", message),
Style::default()
.bg(if is_success { Color::Green } else { Color::Red })
.fg(Color::White)
.add_modifier(ratatui::style::Modifier::BOLD),
.bg(bg)
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
)]))
.alignment(Alignment::Center);
@@ -34,179 +38,125 @@ pub fn render_status_bar(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App,
// Normal status bar
let mut status_items = vec![];
// Add mode info
status_items.push(Span::styled(
format!(" {} ", app.runtime_type_name()),
Style::default()
.bg(match app.runtime_type {
RuntimeType::Docker => Color::Blue,
RuntimeType::Podman => Color::Cyan,
RuntimeType::SecureEmulation => Color::Green,
RuntimeType::Emulation => Color::Red,
})
.fg(Color::White),
// Runtime mode badge
status_items.push(theme::badge(
app.runtime_type_name(),
match app.runtime_type {
RuntimeType::Docker => COLORS.runtime_docker,
RuntimeType::Podman => COLORS.runtime_podman,
RuntimeType::SecureEmulation => COLORS.runtime_secure,
RuntimeType::Emulation => COLORS.runtime_emulation,
},
COLORS.text,
));
// Add container runtime status if relevant
// Container runtime status (uses cached availability from App state)
match app.runtime_type {
RuntimeType::Docker => {
// Check Docker silently using safe FD redirection
let is_docker_available = match wrkflw_utils::fd::with_stderr_to_null(
wrkflw_executor::docker::is_available,
) {
Ok(result) => result,
Err(_) => {
wrkflw_logging::debug(
"Failed to redirect stderr when checking Docker availability.",
);
false
}
};
status_items.push(Span::raw(" "));
status_items.push(Span::styled(
if is_docker_available {
" Docker: Connected "
status_items.push(theme::badge(
if app.runtime_available {
"Docker: Connected"
} else {
" Docker: Not Available "
"Docker: Unavailable"
},
Style::default()
.bg(if is_docker_available {
Color::Green
} else {
Color::Red
})
.fg(Color::White),
if app.runtime_available {
COLORS.success
} else {
COLORS.error
},
COLORS.text,
));
}
RuntimeType::Podman => {
// Check Podman silently using safe FD redirection
let is_podman_available = match wrkflw_utils::fd::with_stderr_to_null(
wrkflw_executor::podman::is_available,
) {
Ok(result) => result,
Err(_) => {
wrkflw_logging::debug(
"Failed to redirect stderr when checking Podman availability.",
);
false
}
};
status_items.push(Span::raw(" "));
status_items.push(Span::styled(
if is_podman_available {
" Podman: Connected "
status_items.push(theme::badge(
if app.runtime_available {
"Podman: Connected"
} else {
" Podman: Not Available "
"Podman: Unavailable"
},
Style::default()
.bg(if is_podman_available {
Color::Green
} else {
Color::Red
})
.fg(Color::White),
if app.runtime_available {
COLORS.success
} else {
COLORS.error
},
COLORS.text,
));
}
RuntimeType::SecureEmulation => {
status_items.push(Span::raw(" "));
status_items.push(Span::styled(
" 🔒SECURE ",
Style::default().bg(Color::Green).fg(Color::White),
format!(" {}SECURE ", theme::symbols::LOCK),
Style::default().bg(COLORS.runtime_secure).fg(COLORS.text),
));
}
RuntimeType::Emulation => {
// No need to check anything for emulation mode
}
RuntimeType::Emulation => {}
}
// Add validation/execution mode
// Validation/execution mode badge
status_items.push(Span::raw(" "));
if app.validation_mode {
status_items.push(theme::badge(
"Validation",
COLORS.warning,
ratatui::style::Color::Black,
));
} else {
status_items.push(theme::badge(
"Execution",
COLORS.success,
ratatui::style::Color::Black,
));
}
// Separator
status_items.push(Span::styled(
format!(
" {} ",
if app.validation_mode {
"Validation"
} else {
"Execution"
}
),
Style::default()
.bg(if app.validation_mode {
Color::Yellow
} else {
Color::Green
})
.fg(Color::Black),
format!(" {} ", theme::symbols::SEPARATOR),
Style::default().fg(COLORS.text_muted),
));
// Add context-specific help based on current tab
status_items.push(Span::raw(" "));
let help_text: String = match app.selected_tab {
0 => {
if app.job_selection_mode {
"[Enter] Run job [a] Run all jobs [Esc] Back to workflows".to_string()
} else if let Some(idx) = app.workflow_list_state.selected() {
if idx < app.workflows.len() {
let workflow = &app.workflows[idx];
match workflow.status {
crate::models::WorkflowStatus::NotStarted => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected [t] Trigger [Shift+R] Reset".to_string(),
crate::models::WorkflowStatus::Running => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected (Running...)".to_string(),
crate::models::WorkflowStatus::Success | crate::models::WorkflowStatus::Failed | crate::models::WorkflowStatus::Skipped => "[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected [Shift+R] Reset".to_string(),
}
} else {
"[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected".to_string()
}
} else {
"[Space] Toggle [Enter] Run [J] Select jobs [r] Run selected".to_string()
}
}
1 => {
if app.detailed_view {
"[Esc] Back to jobs [↑/↓] Navigate steps".to_string()
} else {
"[Enter] View details [↑/↓] Navigate jobs".to_string()
}
}
2 => {
// For logs tab, show scrolling instructions
let log_count = app.logs.len() + wrkflw_logging::get_logs().len();
if log_count > 0 {
format!(
"[↑/↓] Scroll logs ({}/{}) [s] Search [f] Filter",
app.log_scroll + 1,
log_count
)
} else {
"[No logs to display]".to_string()
}
}
3 => "[↑/↓] Scroll help [?] Toggle help overlay".to_string(),
_ => String::new(),
};
status_items.push(Span::styled(
format!(" {} ", help_text),
Style::default().fg(Color::White),
));
// Show keybindings for common actions
status_items.push(Span::raw(" "));
status_items.push(Span::styled(
" [Tab] Switch tabs ",
Style::default().fg(Color::White),
));
status_items.push(Span::styled(
" [?] Help ",
Style::default().fg(Color::White),
));
status_items.push(Span::styled(
" [q] Quit ",
Style::default().fg(Color::White),
));
// Context-specific help
let help_text = build_context_help(app);
status_items.push(Span::styled(help_text, theme::hint_style()));
let status_bar = Paragraph::new(Line::from(status_items))
.style(Style::default().bg(Color::DarkGray))
.style(Style::default().bg(COLORS.bg_bar))
.alignment(Alignment::Left);
f.render_widget(status_bar, area);
}
fn build_context_help(app: &App) -> String {
match app.selected_tab {
0 => {
if app.job_selection_mode {
"Enter run \u{2502} a all \u{2502} Esc back".to_string()
} else {
"Space toggle \u{2502} Enter run \u{2502} J jobs \u{2502} r queue \u{2502} t trigger \u{2502} ? help \u{2502} q quit".to_string()
}
}
1 => {
if app.detailed_view {
"Esc back \u{2502} \u{2191}\u{2193} steps \u{2502} ? help \u{2502} q quit"
.to_string()
} else {
"Enter details \u{2502} \u{2191}\u{2193} jobs \u{2502} ? help \u{2502} q quit"
.to_string()
}
}
2 => {
let log_count = app.logs.len() + wrkflw_logging::get_logs().len();
if log_count > 0 {
format!(
"{} logs \u{2502} \u{2191}\u{2193} scroll \u{2502} s search \u{2502} f filter \u{2502} ? help \u{2502} q quit",
log_count
)
} else {
"No logs \u{2502} ? help \u{2502} q quit".to_string()
}
}
3 => "\u{2191}\u{2193} scroll \u{2502} ? close \u{2502} q quit".to_string(),
_ => String::new(),
}
}

View File

@@ -1,74 +1,56 @@
// Title bar rendering
use crate::app::App;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Tabs},
Frame,
};
use std::io;
// Render the title bar with tabs
pub fn render_title_bar(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App, area: Rect) {
let titles = ["Workflows", "Execution", "Logs", "Help"];
let tabs = Tabs::new(
titles
.iter()
.enumerate()
.map(|(i, t)| {
if i == 1 {
// Special case for "Execution"
let e_part = &t[0..1]; // "E"
let x_part = &t[1..2]; // "x"
let rest = &t[2..]; // "ecution"
Line::from(vec![
Span::styled(e_part, Style::default().fg(Color::White)),
Span::styled(
x_part,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED),
),
Span::styled(rest, Style::default().fg(Color::White)),
])
} else {
// Original styling for other tabs
let (first, rest) = t.split_at(1);
Line::from(vec![
Span::styled(
first,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::UNDERLINED),
),
Span::styled(rest, Style::default().fg(Color::White)),
])
}
})
.collect(),
)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" wrkflw ",
pub fn render_title_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
let tab_labels = [
"1\u{00B7}Workflows",
"2\u{00B7}Execution",
"3\u{00B7}Logs",
"4\u{00B7}Help",
];
let tab_lines: Vec<Line> = tab_labels
.iter()
.enumerate()
.map(|(i, t)| {
let style = if i == app.selected_tab {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.select(app.selected_tab)
.divider(Span::raw("|"));
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(COLORS.text_dim)
};
Line::from(Span::styled(*t, style))
})
.collect();
let tabs = Tabs::new(tab_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLORS.border))
.title(Span::styled(" wrkflw ", theme::brand_style()))
.title_alignment(Alignment::Center),
)
.highlight_style(
Style::default()
.bg(COLORS.bg_selected)
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD),
)
.select(app.selected_tab)
.divider(Span::styled(
theme::symbols::TAB_DIVIDER,
Style::default().fg(COLORS.text_muted),
));
f.render_widget(tabs, area);
}

View File

@@ -1,22 +1,15 @@
// Workflows tab rendering
use crate::app::App;
use crate::models::WorkflowStatus;
use crate::theme::{self, COLORS};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table, TableState},
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
widgets::{Cell, Row, Table, TableState},
Frame,
};
use std::io;
// Render the workflow list tab
pub fn render_workflows_tab(
f: &mut Frame<CrosstermBackend<io::Stdout>>,
app: &mut App,
area: Rect,
) {
pub fn render_workflows_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) {
if app.job_selection_mode {
render_job_selection(f, app, area);
} else {
@@ -24,75 +17,26 @@ pub fn render_workflows_tab(
}
}
fn render_workflow_list(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, area: Rect) {
// Create a more structured layout for the workflow tab
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Header with instructions
Constraint::Min(5), // Workflow list
]
.as_ref(),
)
.margin(1)
.split(area);
// Render header with instructions
let header_text = vec![
Line::from(vec![Span::styled(
"Available Workflows",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Cyan)),
Span::raw(": Toggle "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Run "),
Span::styled("J", Style::default().fg(Color::Cyan)),
Span::raw(": Select jobs "),
Span::styled("t", Style::default().fg(Color::Cyan)),
Span::raw(": Trigger remotely"),
]),
];
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.alignment(Alignment::Center);
f.render_widget(header, chunks[0]);
// Create a table for workflows instead of a list for better organization
let selected_style = Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
fn render_workflow_list(f: &mut Frame<'_>, app: &mut App, area: Rect) {
let selected_count = app.workflows.iter().filter(|w| w.selected).count();
let block_title = format!("Workflows ({} selected)", selected_count);
let header_cells = ["", "Status", "Workflow Name", "Path"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
.map(|h| Cell::from(*h).style(theme::header_style()));
let header = Row::new(header_cells)
.style(Style::default().add_modifier(Modifier::BOLD))
.height(1);
let header = Row::new(header_cells).height(1);
let rows = app.workflows.iter().map(|workflow| {
// Create cells for each column
let checkbox = if workflow.selected { "" } else { " " };
let (status_symbol, status_style) = match workflow.status {
WorkflowStatus::NotStarted => ("", Style::default().fg(Color::Gray)),
WorkflowStatus::Running => ("", Style::default().fg(Color::Cyan)),
WorkflowStatus::Success => ("", Style::default().fg(Color::Green)),
WorkflowStatus::Failed => ("", Style::default().fg(Color::Red)),
WorkflowStatus::Skipped => ("", Style::default().fg(Color::Yellow)),
let checkbox = if workflow.selected {
theme::symbols::CHECKBOX_ON
} else {
theme::symbols::CHECKBOX_OFF
};
let (status_symbol, status_style) =
theme::workflow_status_animated(&workflow.status, app.spinner_frame);
let path_display = workflow.path.to_string_lossy();
let path_shortened = if path_display.len() > 30 {
let start = path_display
@@ -101,62 +45,52 @@ fn render_workflow_list(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut A
.nth(29)
.map(|(i, _)| i)
.unwrap_or(0);
format!("...{}", &path_display[start..])
format!("\u{2026}{}", &path_display[start..])
} else {
path_display.to_string()
};
Row::new(vec![
Cell::from(checkbox).style(Style::default().fg(Color::Green)),
Cell::from(checkbox).style(Style::default().fg(COLORS.success)),
Cell::from(status_symbol).style(status_style),
Cell::from(workflow.name.clone()),
Cell::from(path_shortened).style(Style::default().fg(Color::DarkGray)),
Cell::from(workflow.name.clone()).style(
Style::default()
.fg(COLORS.text)
.add_modifier(Modifier::BOLD),
),
Cell::from(path_shortened).style(theme::muted_style()),
])
});
let workflows_table = Table::new(rows)
let widths = [
Constraint::Length(5), // Checkbox column
Constraint::Length(3), // Status icon column
Constraint::Percentage(45), // Name column
Constraint::Percentage(45), // Path column
];
let workflows_table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Workflows ",
Style::default().fg(Color::Yellow),
)),
)
.highlight_style(selected_style)
.highlight_symbol("» ")
.widths(&[
Constraint::Length(3), // Checkbox column
Constraint::Length(4), // Status icon column
Constraint::Percentage(45), // Name column
Constraint::Percentage(45), // Path column
]);
.block(theme::block(&block_title))
.highlight_style(theme::selected_style())
.highlight_symbol(theme::symbols::SELECTED);
// We need to convert ListState to TableState
let mut table_state = TableState::default();
table_state.select(app.workflow_list_state.selected());
f.render_stateful_widget(workflows_table, chunks[1], &mut table_state);
// Use inner area with margin for consistent spacing
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)].as_ref())
.margin(1)
.split(area);
f.render_stateful_widget(workflows_table, inner[0], &mut table_state);
// Update the app list state to match the table state
app.workflow_list_state.select(table_state.selected());
}
fn render_job_selection(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Header with instructions
Constraint::Min(5), // Job list
]
.as_ref(),
)
.margin(1)
.split(area);
fn render_job_selection(f: &mut Frame<'_>, app: &mut App, area: Rect) {
// Get workflow name for the header
let workflow_name = app
.workflow_list_state
@@ -165,69 +99,39 @@ fn render_job_selection(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &mut A
.map(|w| w.name.as_str())
.unwrap_or("Unknown");
let header_text = vec![
Line::from(vec![Span::styled(
format!("Jobs in '{}'", workflow_name),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(": Run job "),
Span::styled("a", Style::default().fg(Color::Cyan)),
Span::raw(": Run all "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(": Back"),
]),
];
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.alignment(Alignment::Center);
f.render_widget(header, chunks[0]);
let selected_style = Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
let block_title = format!("Jobs in '{}'", workflow_name);
let header_cells = ["#", "Job Name"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
.map(|h| Cell::from(*h).style(theme::header_style()));
let header = Row::new(header_cells)
.style(Style::default().add_modifier(Modifier::BOLD))
.height(1);
let header = Row::new(header_cells).height(1);
let rows = app.available_jobs.iter().enumerate().map(|(i, job_name)| {
Row::new(vec![
Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray)),
Cell::from(job_name.clone()),
Cell::from(format!("{}", i + 1)).style(theme::muted_style()),
Cell::from(job_name.clone()).style(Style::default().fg(COLORS.text)),
])
});
let jobs_table = Table::new(rows)
let widths = [
Constraint::Length(4), // Number column
Constraint::Percentage(90), // Job name column
];
let jobs_table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(" Jobs ", Style::default().fg(Color::Yellow))),
)
.highlight_style(selected_style)
.highlight_symbol("» ")
.widths(&[
Constraint::Length(4), // Number column
Constraint::Percentage(90), // Job name column
]);
.block(theme::block(&block_title))
.highlight_style(theme::selected_style())
.highlight_symbol(theme::symbols::SELECTED);
let mut table_state = TableState::default();
table_state.select(Some(app.selected_job_index));
f.render_stateful_widget(jobs_table, chunks[1], &mut table_state);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)].as_ref())
.margin(1)
.split(area);
f.render_stateful_widget(jobs_table, inner[0], &mut table_state);
}

View File

@@ -448,86 +448,94 @@ async fn main() {
});
// Print execution summary
use wrkflw_ui::cli_style;
if result.failure_details.is_some() {
eprintln!("Workflow execution failed:");
eprintln!("{}", cli_style::error("Workflow execution failed:"));
if let Some(details) = result.failure_details {
if verbose {
// Show full error details in verbose mode
eprintln!("{}", details);
} else {
// Show simplified error info in non-verbose mode
let simplified_error = details
.lines()
.filter(|line| line.contains("") || line.trim().starts_with("Error:"))
.take(5) // Limit to the first 5 error lines
.filter(|line| {
line.contains(wrkflw_logging::symbols::FAILURE)
|| line.trim().starts_with("Error:")
})
.take(5)
.collect::<Vec<&str>>()
.join("\n");
eprintln!("{}", simplified_error);
if details.lines().count() > 5 {
eprintln!("\nUse --verbose flag to see full error details");
eprintln!(
"\n{}",
cli_style::dim("Use --verbose flag to see full error details")
);
}
}
}
std::process::exit(1);
} else {
println!("✅ Workflow execution completed successfully!");
println!(
"{}",
cli_style::success("Workflow execution completed successfully!")
);
// Print a summary of executed jobs
println!("\nJob summary:");
println!("{}", cli_style::section("Job summary"));
for job in result.jobs {
println!(
" {} {} ({})",
match job.status {
wrkflw_executor::JobStatus::Success => "",
wrkflw_executor::JobStatus::Failure => "",
wrkflw_executor::JobStatus::Skipped => "⏭️",
},
job.name,
match job.status {
wrkflw_executor::JobStatus::Success => "success",
wrkflw_executor::JobStatus::Failure => "failure",
wrkflw_executor::JobStatus::Skipped => "skipped",
match job.status {
wrkflw_executor::JobStatus::Success => {
println!(" {}", cli_style::job_success(&job.name))
}
);
wrkflw_executor::JobStatus::Failure => {
println!(" {}", cli_style::job_failure(&job.name))
}
wrkflw_executor::JobStatus::Skipped => {
println!(" {}", cli_style::job_skipped(&job.name))
}
}
// Always show steps, not just in debug mode
println!(" Steps:");
for step in job.steps {
let step_status = match step.status {
wrkflw_executor::StepStatus::Success => "",
wrkflw_executor::StepStatus::Failure => "",
wrkflw_executor::StepStatus::Skipped => "⏭️",
};
match step.status {
wrkflw_executor::StepStatus::Success => {
println!("{}", cli_style::step_success(&step.name))
}
wrkflw_executor::StepStatus::Failure => {
println!("{}", cli_style::step_failure(&step.name));
println!(" {} {}", step_status, step.name);
if !verbose {
let error_lines = step
.output
.lines()
.filter(|line| {
line.contains("error:")
|| line.contains("Error:")
|| line.trim().starts_with("Exit code:")
|| line.contains("failed")
})
.take(3)
.collect::<Vec<&str>>();
// If step failed and we're not in verbose mode, show condensed error info
if step.status == wrkflw_executor::StepStatus::Failure && !verbose {
// Extract error information from step output
let error_lines = step
.output
.lines()
.filter(|line| {
line.contains("error:")
|| line.contains("Error:")
|| line.trim().starts_with("Exit code:")
|| line.contains("failed")
})
.take(3) // Limit to 3 most relevant error lines
.collect::<Vec<&str>>();
if !error_lines.is_empty() {
for line in error_lines {
println!("{}", cli_style::indent(line.trim()));
}
if !error_lines.is_empty() {
println!(" Error details:");
for line in error_lines {
println!(" {}", line.trim());
}
if step.output.lines().count() > 3 {
println!(" (Use --verbose for full output)");
if step.output.lines().count() > 3 {
println!(
"{}",
cli_style::indent(
"(Use --verbose for full output)"
)
);
}
}
}
}
wrkflw_executor::StepStatus::Skipped => {
println!("{}", cli_style::step_skipped(&step.name))
}
}
}
}
@@ -619,27 +627,28 @@ async fn main() {
/// Validate a GitHub workflow file
/// Returns true if validation failed, false if it passed
fn validate_github_workflow(path: &Path, verbose: bool) -> bool {
use wrkflw_ui::cli_style;
print!("Validating GitHub workflow file: {}... ", path.display());
match wrkflw_evaluator::evaluate_workflow_file(path, verbose) {
Ok(result) => {
if result.is_valid {
println!("Valid");
println!("{}", cli_style::success("Valid"));
if verbose {
println!(" All validation checks passed");
println!("{}", cli_style::dim(" All validation checks passed"));
}
} else {
println!("Invalid");
println!("{}", cli_style::error("Invalid"));
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
println!("{}", cli_style::indent(&format!("{}. {}", i + 1, issue)));
}
}
!result.is_valid
}
Err(e) => {
println!("Error");
println!("{}", cli_style::error("Error"));
eprintln!(" {}", e);
true // Parse errors count as validation failure
true
}
}
}
@@ -647,43 +656,45 @@ fn validate_github_workflow(path: &Path, verbose: bool) -> bool {
/// Validate a GitLab CI/CD pipeline file
/// Returns true if validation failed, false if it passed
fn validate_gitlab_pipeline(path: &Path, verbose: bool) -> bool {
use wrkflw_ui::cli_style;
print!("Validating GitLab CI pipeline file: {}... ", path.display());
// Parse and validate the pipeline file
match wrkflw_parser::gitlab::parse_pipeline(path) {
Ok(pipeline) => {
println!("Valid syntax");
println!("{}", cli_style::success("Valid syntax"));
// Additional structural validation
let validation_result = wrkflw_validators::validate_gitlab_pipeline(&pipeline);
if !validation_result.is_valid {
println!("⚠️ Validation issues:");
println!("{}", cli_style::warning("Validation issues:"));
for issue in validation_result.issues {
println!(" - {}", issue);
println!("{}", cli_style::indent(&format!("- {}", issue)));
}
true // Validation failed
true
} else {
if verbose {
println!("All validation checks passed");
println!("{}", cli_style::success("All validation checks passed"));
}
false // Validation passed
}
}
Err(e) => {
println!("Invalid");
println!("{}", cli_style::error("Invalid"));
eprintln!("Validation failed: {}", e);
true // Parse error counts as validation failure
true
}
}
}
/// List available workflows and pipelines in the repository
fn list_workflows_and_pipelines(verbose: bool, show_jobs: bool) {
use colored::Colorize;
use wrkflw_ui::cli_style;
// Check for GitHub workflows
let github_path = PathBuf::from(".github/workflows");
if github_path.exists() && github_path.is_dir() {
println!("GitHub Workflows:");
println!("{}", "GitHub Workflows".bold().cyan());
match std::fs::read_dir(&github_path) {
Ok(rd) => {
@@ -699,27 +710,42 @@ fn list_workflows_and_pipelines(verbose: bool, show_jobs: bool) {
.collect();
if entries.is_empty() {
println!(" No workflow files found in .github/workflows");
println!(
"{}",
cli_style::dim(" No workflow files found in .github/workflows")
);
} else {
for entry in entries {
println!(" - {}", entry.path().display());
for (i, entry) in entries.iter().enumerate() {
let is_last = i == entries.len() - 1;
let connector = if is_last {
"\u{2514}\u{2500}\u{2500}"
} else {
"\u{251C}\u{2500}\u{2500}"
};
println!("{} {}", connector.dimmed(), entry.path().display());
if show_jobs {
let prefix = if is_last { " " } else { "\u{2502} " };
match wrkflw_parser::workflow::parse_workflow(&entry.path()) {
Ok(workflow) => {
let mut job_names: Vec<&String> =
workflow.jobs.keys().collect();
job_names.sort();
println!(
" Jobs: {}",
job_names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
"{}{}",
prefix.dimmed(),
format!(
"Jobs: {}",
job_names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
.dimmed()
);
}
Err(e) => {
eprintln!(" Could not parse workflow: {}", e);
eprintln!("{}Could not parse workflow: {}", prefix, e);
}
}
}
@@ -728,47 +754,67 @@ fn list_workflows_and_pipelines(verbose: bool, show_jobs: bool) {
}
Err(e) => {
eprintln!(
" Failed to read directory {}: {}",
github_path.display(),
e
"{}",
cli_style::error(&format!(
"Failed to read directory {}: {}",
github_path.display(),
e
))
);
}
}
} else {
println!("GitHub Workflows: No .github/workflows directory found");
println!(
"{}",
cli_style::dim("GitHub Workflows: No .github/workflows directory found")
);
}
// Check for GitLab CI pipeline
let gitlab_path = PathBuf::from(".gitlab-ci.yml");
if gitlab_path.exists() && gitlab_path.is_file() {
println!("GitLab CI Pipeline:");
println!(" - {}", gitlab_path.display());
println!("\n{}", "GitLab CI Pipeline".bold().cyan());
println!(
"{} {}",
"\u{2514}\u{2500}\u{2500}".dimmed(),
gitlab_path.display()
);
if show_jobs {
match wrkflw_parser::gitlab::parse_pipeline(Path::new(".gitlab-ci.yml")) {
Ok(pipeline) => {
let mut job_names: Vec<&String> = pipeline.jobs.keys().collect();
job_names.sort();
println!(
" Jobs: {}",
job_names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
" {}",
format!(
"Jobs: {}",
job_names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
.dimmed()
);
}
Err(e) => {
eprintln!(" Could not parse pipeline: {}", e);
eprintln!(" Could not parse pipeline: {}", e);
}
}
}
} else {
println!("GitLab CI Pipeline: No .gitlab-ci.yml file found");
println!(
"{}",
cli_style::dim("GitLab CI Pipeline: No .gitlab-ci.yml file found")
);
}
// Check for other GitLab CI pipeline files
if verbose {
println!("Searching for other GitLab CI pipeline files...");
println!(
"\n{}",
cli_style::info("Searching for other GitLab CI pipeline files...")
);
let entries = walkdir::WalkDir::new(".")
.follow_links(true)
@@ -785,9 +831,13 @@ fn list_workflows_and_pipelines(verbose: bool, show_jobs: bool) {
.collect::<Vec<_>>();
if !entries.is_empty() {
println!("Additional GitLab CI Pipeline files:");
println!("{}", "Additional GitLab CI Pipeline files:".bold());
for entry in entries {
println!(" - {}", entry.path().display());
println!(
"{} {}",
"\u{2514}\u{2500}\u{2500}".dimmed(),
entry.path().display()
);
}
}
}