mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
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:
204
Cargo.lock
generated
204
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
crates/logging/src/symbols.rs
Normal file
37
crates/logging/src/symbols.rs
Normal 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}",
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
crates/ui/src/cli_style.rs
Normal file
78
crates/ui/src/cli_style.rs
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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!("\n❌ Workflow 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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
230
crates/ui/src/theme.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user