mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
feat(ui): ship screens 4, 7, 8 and the Tweaks overlay (#105)
* feat(ui): ship screens 4, 7, 8 and the Tweaks overlay PR #104 landed three screens from the Claude Design handoff (Dashboard, Live Run, Step Inspector) and deliberately punted the rest because "a UI without backing data is worse than no UI." Fair. Picked up the remaining screens that actually *have* backing data today, and left the one that doesn't alone. Three new top-level tabs: - DAG (tab 3) — full topological view of the selected workflow, `g` toggles between the spatial column layout and a stage-list layout. Reuses the existing `dag::topo_levels` so what you see here is exactly what the mini-DAG in the Execution tab shows, just bigger. - Trigger (tab 5) — form + live curl preview, dispatches through `wrkflw_github::trigger_workflow` or `wrkflw_gitlab::trigger_pipeline`. `p` flips platform, `+` adds a k=v input, `Tab` walks fields, `Enter` dispatches, `c` dumps the curl into the log buffer because integrating with every terminal's clipboard is not a fight I want to pick today. - Secrets (tab 6) — reads SecretConfig::default(), shows the providers the user actually has wired, detail pane with `m` to toggle the value mask. Runtime pills on the side share state with the existing runtime cycler. We do *not* render a list of individual secret keys; SecretManager doesn't expose a list_known_keys() and I refuse to fake one. Step Inspector's Matrix sub-tab now calls wrkflw_matrix::expand_matrix and renders up to 32 combos with an overflow badge. Status glyph inherits from the parent job because per-combo status isn't tracked end-to-end yet — a future executor change to surface it will drop straight into this render. Tweaks overlay (`,` key) ports the design's floating TweaksPanel. Only one knob: accent color, five slots matching the design palette. theme::set_accent_override plumbs the chosen color into a thread-local the brand mark and focused borders consult every frame. Dropped theme (dark/light) and density from the panel because they'd be dead toggles today — the rule is the rule. What's still not in: screen 6 (Run history + diff). That one needs a storage layer we don't have — no run persistence, no serialized run records, no diff engine. Building a UI on top of nothing would be exactly the thing PR #104 warned against. Left for a separate PR that starts from storage. Verified: cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (734 tests, all green), cargo test -p wrkflw-ui (25/25). Clean. * docs(examples): add ui-demo workflow set for the new screens The new UI (screens 4, 7, 8 + Tweaks) shipped without a dedicated set of workflows to poke at it. The existing tests/workflows/ fixtures are fine for the parser, but they don't have the shapes you actually want to eyeball — there's no clean diamond, no wide multi-stage fan-out, no workflow_dispatch with a mix of input types. So you end up firing up the TUI and squinting at whatever happens to be checked in. Not great. Add examples/ui-demo/ with one workflow per screen/feature: a diamond and a wide fan-out DAG for screen 4, a workflow_dispatch with choice / string / bool inputs for screen 8, a matrix with include/exclude plus three scopes of env for the Step Inspector, a secrets + services + container job for screen 7, a multi-event trigger for the `d` diff filter, and a mixed-status workflow so the status badges aren't stuck on all-green. Every file carries a header comment explaining which screen it targets and what to look at. All eight parse clean under `wrkflw validate`. * fix(ui): clean up loose ends around the new tabs and Tweaks overlay The DAG / Trigger / Secrets tabs and the Tweaks overlay shipped with the edges unfinished. A review turned up a pile of correctness and consistency bugs that individually didn't look like much but together amounted to "the tabs work but everything around them lies." The status bar's context_hints still only knew about the old 4-tab layout — tab 2 was now DAG but showed Logs hints, tab 3 was Logs but showed Help hints, tabs 4-6 went blank. The Help overlay still said "1-4 / w,x,l,h" and listed four tabs. Every keyboard-help surface was advertising the wrong layout. The Tweaks overlay's doc comment claimed it was modal — "the overlay wins" — but the match arm only handled Esc / \`,\` / \`a\`/\`A\`. Everything else (q, d, 1-7, ?) fell through to the global handler. The comment was aspirational. The code was not. trigger_dispatch had no in-flight guard. Hit Enter twice quickly, fire two workflow_dispatch requests. Against real repos. This is the kind of "oops we double-ran the deploy" bug that nobody enjoys. render_target_pane called resolve_target on every frame, which shelled out to \`git remote get-url origin\` + \`git symbolic-ref\` +/- \`git rev-parse\` *on every frame*. The event loop polls at 50ms, so sitting on the Trigger tab produced ~40-60 git subprocesses per second. Per second. And the DAG tab cheerfully printed fabricated column labels — ["setup", "lint", "build", "test/docs", …] — positionally assigned to topological levels, with no relationship to actual workflow content. A release pipeline's third layer would read "build" even when it was \`deploy\`. Exactly the "UI without backing data" footgun the previous PR was supposed to avoid. The fix is mechanical: - Rewrite status_bar hints and the Help overlay for 7 tabs. - Make the Tweaks overlay actually modal — unmatched keys \`continue\` instead of falling through. - Add a trigger_in_flight AtomicBool; set before spawn, cleared by the spawned task regardless of outcome. - Cache the resolved target on App; invalidate on platform toggle. No more subprocess storm. - Replace the hand-rolled JSON escaper with serde_json, which has been in the tree the whole time. While at it, shell-escape the GitHub ref and double-quote-escape the GitLab k/v pairs so the curl preview is copy-paste safe. - Drop the fabricated stage labels; print "stage N" because that's the honest thing. - Sort the include-only matrix column tail so HashMap order can't cause visual jitter between frames. - switch_tab re-masks secrets when leaving the Secrets tab. The doc had claimed this all along; now it's actually true. - Empty env vars no longer count as a set auth token. - trigger_input_cursor is Option<usize>, not a usize::MAX sentinel. Please don't use usize::MAX for "not set" when Option exists. Sixteen new unit tests cover the new helpers and edge cases. Full workspace build + clippy -D warnings + test suite clean. * fix(ui): stop the Trigger tab from smearing the TUI on dispatch The Trigger tab shipped in the previous commit calls into wrkflw_github::trigger_workflow and wrkflw_gitlab::trigger_pipeline from a tokio::spawn. Both of those are happily scattering println! and eprintln! calls — "Repository: x/y", "Using branch: main", "Workflow triggered successfully!", you name it — straight to the stdout that ratatui is rendering into. The TUI owns the terminal. The SDK crates write to it anyway. The result is a frame full of garbled half-redrawn panels the moment the user hits Enter. wrkflw_logging::set_quiet_mode(true) is already being armed at TUI start, but it only gates the logging subsystem — raw println! skips right past it. Route every one of those prints through wrkflw_logging::{info,warning} so the quiet-mode flag actually does the thing it says on the tin. Adds wrkflw-logging as a dep to both crates, which is fine — it's where those messages belonged in the first place. While at it, fix the rest of what was broken around the Trigger tab: The GitLab curl preview was cheerfully lying. It pointed users at /trigger/pipeline with a PRIVATE-TOKEN header, which is a combination that has never worked — /trigger/pipeline wants a trigger token, and the real dispatcher calls /pipeline with a JSON body. Copy-paste the preview and you'd get a 401, maybe a 404. Rebuilt both previews off the same request shape the dispatcher actually sends. GitHub and GitLab bodies are now built via serde_json end-to-end (github_dispatches_body, gitlab_pipeline_body), shell-escaped with a single helper, and the URL uses the resolved repo from the target cache instead of <owner>/<repo> / <id> placeholders. The dispatch outcome used to reach the user only via the Logs tab — the one on Trigger heard nothing back. Added a mpsc channel so the spawned task reports success/failure back to the main event loop, which mirrors it onto the status bar. Drain runs once per tick next to the existing execution-result drain. The DAG graph view was splitting the area into equal-ratio columns with no floor, so on anything narrower than 18 * stages cells the box-drawing characters wrapped and the whole graph looked like someone spilled coffee on it. Clamped columns to a fixed 18-cell width, render "+N more stages" in the overflow slot, and nudge users toward g for list view. Dropped the duplicated "L1 stage 1" header while I was in there. AccentScope wraps the thread-local accent override in an RAII guard so it lives for exactly one frame. A thread-local that nobody ever clears is a booby trap for whoever adds the second theme knob. The old trigger-dispatch test spawned a real tokio task that called the real wrkflw_github::trigger_workflow. If GITHUB_TOKEN happened to be set in the environment — hello, CI — it would fire a real workflow_dispatch against whatever repo git remote resolved to. That is not a test. Replaced with a synchronous guard test that pre-arms the in-flight flag and asserts rejection. No spawn, no runtime, no network. New tests cover the preview bodies, split_slug, and the outcome-drain mapping. * fix(ui): sand off the UX edges flagged in the screens-4-8 review A review pass over the new DAG / Trigger / Secrets / Tweaks tabs turned up a small but consistent failure mode: several bits of the UI were quietly lying to the user, and a couple of input paths were eating keystrokes that mattered. The Secrets tab's header badge advertised "providers configured", but the tab reads `SecretConfig::default()` unconditionally — no real config file is loaded yet. A user who had carefully customised their secrets config would see the two hard-coded defaults and a badge telling them everything was wired up. That's exactly the "UI without backing data" footgun PR #104 set out to ban. Rename the badge to "default providers" and update the file header so the caveat doesn't get lost the next time someone skims the module. The Trigger tab's edit-mode key handler returned `false` for Up / Down / Left / Right, which meant directional keys fell through to the global handler and called `trigger_tab_prev/next_workflow`. So a user typing `env=prod` who hit ↓ to correct the row above had the *workflow about to be dispatched* silently changed underneath them. Consume the arrows in edit mode — no-op for now; a future enhancement can route them to prev/next field if we want that. `resolve_trigger_target`'s error branch admitted the repo was `<unresolved>` while cheerfully inventing `default_branch: "main"`. Two fields, two voices, one warn badge the user might or might not notice. Mark the branch `<unresolved>` too so the un-resolution story is consistent. The dispatcher has its own `get_repo_info` call in the hot path, so this is strictly a display-honesty fix. The Tweaks overlay was modal to the point of swallowing `q`. Quit is universally modal-safe in this TUI; silently eating it is a discoverability trap. Let it through. While at it, `field_row_hl` was taking `hint: String` where the sibling `field_row` took `&str`. Match the sibling. Tiny thing, but the kind of inconsistency that compounds. Two regression tests pinned to the arrow-swallow path and the resolve-error shape, so a future refactor can't quietly re-open either footgun. * fix(ui): clear out the self-review pile for screens 4-8 A review of the new DAG / Trigger / Secrets tabs and the Tweaks overlay turned up a depressingly long list of "works in the happy path, lies to the user otherwise" bugs. Dealing with them in one sweep rather than trickling out a dozen one-liner fixes. The headliner: the Trigger tab renders a "Branch / ref" row as if it's editable, but *no keystroke* ever wrote into `trigger_branch`. The dispatcher and the curl preview both honoured the field — the field just had no input path. A user wanting anything other than the git-resolved default was out of luck. Add a `b` shortcut to focus the branch row, extend `trigger_handle_input_key` to route into `self.trigger_branch` when focused, and rework `trigger_tab_next_field` so Tab cycles Branch → Input(0).key → Input(0).value → … → wrap. Three tests pin the edit path, the Esc-clears-override path, and the mutual exclusion between branch edit and input edit. `state_for_job` in the DAG tab was using `std::ptr::eq` against `app.workflows` elements to figure out which workflow was the current execution, with `usize::MAX` as the fallback. It worked today because the render path gets its `&Workflow` from the same Vec, but it was one refactor away from returning `Some(usize::MAX)` and silently matching against `app.current_execution`. Thread the index the caller already has and compare that. No more pointer identity. The Secrets tab's "reveal / mask" toggle was theatre. There are no actual secret values at this layer — the "value" being masked was the *provider's source descriptor*, i.e. a filesystem path or an env-var prefix. Bullet-masking a filename protects nothing and teaches the user that "masking" means something it doesn't. Rip out `secrets_reveal`, the `m` handler, and the `switch_tab` auto-revert. When per-secret values land the toggle can come back attached to something it can legitimately hide. The Trigger tab's curl preview was building a `format!` string with Rust's backslash-newline line continuation (which *strips* the backslash) and then splitting the result on `" \\"`. That split never matched anything, so the preview rendered as one long line relying on terminal wrap. Rebuild with explicit ` \\\n` joins and split on `\n` at render time. `format_yaml_scalar`'s fallback runs non-scalar matrix values through `serde_yaml::to_string`, which for sequences and maps returns multi-line YAML. That got shoved into a single ratatui Span. Collapse embedded newlines to a visible ` · ` separator. `drain_trigger_outcomes` overwrote `status_message` on every iteration — if two outcomes arrived in the same tick (rare, but the in-flight guard could relax later) the first was silently lost. Log each drained outcome to `self.logs` as the durable record, and have the status bar prefer errors over later successes in the same drain. Tab indices: stop pretending `2, 3, 4, 5, 6` are self-documenting. Introduce `TAB_WORKFLOWS` / `TAB_EXECUTION` / … constants next to `TAB_LABELS` and use them everywhere. `switch_tab` used to define a local `SECRETS_TAB = 5` that the rest of the file promptly ignored. Please don't do that. While at it, widen the accent override plumb-through. The Tweaks panel claimed to recolour the UI but only `block_focused` and the title_bar brand were actually consulting `current_accent()` — the other 14 `COLORS.accent` call sites stayed on the static palette. A user flipping accent saw the focused borders change and nothing else, which looks broken. Point all accent readers at the thread-local override. Minor: hoist \`provider_entries()\` to one call per frame (was 3×), dedupe the \`truncate(name, 12)\` call in the DAG graph (was computed twice per node). cargo fmt + clippy -D warnings clean; 48/48 UI tests green. * fix(ui): close the screens-4-8 review debt A review pass over the screens-4-8 branch turned up three things that the earlier "done" sweep in09da60ashould have caught. Dealt with them in one commit rather than three. The accent plumb-through was overstated.09da60aclaimed all accent readers went through the thread-local override, but `BadgeKind::Accent` still resolved to `COLORS.accent`, `brand_style`/`label_style` were dead but still reaching for the static palette, and `progress_bar`'s default was hardcoded cyan. A user flipping to Amber saw focused borders change and the "d Toggle diff filter" chip stay exactly the same — the same "feature lies about what it does" failure mode the review was trying to plug. Route the badge variant through `current_accent()`, delete the two dead helpers, and switch the progress bar default over. The curl preview was shell-escaping the JSON body but interpolating owner/repo/workflow-name raw into the URL path. Those three come from `git remote` + the local filesystem — nothing stops a workflow filename like `x;rm -rf /.yml` from producing a copy-paste line that does exactly what it says on the tin. Pipe every path segment through `urlencoding::encode`. Not a high-probability hazard, but the preview is *explicitly* there to be copy-pasted, so unquoted user input is an unforced error. Same09da60acommit introduced TAB_WORKFLOWS..TAB_HELP as canonical indices and swept the codebase — except for `status_bar::context_hints`, which was still matching bare 0..6. Port it. Please don't use a bare integer for a tab index when the constant is right there. While at it: `trigger_tab_copy_curl` was pushing a multi-line curl string as one log entry (embedded newlines, rendered garbled); successful dispatches now set a Success (green) status message instead of Info (cyan) so they're visually distinct from the "queued" messages that share the Trigger tab's accent; renamed a shadowed `truncated` local in `dag_tab` that was doing double duty as both the overflow-stage count and the char-truncated job name. Four new tests pin the curl fallback placeholders, the URL-encoding regression, input-row Enter fallthrough, and AccentScope dropping the thread-local on scope exit. cargo fmt + clippy -D warnings + full workspace test suite clean. * fix(ui): close the remaining review debt on screens 4-8 A review pass over the screens-4-8 branch turned up three things that survived the last round. Dealing with them in one commit. It turns out the curl preview and the real dispatcher were computing the workflow URL segment *differently*. The preview stripped `.yml`/`.yaml` and url-encoded the full user input — subdir and all. The dispatcher ran `Path::new(name).file_stem()` (which drops the subdir) and then unconditionally re-appended `.yml`, turning a `ci.yaml` workflow into a `ci.yaml.yml` URL that has never worked. So the preview was lying about what Enter would send, the dispatcher had a latent `.yml.yml` bug for any input that already carried the extension, and `.yaml` files silently became `.yml` URLs on dispatch. Consolidate both paths behind a single `wrkflw_github::workflow_dispatch_path_segment` helper. Drops any subdir prefix, preserves an existing `.yml`/`.yaml`, appends `.yml` only when absent. The preview and the dispatcher now produce byte-identical URL segments for the same input — and the `.yaml.yml` footgun is gone as a side effect. The second one: `a` (select-all) and `r` (queue+run) were gated only on `!app.running`, not on the active tab. Pressing `a` on the DAG or Trigger or Secrets tabs — outside edit mode — would silently flip every workflow to `selected` behind the user's back. Same story for `r` queuing an execution. Add the tab gate. Please don't do that. The third one: `trigger_in_flight` was cleared by a trailing `store(false)` at the end of the spawned dispatch task. Fine on normal return. A panic inside reqwest or either SDK would skip right past it, strand the flag at `true`, and lock the Trigger tab into a permanent "already in flight" state until the TUI restarts. Wrap the flag in an `InFlightGuard` RAII so Drop is what actually clears it. Normal return, early return, *and* unwinding all land it back at `false`. While at it: dropped the `DEBUG: Shift+R pressed` log spam that was sprinkled through the reset handlers, deleted the now-dead `strip_yaml_suffix` helper, and cleaned up a stray `test_edge_cases.rs` debug script that had been sitting in the repo root. Eight new tests across the two crates cover the shared URL helper, the preview/dispatcher identity, \`.yaml\` round-trip, and the in-flight guard on both normal drop and \`catch_unwind\` panic paths. cargo fmt + clippy -D warnings + full workspace test suite clean.
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -3596,6 +3596,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
"wrkflw-logging",
|
||||
"wrkflw-models",
|
||||
]
|
||||
|
||||
@@ -3611,6 +3612,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
"urlencoding",
|
||||
"wrkflw-logging",
|
||||
"wrkflw-models",
|
||||
]
|
||||
|
||||
@@ -3737,12 +3739,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"wrkflw-evaluator",
|
||||
"wrkflw-executor",
|
||||
"wrkflw-github",
|
||||
"wrkflw-gitlab",
|
||||
"wrkflw-logging",
|
||||
"wrkflw-matrix",
|
||||
"wrkflw-models",
|
||||
"wrkflw-parser",
|
||||
"wrkflw-secrets",
|
||||
"wrkflw-trigger-filter",
|
||||
"wrkflw-utils",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ categories.workspace = true
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wrkflw-models.workspace = true
|
||||
wrkflw-logging.workspace = true
|
||||
|
||||
# External dependencies from workspace
|
||||
serde.workspace = true
|
||||
|
||||
@@ -113,6 +113,33 @@ pub fn get_repo_info() -> Result<RepoInfo, GithubError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a user-facing workflow identifier into the path segment
|
||||
/// GitHub's `workflow_dispatch` endpoint expects as `{workflow_file_name}`
|
||||
/// in `/repos/{owner}/{repo}/actions/workflows/{workflow_file_name}/dispatches`.
|
||||
///
|
||||
/// - Drops any directory prefix: `"release/prod.yml"` → `"prod.yml"`.
|
||||
/// - Preserves an existing `.yml` or `.yaml` suffix so workflows stored
|
||||
/// as `.yaml` don't have `.yml` tacked on.
|
||||
/// - Appends `.yml` when no extension is present so the result is
|
||||
/// always a valid filename reference.
|
||||
/// - Returns `None` for inputs with no extractable basename (empty
|
||||
/// string, bare path separator, trailing slash).
|
||||
///
|
||||
/// Used both by [`trigger_workflow`] to build the real dispatch URL
|
||||
/// and by the TUI's Trigger-tab curl preview via this crate's public
|
||||
/// API, so the preview and the actual POST land on the same endpoint.
|
||||
pub fn workflow_dispatch_path_segment(name: &str) -> Option<String> {
|
||||
let basename = name.rsplit(['/', '\\']).next()?;
|
||||
if basename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if basename.ends_with(".yml") || basename.ends_with(".yaml") {
|
||||
Some(basename.to_string())
|
||||
} else {
|
||||
Some(format!("{basename}.yml"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of available workflows in the repository
|
||||
pub async fn list_workflows(_repo_info: &RepoInfo) -> Result<Vec<String>, GithubError> {
|
||||
let workflows_dir = Path::new(".github/workflows");
|
||||
@@ -164,23 +191,25 @@ pub async fn trigger_workflow(
|
||||
|
||||
// Get repository information
|
||||
let repo_info = get_repo_info()?;
|
||||
println!("Repository: {}/{}", repo_info.owner, repo_info.repo);
|
||||
wrkflw_logging::info(&format!(
|
||||
"Repository: {}/{}",
|
||||
repo_info.owner, repo_info.repo
|
||||
));
|
||||
|
||||
// Prepare the request payload
|
||||
let branch_ref = branch.unwrap_or(&repo_info.default_branch);
|
||||
println!("Using branch: {}", branch_ref);
|
||||
wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
|
||||
|
||||
// Extract just the workflow name from the path if it's a full path
|
||||
let workflow_name = if workflow_name.contains('/') {
|
||||
Path::new(workflow_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?
|
||||
} else {
|
||||
workflow_name
|
||||
};
|
||||
// Normalize the user-facing identifier into the dispatch URL
|
||||
// segment. Handles subdir prefixes (drop) and missing extensions
|
||||
// (append `.yml`) so `"ci"`, `"ci.yml"`, `"ci.yaml"`, and
|
||||
// `"release/prod.yml"` all produce the same URL shape the REST
|
||||
// API expects. The TUI preview goes through the same helper so
|
||||
// a copy-pasted curl lands on the same endpoint.
|
||||
let workflow_segment = workflow_dispatch_path_segment(workflow_name)
|
||||
.ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?;
|
||||
|
||||
println!("Using workflow name: {}", workflow_name);
|
||||
wrkflw_logging::info(&format!("Using workflow file: {}", workflow_segment));
|
||||
|
||||
// Create simplified payload
|
||||
let mut payload = serde_json::json!({
|
||||
@@ -190,16 +219,16 @@ pub async fn trigger_workflow(
|
||||
// Add inputs if provided
|
||||
if let Some(input_map) = inputs {
|
||||
payload["inputs"] = serde_json::json!(input_map);
|
||||
println!("With inputs: {:?}", input_map);
|
||||
wrkflw_logging::info(&format!("With inputs: {:?}", input_map));
|
||||
}
|
||||
|
||||
// Send the workflow_dispatch event
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/dispatches",
|
||||
repo_info.owner, repo_info.repo, workflow_name
|
||||
"https://api.github.com/repos/{}/{}/actions/workflows/{}/dispatches",
|
||||
repo_info.owner, repo_info.repo, workflow_segment
|
||||
);
|
||||
|
||||
println!("Triggering workflow at URL: {}", url);
|
||||
wrkflw_logging::info(&format!("Triggering workflow at URL: {}", url));
|
||||
|
||||
// Create a reqwest client
|
||||
let client = reqwest::Client::new();
|
||||
@@ -243,65 +272,62 @@ pub async fn trigger_workflow(
|
||||
});
|
||||
}
|
||||
|
||||
println!("Workflow triggered successfully!");
|
||||
println!(
|
||||
"View runs at: https://github.com/{}/{}/actions/workflows/{}.yml",
|
||||
repo_info.owner, repo_info.repo, workflow_name
|
||||
);
|
||||
wrkflw_logging::info("Workflow triggered successfully!");
|
||||
wrkflw_logging::info(&format!(
|
||||
"View runs at: https://github.com/{}/{}/actions/workflows/{}",
|
||||
repo_info.owner, repo_info.repo, workflow_segment
|
||||
));
|
||||
|
||||
// Attempt to verify the workflow was actually triggered
|
||||
match list_recent_workflow_runs(&repo_info, workflow_name, &token).await {
|
||||
match list_recent_workflow_runs(&repo_info, &workflow_segment, &token).await {
|
||||
Ok(runs) => {
|
||||
if !runs.is_empty() {
|
||||
println!("\nRecent runs of this workflow:");
|
||||
wrkflw_logging::info("Recent runs of this workflow:");
|
||||
for run in runs.iter().take(3) {
|
||||
println!(
|
||||
wrkflw_logging::info(&format!(
|
||||
"- Run #{} ({}): {}",
|
||||
run.get("id").and_then(|id| id.as_u64()).unwrap_or(0),
|
||||
run.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
run.get("html_url").and_then(|u| u.as_str()).unwrap_or("")
|
||||
);
|
||||
));
|
||||
}
|
||||
} else {
|
||||
println!("\nNo recent runs found. The workflow might still be initializing.");
|
||||
println!(
|
||||
wrkflw_logging::info(
|
||||
"No recent runs found. The workflow might still be initializing.",
|
||||
);
|
||||
wrkflw_logging::info(&format!(
|
||||
"Check GitHub UI in a few moments: https://github.com/{}/{}/actions",
|
||||
repo_info.owner, repo_info.repo
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("\nCould not fetch recent workflow runs: {}", e);
|
||||
println!("This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions",
|
||||
repo_info.owner, repo_info.repo);
|
||||
wrkflw_logging::warning(&format!("Could not fetch recent workflow runs: {}", e));
|
||||
wrkflw_logging::info(&format!(
|
||||
"This doesn't mean the trigger failed - check GitHub UI: https://github.com/{}/{}/actions",
|
||||
repo_info.owner, repo_info.repo
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List recent workflow runs for a specific workflow
|
||||
/// List recent workflow runs for a specific workflow. `workflow_segment`
|
||||
/// must already be the basename-form produced by
|
||||
/// [`workflow_dispatch_path_segment`] (e.g. `"ci.yml"`), not the raw
|
||||
/// user-facing identifier — the caller has already normalized it.
|
||||
async fn list_recent_workflow_runs(
|
||||
repo_info: &RepoInfo,
|
||||
workflow_name: &str,
|
||||
workflow_segment: &str,
|
||||
token: &str,
|
||||
) -> Result<Vec<serde_json::Value>, GithubError> {
|
||||
// Extract just the workflow name from the path if it's a full path
|
||||
let workflow_name = if workflow_name.contains('/') {
|
||||
Path::new(workflow_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| GithubError::GitParseError("Invalid workflow name".to_string()))?
|
||||
} else {
|
||||
workflow_name
|
||||
};
|
||||
|
||||
// Get recent workflow runs via GitHub API
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/runs?per_page=5",
|
||||
repo_info.owner, repo_info.repo, workflow_name
|
||||
"https://api.github.com/repos/{}/{}/actions/workflows/{}/runs?per_page=5",
|
||||
repo_info.owner, repo_info.repo, workflow_segment
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -338,3 +364,89 @@ async fn list_recent_workflow_runs(
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_keeps_yml_extension() {
|
||||
assert_eq!(
|
||||
workflow_dispatch_path_segment("ci.yml"),
|
||||
Some("ci.yml".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_keeps_yaml_extension() {
|
||||
// Regression: the old dispatcher unconditionally appended
|
||||
// `.yml`, turning `ci.yaml` into `ci.yaml.yml` which was a
|
||||
// guaranteed 404. The helper must round-trip the `.yaml`
|
||||
// form untouched.
|
||||
assert_eq!(
|
||||
workflow_dispatch_path_segment("ci.yaml"),
|
||||
Some("ci.yaml".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_appends_yml_when_missing() {
|
||||
assert_eq!(workflow_dispatch_path_segment("ci"), Some("ci.yml".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_strips_subdir_prefix() {
|
||||
// GitHub does not support subdirs under `.github/workflows/`,
|
||||
// but the caller may pass a filesystem-like path. The helper
|
||||
// drops everything before the final path separator so the
|
||||
// segment always addresses the workflow by basename.
|
||||
assert_eq!(
|
||||
workflow_dispatch_path_segment("release/prod.yml"),
|
||||
Some("prod.yml".into())
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_dispatch_path_segment("deep/nested/ci.yaml"),
|
||||
Some("ci.yaml".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_rejects_inputs_with_no_basename() {
|
||||
assert_eq!(workflow_dispatch_path_segment(""), None);
|
||||
assert_eq!(workflow_dispatch_path_segment("/"), None);
|
||||
assert_eq!(workflow_dispatch_path_segment("foo/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_dispatch_path_segment_matches_across_dispatcher_and_preview() {
|
||||
// The whole point of the helper is that the preview and the
|
||||
// real dispatcher produce the same URL segment for the same
|
||||
// input. Pin the identities the Trigger-tab curl preview
|
||||
// depends on so a refactor in either place can't silently
|
||||
// drift them apart.
|
||||
for input in [
|
||||
"ci",
|
||||
"ci.yml",
|
||||
"ci.yaml",
|
||||
"release/prod.yml",
|
||||
"deep/nested/ci.yaml",
|
||||
"has spaces.yml",
|
||||
"weird;name.yml",
|
||||
] {
|
||||
let segment = workflow_dispatch_path_segment(input)
|
||||
.unwrap_or_else(|| panic!("helper produced None for {:?}", input));
|
||||
assert!(
|
||||
!segment.contains('/') && !segment.contains('\\'),
|
||||
"segment {:?} for input {:?} must be a bare basename",
|
||||
segment,
|
||||
input
|
||||
);
|
||||
assert!(
|
||||
segment.ends_with(".yml") || segment.ends_with(".yaml"),
|
||||
"segment {:?} for input {:?} must carry an extension",
|
||||
segment,
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ categories.workspace = true
|
||||
[dependencies]
|
||||
# Internal crates
|
||||
wrkflw-models.workspace = true
|
||||
wrkflw-logging.workspace = true
|
||||
|
||||
# External dependencies
|
||||
lazy_static.workspace = true
|
||||
|
||||
@@ -143,14 +143,14 @@ pub async fn trigger_pipeline(
|
||||
|
||||
// Get repository information
|
||||
let repo_info = get_repo_info()?;
|
||||
println!(
|
||||
wrkflw_logging::info(&format!(
|
||||
"GitLab Repository: {}/{}",
|
||||
repo_info.namespace, repo_info.project
|
||||
);
|
||||
));
|
||||
|
||||
// Prepare the request payload
|
||||
let branch_ref = branch.unwrap_or(&repo_info.default_branch);
|
||||
println!("Using branch: {}", branch_ref);
|
||||
wrkflw_logging::info(&format!("Using branch: {}", branch_ref));
|
||||
|
||||
// Create simplified payload
|
||||
let mut payload = serde_json::json!({
|
||||
@@ -171,7 +171,7 @@ pub async fn trigger_pipeline(
|
||||
.collect();
|
||||
|
||||
payload["variables"] = serde_json::json!(formatted_vars);
|
||||
println!("With variables: {:?}", vars_map);
|
||||
wrkflw_logging::info(&format!("With variables: {:?}", vars_map));
|
||||
}
|
||||
|
||||
// URL encode the namespace and project for use in URL
|
||||
@@ -185,7 +185,7 @@ pub async fn trigger_pipeline(
|
||||
encoded_project = encoded_project,
|
||||
);
|
||||
|
||||
println!("Triggering pipeline at URL: {}", url);
|
||||
wrkflw_logging::info(&format!("Triggering pipeline at URL: {}", url));
|
||||
|
||||
// Create a reqwest client
|
||||
let client = reqwest::Client::new();
|
||||
@@ -236,8 +236,8 @@ pub async fn trigger_pipeline(
|
||||
repo_info.namespace, repo_info.project, pipeline_id
|
||||
);
|
||||
|
||||
println!("Pipeline triggered successfully!");
|
||||
println!("View pipeline at: {}", pipeline_url);
|
||||
wrkflw_logging::info("Pipeline triggered successfully!");
|
||||
wrkflw_logging::info(&format!("View pipeline at: {}", pipeline_url));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ wrkflw-logging.workspace = true
|
||||
wrkflw-trigger-filter.workspace = true
|
||||
wrkflw-utils.workspace = true
|
||||
wrkflw-github.workspace = true
|
||||
wrkflw-gitlab.workspace = true
|
||||
wrkflw-matrix.workspace = true
|
||||
wrkflw-secrets.workspace = true
|
||||
|
||||
# External dependencies
|
||||
chrono.workspace = true
|
||||
@@ -37,3 +40,4 @@ serde_json.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
regex.workspace = true
|
||||
futures.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
@@ -4,7 +4,10 @@ mod state;
|
||||
use crate::handlers::workflow::start_next_workflow_execution;
|
||||
use crate::models::{ExecutionResultMsg, QueuedExecution, Workflow, WorkflowStatus};
|
||||
use crate::utils::load_workflows;
|
||||
use crate::views::render_ui;
|
||||
use crate::views::{
|
||||
render_ui, TAB_COUNT, TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER,
|
||||
TAB_WORKFLOWS,
|
||||
};
|
||||
use chrono::Local;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
@@ -18,7 +21,7 @@ use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use wrkflw_executor::RuntimeType;
|
||||
|
||||
pub use state::App;
|
||||
pub use state::{Accent, App, TriggerPlatform};
|
||||
|
||||
// Main entry point for the TUI interface
|
||||
#[allow(clippy::ptr_arg)]
|
||||
@@ -209,6 +212,11 @@ fn run_tui_event_loop(
|
||||
start_next_workflow_execution(app, tx_clone, verbose);
|
||||
}
|
||||
|
||||
// Surface any completed Trigger-tab dispatches on the status bar
|
||||
// so the user sees the outcome where they fired it, rather than
|
||||
// having to switch tabs to Logs.
|
||||
app.drain_trigger_outcomes();
|
||||
|
||||
// Start execution if we have a queued workflow and nothing is currently running
|
||||
if app.running && app.current_execution.is_none() && !app.execution_queue.is_empty() {
|
||||
start_next_workflow_execution(app, tx_clone, verbose);
|
||||
@@ -218,11 +226,47 @@ fn run_tui_event_loop(
|
||||
if event::poll(event_poll_timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// Handle search input first if we're in search mode and logs tab
|
||||
if app.selected_tab == 2 && app.log_search_active {
|
||||
if app.selected_tab == TAB_LOGS && app.log_search_active {
|
||||
app.handle_log_search_input(key.code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// When the Tweaks overlay is open it is modal: only
|
||||
// its own shortcuts are honoured, everything else is
|
||||
// swallowed so keys like `d` or a tab number can't
|
||||
// silently fire the global handler while the user is
|
||||
// in edit-mode. `q` is the one exception — quit is
|
||||
// universally modal-safe in this TUI and swallowing
|
||||
// it silently was a discoverability trap.
|
||||
if app.tweaks_open {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
break Ok(());
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char(',') => {
|
||||
app.tweaks_open = false;
|
||||
}
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
app.tweaks_accent = app.tweaks_accent.next();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trigger tab: if a text field (branch or an input
|
||||
// row) is focused, route printable characters and
|
||||
// edit keys straight to that field before the global
|
||||
// key-map fires. Otherwise editing a value like
|
||||
// "notify=slack" would trip the `s` logs shortcut
|
||||
// below and jump tabs.
|
||||
if app.selected_tab == TAB_TRIGGER
|
||||
&& app.trigger_editing()
|
||||
&& app.trigger_handle_input_key(key.code)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle help overlay scrolling
|
||||
if app.show_help {
|
||||
match key.code {
|
||||
@@ -263,77 +307,105 @@ fn run_tui_event_loop(
|
||||
// Inside the Step Inspector, Tab cycles inspector
|
||||
// sub-tabs (Output / Env / Files / Matrix / Timeline);
|
||||
// elsewhere it cycles top-level tabs.
|
||||
if app.selected_tab == 1 && app.detailed_view {
|
||||
if app.selected_tab == TAB_EXECUTION && app.detailed_view {
|
||||
app.step_inspector_tab = (app.step_inspector_tab + 1) % 5;
|
||||
} else if app.selected_tab == TAB_TRIGGER {
|
||||
// In the Trigger tab Tab cycles inputs/fields.
|
||||
app.trigger_tab_next_field();
|
||||
} else {
|
||||
app.switch_tab((app.selected_tab + 1) % 4);
|
||||
app.switch_tab((app.selected_tab + 1) % TAB_COUNT);
|
||||
}
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
if app.selected_tab == 1 && app.detailed_view {
|
||||
if app.selected_tab == TAB_EXECUTION && app.detailed_view {
|
||||
app.step_inspector_tab = (app.step_inspector_tab + 4) % 5;
|
||||
} else if app.selected_tab == TAB_TRIGGER {
|
||||
app.trigger_tab_prev_field();
|
||||
} else {
|
||||
app.switch_tab((app.selected_tab + 3) % 4);
|
||||
app.switch_tab((app.selected_tab + TAB_COUNT - 1) % TAB_COUNT);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('1') | KeyCode::Char('w') => app.switch_tab(0),
|
||||
KeyCode::Char('2') | KeyCode::Char('x') => app.switch_tab(1),
|
||||
KeyCode::Char('3') | KeyCode::Char('l') => app.switch_tab(2),
|
||||
KeyCode::Char('4') | KeyCode::Char('h') => app.switch_tab(3),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if app.selected_tab == 2 {
|
||||
if !app.log_search_matches.is_empty() {
|
||||
app.previous_search_match();
|
||||
} else {
|
||||
app.scroll_logs_up();
|
||||
}
|
||||
} else if app.selected_tab == 3 {
|
||||
app.scroll_help_up();
|
||||
} else if app.selected_tab == 0 {
|
||||
KeyCode::Char('1') | KeyCode::Char('w') => app.switch_tab(TAB_WORKFLOWS),
|
||||
KeyCode::Char('2') | KeyCode::Char('x') => app.switch_tab(TAB_EXECUTION),
|
||||
KeyCode::Char('3') => app.switch_tab(TAB_DAG),
|
||||
KeyCode::Char('4') | KeyCode::Char('l') => app.switch_tab(TAB_LOGS),
|
||||
KeyCode::Char('5') => app.switch_tab(TAB_TRIGGER),
|
||||
KeyCode::Char('6') => app.switch_tab(TAB_SECRETS),
|
||||
KeyCode::Char('7') | KeyCode::Char('h') => app.switch_tab(TAB_HELP),
|
||||
KeyCode::Char(',') => {
|
||||
// `,` toggles the Tweaks overlay anywhere (global).
|
||||
// Chosen because it never conflicts with our single-
|
||||
// letter tab shortcuts or with the log-search input
|
||||
// mode (which only consumes printable chars when
|
||||
// `log_search_active` is true, which we already
|
||||
// handled above with a `continue`).
|
||||
app.tweaks_open = !app.tweaks_open;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => match app.selected_tab {
|
||||
TAB_WORKFLOWS => {
|
||||
if app.job_selection_mode {
|
||||
app.previous_available_job();
|
||||
} else {
|
||||
app.previous_workflow();
|
||||
}
|
||||
} else if app.selected_tab == 1 {
|
||||
}
|
||||
TAB_EXECUTION => {
|
||||
if app.detailed_view {
|
||||
app.previous_step();
|
||||
} else {
|
||||
app.previous_job();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if app.selected_tab == 2 {
|
||||
TAB_LOGS => {
|
||||
if !app.log_search_matches.is_empty() {
|
||||
app.next_search_match();
|
||||
app.previous_search_match();
|
||||
} else {
|
||||
app.scroll_logs_down();
|
||||
app.scroll_logs_up();
|
||||
}
|
||||
} else if app.selected_tab == 3 {
|
||||
app.scroll_help_down();
|
||||
} else if app.selected_tab == 0 {
|
||||
}
|
||||
TAB_TRIGGER => app.trigger_tab_prev_workflow(),
|
||||
TAB_SECRETS => app.secrets_tab_prev(),
|
||||
TAB_HELP => app.scroll_help_up(),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Down | KeyCode::Char('j') => match app.selected_tab {
|
||||
TAB_WORKFLOWS => {
|
||||
if app.job_selection_mode {
|
||||
app.next_available_job();
|
||||
} else {
|
||||
app.next_workflow();
|
||||
}
|
||||
} else if app.selected_tab == 1 {
|
||||
}
|
||||
TAB_EXECUTION => {
|
||||
if app.detailed_view {
|
||||
app.next_step();
|
||||
} else {
|
||||
app.next_job();
|
||||
}
|
||||
}
|
||||
}
|
||||
TAB_LOGS => {
|
||||
if !app.log_search_matches.is_empty() {
|
||||
app.next_search_match();
|
||||
} else {
|
||||
app.scroll_logs_down();
|
||||
}
|
||||
}
|
||||
TAB_TRIGGER => app.trigger_tab_next_workflow(),
|
||||
TAB_SECRETS => app.secrets_tab_next(),
|
||||
TAB_HELP => app.scroll_help_down(),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Char(' ') => {
|
||||
if app.selected_tab == 0 && !app.running && !app.job_selection_mode {
|
||||
if app.selected_tab == TAB_WORKFLOWS
|
||||
&& !app.running
|
||||
&& !app.job_selection_mode
|
||||
{
|
||||
app.toggle_selected();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match app.selected_tab {
|
||||
0 => {
|
||||
TAB_WORKFLOWS => {
|
||||
if !app.running {
|
||||
if app.job_selection_mode {
|
||||
// In job selection mode, run the selected job
|
||||
@@ -354,39 +426,44 @@ fn run_tui_event_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
TAB_EXECUTION => {
|
||||
// In execution tab, Enter shows job details
|
||||
app.toggle_detailed_view();
|
||||
}
|
||||
TAB_TRIGGER => {
|
||||
// Trigger tab: Enter on a non-editing row
|
||||
// begins editing it (value first, then Tab
|
||||
// to swap); when already editing, Enter
|
||||
// commits the edit back to the row.
|
||||
app.trigger_tab_enter();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
// Check if shift is pressed - this might be receiving the reset command
|
||||
// Some terminals deliver Shift+r as lowercase
|
||||
// `r` plus a SHIFT modifier instead of
|
||||
// uppercase `R` (see the `R` arm below).
|
||||
// Route both encodings to the reset path so
|
||||
// users don't have to reason about what their
|
||||
// terminal emits.
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
let timestamp = Local::now().format("%H:%M:%S").to_string();
|
||||
app.logs.push(format!(
|
||||
"[{}] DEBUG: Shift+r detected - this should be uppercase R",
|
||||
timestamp
|
||||
));
|
||||
wrkflw_logging::info(
|
||||
"Shift+r detected as lowercase - this should be uppercase R",
|
||||
);
|
||||
|
||||
if !app.running {
|
||||
// Reset workflow status with Shift+r
|
||||
app.logs.push(format!(
|
||||
"[{}] Attempting to reset workflow status via Shift+r...",
|
||||
timestamp
|
||||
));
|
||||
app.reset_workflow_status();
|
||||
|
||||
// Force redraw to update UI immediately
|
||||
terminal.draw(|f| {
|
||||
render_ui(f, app);
|
||||
})?;
|
||||
}
|
||||
} else if !app.running && !app.job_selection_mode {
|
||||
} else if !app.running
|
||||
&& !app.job_selection_mode
|
||||
&& app.selected_tab == TAB_WORKFLOWS
|
||||
{
|
||||
// Plain `r` queues the selected workflow
|
||||
// for execution — a Workflows-tab action.
|
||||
// Gate on the active tab so `r` typed on
|
||||
// the DAG/Trigger/Secrets tabs doesn't
|
||||
// silently mutate queue state behind the
|
||||
// user's back.
|
||||
app.queue_selected_for_execution();
|
||||
app.start_execution();
|
||||
}
|
||||
@@ -396,8 +473,13 @@ fn run_tui_event_loop(
|
||||
if app.job_selection_mode {
|
||||
// In job selection mode, run all jobs
|
||||
app.run_from_job_selection(None);
|
||||
} else {
|
||||
// Select all workflows
|
||||
} else if app.selected_tab == TAB_WORKFLOWS {
|
||||
// Select-all is a Workflows-tab action.
|
||||
// Without this tab gate, pressing `a`
|
||||
// on DAG/Trigger/Secrets (outside edit
|
||||
// mode) would silently flip every
|
||||
// workflow to `selected` behind the
|
||||
// user's back.
|
||||
for workflow in &mut app.workflows {
|
||||
workflow.selected = true;
|
||||
}
|
||||
@@ -406,7 +488,10 @@ fn run_tui_event_loop(
|
||||
}
|
||||
KeyCode::Char('J') => {
|
||||
// Enter job selection mode for selected workflow
|
||||
if !app.running && app.selected_tab == 0 && !app.job_selection_mode {
|
||||
if !app.running
|
||||
&& app.selected_tab == TAB_WORKFLOWS
|
||||
&& !app.job_selection_mode
|
||||
{
|
||||
app.enter_job_selection_mode();
|
||||
}
|
||||
}
|
||||
@@ -421,7 +506,7 @@ fn run_tui_event_loop(
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if !app.running && app.selected_tab == 0 {
|
||||
if !app.running && app.selected_tab == TAB_WORKFLOWS {
|
||||
app.toggle_diff_filter();
|
||||
}
|
||||
}
|
||||
@@ -434,14 +519,14 @@ fn run_tui_event_loop(
|
||||
// workflow gated on a non-push event — exactly
|
||||
// the "stop lying about which workflows would
|
||||
// run" failure mode the commit history fought.
|
||||
if !app.running && app.selected_tab == 0 {
|
||||
if !app.running && app.selected_tab == TAB_WORKFLOWS {
|
||||
app.cycle_diff_filter_event();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
if app.selected_tab == 2 && !app.log_search_query.is_empty() {
|
||||
if app.selected_tab == TAB_LOGS && !app.log_search_query.is_empty() {
|
||||
app.next_search_match();
|
||||
} else if app.selected_tab == 0 && !app.running {
|
||||
} else if app.selected_tab == TAB_WORKFLOWS && !app.running {
|
||||
// Deselect all workflows
|
||||
for workflow in &mut app.workflows {
|
||||
workflow.selected = false;
|
||||
@@ -449,30 +534,15 @@ fn run_tui_event_loop(
|
||||
}
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
let timestamp = Local::now().format("%H:%M:%S").to_string();
|
||||
app.logs.push(format!(
|
||||
"[{}] DEBUG: Reset key 'Shift+R' pressed",
|
||||
timestamp
|
||||
));
|
||||
wrkflw_logging::info("Reset key 'Shift+R' pressed");
|
||||
|
||||
if !app.running {
|
||||
// Reset workflow status
|
||||
app.logs.push(format!(
|
||||
"[{}] Attempting to reset workflow status...",
|
||||
timestamp
|
||||
));
|
||||
app.reset_workflow_status();
|
||||
|
||||
// Force redraw to update UI immediately
|
||||
terminal.draw(|f| {
|
||||
render_ui(f, app);
|
||||
})?;
|
||||
} else {
|
||||
app.logs.push(format!(
|
||||
"[{}] Cannot reset workflow while another operation is running",
|
||||
timestamp
|
||||
));
|
||||
app.add_timestamped_log(
|
||||
"Cannot reset workflow while another operation is running",
|
||||
);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
@@ -481,7 +551,7 @@ fn run_tui_event_loop(
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
// Only trigger workflow if not already running and we're in the workflows tab
|
||||
if !app.running && app.selected_tab == 0 {
|
||||
if !app.running && app.selected_tab == TAB_WORKFLOWS {
|
||||
if let Some(selected_idx) = app.workflow_list_state.selected() {
|
||||
if selected_idx < app.workflows.len() {
|
||||
let workflow = &app.workflows[selected_idx];
|
||||
@@ -551,33 +621,72 @@ fn run_tui_event_loop(
|
||||
wrkflw_logging::warning(
|
||||
"Cannot trigger workflow while another operation is in progress",
|
||||
);
|
||||
} else if app.selected_tab != 0 {
|
||||
} else if app.selected_tab != TAB_WORKFLOWS {
|
||||
app.logs
|
||||
.push("Switch to Workflows tab to trigger a workflow".to_string());
|
||||
wrkflw_logging::warning(
|
||||
"Switch to Workflows tab to trigger a workflow",
|
||||
);
|
||||
// For better UX, we could also automatically switch to the Workflows tab here
|
||||
app.switch_tab(0);
|
||||
app.switch_tab(TAB_WORKFLOWS);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
if app.selected_tab == 2 {
|
||||
if app.selected_tab == TAB_LOGS {
|
||||
app.toggle_log_search();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
if app.selected_tab == 2 {
|
||||
if app.selected_tab == TAB_LOGS {
|
||||
app.toggle_log_filter();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if app.selected_tab == 2 {
|
||||
if app.selected_tab == TAB_LOGS {
|
||||
app.clear_log_search_and_filter();
|
||||
} else if app.selected_tab == TAB_TRIGGER {
|
||||
// Trigger tab: copy curl preview into the
|
||||
// status bar so the user can scrape it from
|
||||
// their scrollback without shelling out of
|
||||
// the TUI. No clipboard integration yet —
|
||||
// terminals vary too widely; a log line is
|
||||
// honest.
|
||||
app.trigger_tab_copy_curl();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
if app.selected_tab == TAB_DAG {
|
||||
// Toggle DAG tab between graph and list view —
|
||||
// matches the design's `g` shortcut on
|
||||
// screen 4.
|
||||
app.dag_list_view = !app.dag_list_view;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if app.selected_tab == TAB_TRIGGER {
|
||||
// Trigger tab: flip github ↔ gitlab. Also
|
||||
// rebinds the branch default because each
|
||||
// platform has its own `get_repo_info`.
|
||||
app.trigger_tab_toggle_platform();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
if app.selected_tab == TAB_TRIGGER {
|
||||
app.trigger_tab_add_input();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// Trigger tab: focus the Branch / ref row so
|
||||
// the user can type a non-default branch. The
|
||||
// previous revision rendered the field as
|
||||
// editable but provided no keystroke that
|
||||
// reached it.
|
||||
if app.selected_tab == TAB_TRIGGER {
|
||||
app.trigger_tab_edit_branch();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if app.selected_tab == 2 && app.log_search_active {
|
||||
if app.selected_tab == TAB_LOGS && app.log_search_active {
|
||||
app.handle_log_search_input(KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
// Progress bar component
|
||||
use crate::theme::COLORS;
|
||||
use crate::theme::{self, COLORS};
|
||||
use ratatui::{
|
||||
style::{Color, Style},
|
||||
widgets::Gauge,
|
||||
@@ -18,7 +18,7 @@ impl ProgressBar {
|
||||
ProgressBar {
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
label: None,
|
||||
color: COLORS.accent,
|
||||
color: theme::current_accent(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,29 @@ use ratatui::{
|
||||
text::Span,
|
||||
widgets::{Block, BorderType, Borders},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
/// Per-render accent override. Populated by `set_accent_override`
|
||||
/// at the top of [`crate::views::render_ui`] and consulted by
|
||||
/// [`current_accent`] inside widget builders. Thread-local so each
|
||||
/// ratatui backend thread gets its own value; a `Cell<Option<_>>`
|
||||
/// because renders are single-frame and we never need interior
|
||||
/// sharing — just a scalar handoff.
|
||||
static ACCENT_OVERRIDE: Cell<Option<Color>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Install an accent color for the current frame. Pass `None` to
|
||||
/// fall back to [`COLORS.accent`].
|
||||
pub fn set_accent_override(color: Option<Color>) {
|
||||
ACCENT_OVERRIDE.with(|c| c.set(color));
|
||||
}
|
||||
|
||||
/// Read the active accent color. Falls back to the static palette
|
||||
/// value when no override is installed.
|
||||
pub fn current_accent() -> Color {
|
||||
ACCENT_OVERRIDE.with(|c| c.get()).unwrap_or(COLORS.accent)
|
||||
}
|
||||
|
||||
// ── Color Palette ──────────────────────────────────────────────────
|
||||
|
||||
@@ -85,16 +108,6 @@ pub fn title_style() -> Style {
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn brand_style() -> Style {
|
||||
Style::default()
|
||||
.fg(COLORS.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn label_style() -> Style {
|
||||
Style::default().fg(COLORS.accent)
|
||||
}
|
||||
|
||||
pub fn selected_style() -> Style {
|
||||
Style::default()
|
||||
.bg(COLORS.bg_selected)
|
||||
@@ -195,7 +208,7 @@ pub fn block_focused<'a>(title: &'a str) -> Block<'a> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(COLORS.border_focused))
|
||||
.border_style(Style::default().fg(current_accent()))
|
||||
.title(Span::styled(format!(" {} ", title), title_style()))
|
||||
}
|
||||
|
||||
@@ -226,7 +239,9 @@ impl BadgeKind {
|
||||
BadgeKind::Warning => COLORS.warning,
|
||||
BadgeKind::Trigger => COLORS.trigger,
|
||||
BadgeKind::Dim => COLORS.text_dim,
|
||||
BadgeKind::Accent => COLORS.accent,
|
||||
// Resolve dynamically so Tweaks accent changes recolor
|
||||
// `BadgeKind::Accent` call sites in the same frame.
|
||||
BadgeKind::Accent => current_accent(),
|
||||
BadgeKind::Highlight => COLORS.highlight,
|
||||
BadgeKind::Docker => COLORS.runtime_docker,
|
||||
BadgeKind::Podman => COLORS.runtime_podman,
|
||||
|
||||
449
crates/ui/src/views/dag_tab.rs
Normal file
449
crates/ui/src/views/dag_tab.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
// DAG full view — screen 4 from the design.
|
||||
//
|
||||
// Two modes behind a single tab (toggled with `g`):
|
||||
//
|
||||
// - Graph: jobs laid out in topological columns, each column prefixed
|
||||
// with a stage label. Edges are drawn on the left gutter of each
|
||||
// column so a user can see `needs:` at a glance without us having
|
||||
// to pretend we're an SVG canvas.
|
||||
// - List: topological stages as headers with jobs listed under each —
|
||||
// the same data as the design's `TopoList`. Denser, read-easier on
|
||||
// narrow terminals.
|
||||
//
|
||||
// The workflow shown is the one currently focused in the Workflows
|
||||
// tab. This deliberately mirrors the design (no workflow picker on
|
||||
// this screen) — the Workflows tab is the selector.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::components::dag::{self, NodeState};
|
||||
use crate::models::WorkflowStatus;
|
||||
use crate::theme::{self, BadgeKind, COLORS};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use wrkflw_executor::JobStatus;
|
||||
use wrkflw_parser::workflow::WorkflowDefinition;
|
||||
|
||||
pub fn render_dag_tab(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let Some(idx) = app.workflow_list_state.selected() else {
|
||||
render_empty_state(
|
||||
f,
|
||||
area,
|
||||
"No workflow selected — pick one on the Workflows tab.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(workflow) = app.workflows.get(idx) else {
|
||||
render_empty_state(f, area, "Workflow selection out of range.");
|
||||
return;
|
||||
};
|
||||
let Some(def) = workflow.definition.as_ref() else {
|
||||
render_empty_state(
|
||||
f,
|
||||
area,
|
||||
&format!("Couldn't parse {} — DAG unavailable.", workflow.name),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
render_header(f, app, workflow, outer[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(30)])
|
||||
.split(outer[1]);
|
||||
|
||||
if app.dag_list_view {
|
||||
render_topo_list(f, app, def, workflow, idx, body[0]);
|
||||
} else {
|
||||
render_graph(f, app, def, workflow, idx, body[0]);
|
||||
}
|
||||
render_legend(f, app, body[1]);
|
||||
}
|
||||
|
||||
fn render_empty_state(f: &mut Frame<'_>, area: Rect, msg: &str) {
|
||||
let block = theme::block("DAG");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(
|
||||
Paragraph::new(msg).style(Style::default().fg(COLORS.text_muted)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_header(f: &mut Frame<'_>, app: &App, workflow: &crate::models::Workflow, area: Rect) {
|
||||
let view_label = if app.dag_list_view { "list" } else { "graph" };
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
workflow.name.clone(),
|
||||
Style::default()
|
||||
.fg(COLORS.text)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" · dependency graph · ",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
theme::badge_outline(view_label, BadgeKind::Info),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"press `g` to toggle",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(spans)).alignment(Alignment::Left),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
/// State lookup for every named job — consults the current
|
||||
/// `WorkflowExecution` so a running nightly shows `build` as
|
||||
/// `Running`, mirroring the design's live DAG. `workflow_idx` is the
|
||||
/// position of this workflow in `app.workflows`; it's threaded from
|
||||
/// the caller so we don't have to reach for pointer identity here.
|
||||
fn state_for_job(
|
||||
app: &App,
|
||||
workflow: &crate::models::Workflow,
|
||||
workflow_idx: usize,
|
||||
name: &str,
|
||||
) -> NodeState {
|
||||
if !matches!(
|
||||
workflow.status,
|
||||
WorkflowStatus::Running | WorkflowStatus::Success | WorkflowStatus::Failed
|
||||
) {
|
||||
return NodeState::Pending;
|
||||
}
|
||||
let Some(exec) = workflow.execution_details.as_ref() else {
|
||||
return NodeState::Pending;
|
||||
};
|
||||
match exec.jobs.iter().find(|j| j.name == name) {
|
||||
Some(j) => match j.status {
|
||||
JobStatus::Success => NodeState::Success,
|
||||
JobStatus::Failure => NodeState::Failure,
|
||||
JobStatus::Skipped => NodeState::Skipped,
|
||||
},
|
||||
None => {
|
||||
if app.current_execution == Some(workflow_idx) {
|
||||
NodeState::Running
|
||||
} else {
|
||||
NodeState::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_graph(
|
||||
f: &mut Frame<'_>,
|
||||
app: &App,
|
||||
def: &WorkflowDefinition,
|
||||
workflow: &crate::models::Workflow,
|
||||
workflow_idx: usize,
|
||||
area: Rect,
|
||||
) {
|
||||
let block = theme::block_focused("DAG · topological columns");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let levels = dag::topo_levels(def);
|
||||
if levels.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new("no jobs").style(Style::default().fg(COLORS.text_muted)),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Node cards are a fixed 18 cells wide ("╭────────────────╮").
|
||||
// If we just split the area into `levels.len()` equal columns, each
|
||||
// column can shrink below 18 on narrow terminals and the box-drawing
|
||||
// characters wrap — which looks broken. Instead, clamp the visible
|
||||
// column count to what fits, and render a trailing "… +N more
|
||||
// stages" marker so the user knows they're seeing a subset.
|
||||
const NODE_CARD_W: u16 = 18;
|
||||
const OVERFLOW_W: u16 = 16; // width reserved for "… +N more" column
|
||||
let total_stages = levels.len();
|
||||
let max_visible = (inner.width / NODE_CARD_W).max(1) as usize;
|
||||
let (visible_stages, truncated) = if total_stages > max_visible {
|
||||
// Reserve space for the overflow column by dropping one more
|
||||
// stage; ensures the tail marker has somewhere to live.
|
||||
let capped = max_visible.saturating_sub(1).max(1);
|
||||
(capped, total_stages - capped)
|
||||
} else {
|
||||
(total_stages, 0)
|
||||
};
|
||||
|
||||
let mut constraints: Vec<Constraint> = (0..visible_stages)
|
||||
.map(|_| Constraint::Length(NODE_CARD_W))
|
||||
.collect();
|
||||
if truncated > 0 {
|
||||
constraints.push(Constraint::Length(OVERFLOW_W));
|
||||
}
|
||||
constraints.push(Constraint::Min(0)); // trailing slack
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(constraints)
|
||||
.split(inner);
|
||||
|
||||
// Column labels read straight off the topology ("Stage N"). The
|
||||
// design handoff uses semantic names like "build" / "test", but
|
||||
// no such grouping exists in GitHub Actions' workflow YAML today,
|
||||
// so putting a name here would mean *inventing* one.
|
||||
for (li, layer) in levels.iter().take(visible_stages).enumerate() {
|
||||
let col = cols[li];
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("Stage {}", li + 1),
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
lines.push(Line::from(""));
|
||||
for name in layer {
|
||||
let st = state_for_job(app, workflow, workflow_idx, name);
|
||||
let color = match st {
|
||||
NodeState::Success => COLORS.success,
|
||||
NodeState::Failure => COLORS.error,
|
||||
NodeState::Skipped => COLORS.warning,
|
||||
NodeState::Running => COLORS.info,
|
||||
NodeState::Pending => COLORS.text_muted,
|
||||
};
|
||||
let glyph = match st {
|
||||
NodeState::Success => theme::symbols::SUCCESS,
|
||||
NodeState::Failure => theme::symbols::FAILURE,
|
||||
NodeState::Skipped => theme::symbols::SKIPPED,
|
||||
NodeState::Running => theme::spinner(app.spinner_frame),
|
||||
NodeState::Pending => theme::symbols::NOT_STARTED,
|
||||
};
|
||||
// Node card: two-line "┌─ name ─┐" style in plain text.
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"╭────────────────╮",
|
||||
Style::default().fg(color),
|
||||
)]));
|
||||
// Distinct name from the outer `truncated` stage-count
|
||||
// binding so the shadow doesn't mislead a future reader.
|
||||
let short_name = truncate(name, 12);
|
||||
let padding = 12usize.saturating_sub(short_name.chars().count());
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("│ ", Style::default().fg(color)),
|
||||
Span::styled(glyph.to_string(), Style::default().fg(color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
short_name,
|
||||
Style::default()
|
||||
.fg(if matches!(st, NodeState::Running) {
|
||||
COLORS.text
|
||||
} else {
|
||||
color
|
||||
})
|
||||
.add_modifier(if matches!(st, NodeState::Running) {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
}),
|
||||
),
|
||||
Span::styled(" ".repeat(padding), Style::default().fg(color)),
|
||||
Span::styled(" │", Style::default().fg(color)),
|
||||
]));
|
||||
// Matrix badge for nodes that carry a strategy.matrix.
|
||||
let matrix_axes = def
|
||||
.jobs
|
||||
.get(name)
|
||||
.and_then(|j| j.matrix_config())
|
||||
.map(|m| m.parameters.len())
|
||||
.unwrap_or(0);
|
||||
if matrix_axes > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("│ ", Style::default().fg(color)),
|
||||
theme::badge_outline(format!("matrix×{}", matrix_axes), BadgeKind::Info),
|
||||
Span::styled(" │", Style::default().fg(color)),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"╰────────────────╯",
|
||||
Style::default().fg(color),
|
||||
)]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), col);
|
||||
}
|
||||
|
||||
if truncated > 0 {
|
||||
let overflow_col = cols[visible_stages];
|
||||
let mut overflow_lines: Vec<Line> = Vec::new();
|
||||
overflow_lines.push(Line::from(vec![Span::styled(
|
||||
format!("+{}", truncated),
|
||||
Style::default()
|
||||
.fg(COLORS.text_muted)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
overflow_lines.push(Line::from(Span::styled(
|
||||
"more stages",
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
)));
|
||||
overflow_lines.push(Line::from(""));
|
||||
overflow_lines.push(Line::from(Span::styled(
|
||||
"press `g`",
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
)));
|
||||
overflow_lines.push(Line::from(Span::styled(
|
||||
"for list view",
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
)));
|
||||
f.render_widget(
|
||||
Paragraph::new(overflow_lines).wrap(Wrap { trim: false }),
|
||||
overflow_col,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_topo_list(
|
||||
f: &mut Frame<'_>,
|
||||
app: &App,
|
||||
def: &WorkflowDefinition,
|
||||
workflow: &crate::models::Workflow,
|
||||
workflow_idx: usize,
|
||||
area: Rect,
|
||||
) {
|
||||
let block = theme::block_focused("Jobs · topological order");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let levels = dag::topo_levels(def);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for (li, layer) in levels.iter().enumerate() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" Stage {}", li + 1),
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
for name in layer {
|
||||
let st = state_for_job(app, workflow, workflow_idx, name);
|
||||
let (glyph, style) = match st {
|
||||
NodeState::Success => {
|
||||
(theme::symbols::SUCCESS, Style::default().fg(COLORS.success))
|
||||
}
|
||||
NodeState::Failure => (theme::symbols::FAILURE, Style::default().fg(COLORS.error)),
|
||||
NodeState::Skipped => {
|
||||
(theme::symbols::SKIPPED, Style::default().fg(COLORS.warning))
|
||||
}
|
||||
NodeState::Running => (
|
||||
theme::spinner(app.spinner_frame),
|
||||
Style::default().fg(COLORS.info),
|
||||
),
|
||||
NodeState::Pending => (
|
||||
theme::symbols::NOT_STARTED,
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
};
|
||||
let needs: String = def
|
||||
.jobs
|
||||
.get(name)
|
||||
.and_then(|j| j.needs.as_ref())
|
||||
.map(|n| {
|
||||
if n.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
n.join(", ")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let matrix_badge = def
|
||||
.jobs
|
||||
.get(name)
|
||||
.and_then(|j| j.matrix_config())
|
||||
.map(|m| format!(" matrix×{}", m.parameters.len()))
|
||||
.unwrap_or_default();
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(glyph.to_string(), style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
name.clone(),
|
||||
Style::default()
|
||||
.fg(COLORS.text)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(matrix_badge, Style::default().fg(COLORS.info)),
|
||||
Span::styled(" needs: ", Style::default().fg(COLORS.text_muted)),
|
||||
Span::styled(needs, Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn render_legend(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let block = theme::block("Legend");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(theme::symbols::SUCCESS, Style::default().fg(COLORS.success)),
|
||||
Span::raw(" success"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
theme::spinner(app.spinner_frame),
|
||||
Style::default().fg(COLORS.info),
|
||||
),
|
||||
Span::raw(" running"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
theme::symbols::NOT_STARTED,
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::raw(" pending"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(theme::symbols::SKIPPED, Style::default().fg(COLORS.warning)),
|
||||
Span::raw(" skipped"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(theme::symbols::FAILURE, Style::default().fg(COLORS.error)),
|
||||
Span::raw(" failed"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"SHORTCUTS",
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![
|
||||
theme::key_chip("g"),
|
||||
Span::raw(" "),
|
||||
Span::styled("toggle graph/list", Style::default().fg(COLORS.text_dim)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
theme::key_chip("enter"),
|
||||
Span::raw(" "),
|
||||
Span::styled("open Execution", Style::default().fg(COLORS.text_dim)),
|
||||
]),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ fn render_empty_state(f: &mut Frame<'_>, area: Rect) {
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Switch to ", Style::default().fg(COLORS.text_muted)),
|
||||
Span::styled("Workflows", Style::default().fg(COLORS.accent)),
|
||||
Span::styled("Workflows", Style::default().fg(theme::current_accent())),
|
||||
Span::styled(" and press ", Style::default().fg(COLORS.text_muted)),
|
||||
theme::key_chip("r"),
|
||||
Span::styled(" to run, or ", Style::default().fg(COLORS.text_muted)),
|
||||
|
||||
@@ -13,7 +13,7 @@ fn section_header<'a>(title: &'a str) -> Vec<Line<'a>> {
|
||||
Line::from(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(COLORS.accent)
|
||||
.fg(theme::current_accent())
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
@@ -48,9 +48,11 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize)
|
||||
left_lines.extend(section_header("NAVIGATION"));
|
||||
left_lines.push(Line::from(""));
|
||||
left_lines.push(key_line("Tab / Shift+Tab", "Switch between tabs"));
|
||||
left_lines.push(key_line("1-4 / w,x,l,h", "Jump to specific tab"));
|
||||
left_lines.push(key_line("1-7", "Jump to tab by number"));
|
||||
left_lines.push(key_line("w,x,l,h", "Workflows / Execution / Logs / Help"));
|
||||
left_lines.push(key_line("\u{2191}/\u{2193} or k/j", "Navigate lists"));
|
||||
left_lines.push(key_line("Enter", "Select / View details"));
|
||||
left_lines.push(key_line(",", "Toggle Tweaks overlay"));
|
||||
left_lines.push(key_line("Esc", "Back / Exit help"));
|
||||
left_lines.push(Line::from(""));
|
||||
|
||||
@@ -117,66 +119,53 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize)
|
||||
|
||||
right_lines.extend(section_header("LOGS & SEARCH"));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(key_line("s", "Toggle log search"));
|
||||
right_lines.push(key_line("f", "Toggle log filter"));
|
||||
right_lines.push(key_line("c", "Clear search & filter"));
|
||||
right_lines.push(key_line("s", "Toggle log search (Logs tab)"));
|
||||
right_lines.push(key_line("f", "Toggle log filter (Logs tab)"));
|
||||
right_lines.push(key_line("c", "Clear search & filter (Logs tab)"));
|
||||
right_lines.push(key_line("n", "Next search match"));
|
||||
right_lines.push(key_line("\u{2191}/\u{2193}", "Scroll logs / Navigate"));
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.extend(section_header("DAG · TRIGGER · SECRETS"));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(key_line("g", "DAG: toggle graph ↔ list"));
|
||||
right_lines.push(key_line("p", "Trigger: flip platform github ↔ gitlab"));
|
||||
right_lines.push(key_line("b", "Trigger: edit branch / ref"));
|
||||
right_lines.push(key_line("+", "Trigger: add a key=value input"));
|
||||
right_lines.push(key_line("Tab", "Trigger: next field (in edit mode)"));
|
||||
right_lines.push(key_line("Enter", "Trigger: dispatch (or commit edit)"));
|
||||
right_lines.push(key_line("c", "Trigger: copy curl preview to logs"));
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.extend(section_header("TAB OVERVIEW"));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"1. Workflows",
|
||||
Style::default()
|
||||
.fg(COLORS.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
for (idx, name, color, tag) in [
|
||||
(
|
||||
1u32,
|
||||
"Workflows",
|
||||
theme::current_accent(),
|
||||
"Browse & select workflows",
|
||||
),
|
||||
Span::styled(" \u{2500} Browse & select workflows", theme::dim_style()),
|
||||
]));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" View, select, and run workflows",
|
||||
theme::muted_style(),
|
||||
)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"2. Execution",
|
||||
Style::default()
|
||||
.fg(COLORS.success)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
(2, "Execution", COLORS.success, "Monitor job progress"),
|
||||
(3, "DAG", COLORS.info, "Dependency graph / topological list"),
|
||||
(4, "Logs", COLORS.info, "Execution logs · search · filter"),
|
||||
(
|
||||
5,
|
||||
"Trigger",
|
||||
COLORS.trigger,
|
||||
"Dispatch remote workflow_dispatch",
|
||||
),
|
||||
Span::styled(" \u{2500} Monitor job progress", theme::dim_style()),
|
||||
]));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Track jobs, steps, and output",
|
||||
theme::muted_style(),
|
||||
)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"3. Logs",
|
||||
Style::default()
|
||||
.fg(COLORS.info)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" \u{2500} View execution logs", theme::dim_style()),
|
||||
]));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Search, filter, real-time streaming",
|
||||
theme::muted_style(),
|
||||
)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"4. Help",
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" \u{2500} This guide", theme::dim_style()),
|
||||
]));
|
||||
(6, "Secrets", COLORS.warning, "Provider routing & runtime"),
|
||||
(7, "Help", COLORS.highlight, "This guide"),
|
||||
] {
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{}. {}", idx, name),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" \u{2500} {}", tag), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.extend(section_header("QUICK ACTIONS"));
|
||||
|
||||
@@ -125,7 +125,7 @@ fn render_tab_strip(f: &mut Frame<'_>, active: usize, area: Rect) {
|
||||
format!(" {} ", label),
|
||||
if is_active {
|
||||
Style::default()
|
||||
.fg(COLORS.accent)
|
||||
.fg(theme::current_accent())
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
} else {
|
||||
Style::default().fg(COLORS.text_dim)
|
||||
@@ -380,7 +380,10 @@ fn render_matrix_pane(
|
||||
None => vec![format!("{:?}", value)],
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {}: ", name), Style::default().fg(COLORS.accent)),
|
||||
Span::styled(
|
||||
format!(" {}: ", name),
|
||||
Style::default().fg(theme::current_accent()),
|
||||
),
|
||||
Span::styled(values.join(", "), Style::default().fg(COLORS.text)),
|
||||
]));
|
||||
}
|
||||
@@ -401,16 +404,94 @@ fn render_matrix_pane(
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(chips));
|
||||
}
|
||||
if !matrix.include.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("INCLUDE ({})", matrix.include.len()),
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
|
||||
// Matrix combinations — real expansion via `wrkflw_matrix::expand_matrix`.
|
||||
//
|
||||
// We can show the combos (the *what* — design screen 5's grid) but
|
||||
// not per-combo runtime status (the *how it went* — we don't track
|
||||
// status per combo, only aggregated job status). So rows are
|
||||
// labelled `queued` by default; if the parent job finished we
|
||||
// inherit its status for every row. This is honest: a future
|
||||
// executor change to surface per-combo results will drop right
|
||||
// into this render.
|
||||
match wrkflw_matrix::expand_matrix(matrix) {
|
||||
Ok(combos) if !combos.is_empty() => {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("COMBINATIONS ({})", combos.len()),
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
// Key order: show axes in the order they were declared,
|
||||
// plus any extra keys an `include:` entry introduced,
|
||||
// appended after (and sorted, since `MatrixCombination.values`
|
||||
// is a HashMap whose iteration order is not stable — without
|
||||
// a sort, include-only columns could jitter between frames
|
||||
// or process runs).
|
||||
let mut key_order: Vec<String> = matrix.parameters.keys().cloned().collect();
|
||||
let mut extra: Vec<String> = Vec::new();
|
||||
for c in &combos {
|
||||
for k in c.values.keys() {
|
||||
if !key_order.contains(k) && !extra.contains(k) {
|
||||
extra.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
extra.sort();
|
||||
key_order.extend(extra);
|
||||
for c in combos.iter().take(32) {
|
||||
let mut spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
let status_glyph = inherited_combo_glyph(workflow, job_name);
|
||||
spans.push(status_glyph);
|
||||
spans.push(Span::raw(" "));
|
||||
for (i, k) in key_order.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" ", Style::default().fg(COLORS.text_muted)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
format!("{}=", k),
|
||||
Style::default().fg(theme::current_accent()),
|
||||
));
|
||||
let v = c
|
||||
.values
|
||||
.get(k)
|
||||
.map(format_yaml_scalar)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
spans.push(Span::styled(v, Style::default().fg(COLORS.text)));
|
||||
}
|
||||
if c.is_included {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(theme::badge_outline("+include", BadgeKind::Warning));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
if combos.len() > 32 {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" … +{} more", combos.len() - 32),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"(matrix expanded to 0 combinations — check exclude: or empty axes)",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
)]));
|
||||
}
|
||||
Err(e) => {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
theme::badge_outline("expansion error", BadgeKind::Error),
|
||||
Span::raw(" "),
|
||||
Span::styled(e.to_string(), Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if !matrix.exclude.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("EXCLUDE ({})", matrix.exclude.len()),
|
||||
Style::default()
|
||||
@@ -419,7 +500,60 @@ fn render_matrix_pane(
|
||||
)]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner_area);
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner_area);
|
||||
}
|
||||
|
||||
fn format_yaml_scalar(v: &serde_yaml::Value) -> String {
|
||||
match v {
|
||||
serde_yaml::Value::String(s) => collapse_newlines(s),
|
||||
serde_yaml::Value::Bool(b) => b.to_string(),
|
||||
serde_yaml::Value::Number(n) => n.to_string(),
|
||||
serde_yaml::Value::Null => "~".to_string(),
|
||||
other => {
|
||||
// Sequences and maps round-trip to multi-line YAML; each
|
||||
// combo renders into a single ratatui Span, so embedded
|
||||
// newlines would silently garble the layout. Collapse
|
||||
// them into a visible ` · ` separator.
|
||||
let raw = serde_yaml::to_string(other).unwrap_or_default();
|
||||
collapse_newlines(raw.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace any `\n` / `\r` with a visible separator so multi-line
|
||||
/// payloads don't break single-Line rendering.
|
||||
fn collapse_newlines(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last_was_sep = false;
|
||||
for ch in s.chars() {
|
||||
if ch == '\n' || ch == '\r' {
|
||||
if !last_was_sep {
|
||||
out.push_str(" · ");
|
||||
last_was_sep = true;
|
||||
}
|
||||
} else {
|
||||
out.push(ch);
|
||||
last_was_sep = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return a small colored glyph indicating what we know about the
|
||||
/// parent matrix job's state — per-combo status isn't tracked, so
|
||||
/// every row mirrors the parent.
|
||||
fn inherited_combo_glyph<'a>(workflow: &'a crate::models::Workflow, job_name: &'a str) -> Span<'a> {
|
||||
let job = workflow
|
||||
.execution_details
|
||||
.as_ref()
|
||||
.and_then(|e| e.jobs.iter().find(|j| j.name == job_name));
|
||||
let (glyph, color) = match job.map(|j| &j.status) {
|
||||
Some(JobStatus::Success) => (theme::symbols::SUCCESS, COLORS.success),
|
||||
Some(JobStatus::Failure) => (theme::symbols::FAILURE, COLORS.error),
|
||||
Some(JobStatus::Skipped) => (theme::symbols::SKIPPED, COLORS.warning),
|
||||
None => (theme::symbols::NOT_STARTED, COLORS.text_muted),
|
||||
};
|
||||
Span::styled(glyph.to_string(), Style::default().fg(color))
|
||||
}
|
||||
|
||||
// ─── Timeline pane (uses timing component) ────────────────────────
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
// UI Views module
|
||||
mod dag_tab;
|
||||
mod execution_tab;
|
||||
mod help_overlay;
|
||||
mod job_detail;
|
||||
mod logs_tab;
|
||||
mod secrets_tab;
|
||||
mod status_bar;
|
||||
mod title_bar;
|
||||
mod trigger_tab;
|
||||
mod tweaks_overlay;
|
||||
mod workflows_tab;
|
||||
|
||||
pub use title_bar::{
|
||||
TAB_COUNT, TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER, TAB_WORKFLOWS,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use ratatui::Frame;
|
||||
|
||||
/// RAII guard that installs an accent override on construction and
|
||||
/// clears it on drop. Scoping the thread-local to one render pass
|
||||
/// stops later code (tests, alternate backends) from inheriting stale
|
||||
/// state — the thread-local is a handoff, not a setting.
|
||||
struct AccentScope;
|
||||
|
||||
impl AccentScope {
|
||||
fn install(color: ratatui::style::Color) -> Self {
|
||||
crate::theme::set_accent_override(Some(color));
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AccentScope {
|
||||
fn drop(&mut self) {
|
||||
crate::theme::set_accent_override(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Main render function for the UI
|
||||
pub fn render_ui(f: &mut Frame<'_>, app: &mut App) {
|
||||
// Plumb the Tweaks accent into the theme's thread-local so
|
||||
// anything that calls `theme::current_accent()` or uses
|
||||
// `block_focused` picks up the user's choice. The guard clears
|
||||
// the override on drop so the override lives for exactly this
|
||||
// frame.
|
||||
let (r, g, b) = app.tweaks_accent.rgb();
|
||||
let _accent = AccentScope::install(ratatui::style::Color::Rgb(r, g, b));
|
||||
|
||||
// Check if help should be shown as an overlay
|
||||
if app.show_help {
|
||||
help_overlay::render_help_overlay(f, app.help_scroll);
|
||||
@@ -38,19 +73,52 @@ pub fn render_ui(f: &mut Frame<'_>, app: &mut App) {
|
||||
|
||||
// Render main content based on selected tab
|
||||
match app.selected_tab {
|
||||
0 => workflows_tab::render_workflows_tab(f, app, main_chunks[1]),
|
||||
1 => {
|
||||
TAB_WORKFLOWS => workflows_tab::render_workflows_tab(f, app, main_chunks[1]),
|
||||
TAB_EXECUTION => {
|
||||
if app.detailed_view {
|
||||
job_detail::render_job_detail_view(f, app, main_chunks[1])
|
||||
} else {
|
||||
execution_tab::render_execution_tab(f, app, main_chunks[1])
|
||||
}
|
||||
}
|
||||
2 => logs_tab::render_logs_tab(f, app, main_chunks[1]),
|
||||
3 => help_overlay::render_help_content(f, main_chunks[1], app.help_scroll),
|
||||
TAB_DAG => dag_tab::render_dag_tab(f, app, main_chunks[1]),
|
||||
TAB_LOGS => logs_tab::render_logs_tab(f, app, main_chunks[1]),
|
||||
TAB_TRIGGER => trigger_tab::render_trigger_tab(f, app, main_chunks[1]),
|
||||
TAB_SECRETS => secrets_tab::render_secrets_tab(f, app, main_chunks[1]),
|
||||
TAB_HELP => help_overlay::render_help_content(f, main_chunks[1], app.help_scroll),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Render status bar
|
||||
status_bar::render_status_bar(f, app, main_chunks[2]);
|
||||
|
||||
// Tweaks overlay is rendered last so it sits above the main view
|
||||
// (matches the floating `TweaksPanel` in the design's bottom-right).
|
||||
if app.tweaks_open {
|
||||
tweaks_overlay::render_tweaks_overlay(f, app, size);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AccentScope;
|
||||
use crate::theme::{self, COLORS};
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn accent_scope_clears_thread_local_on_drop() {
|
||||
// Regression guard: the thread-local accent override is a
|
||||
// per-frame handoff, not a setting. Installing it and dropping
|
||||
// the guard must restore `current_accent()` to the static
|
||||
// palette so later code (tests, alternate backends, the next
|
||||
// frame) doesn't inherit stale state. Without this contract a
|
||||
// test that renders with a Tweaks accent installed could leak
|
||||
// the override into every subsequent test on the same thread.
|
||||
assert_eq!(theme::current_accent(), COLORS.accent);
|
||||
{
|
||||
let _guard = AccentScope::install(Color::Rgb(0xff, 0x00, 0x00));
|
||||
assert_eq!(theme::current_accent(), Color::Rgb(0xff, 0x00, 0x00));
|
||||
}
|
||||
assert_eq!(theme::current_accent(), COLORS.accent);
|
||||
}
|
||||
}
|
||||
|
||||
292
crates/ui/src/views/secrets_tab.rs
Normal file
292
crates/ui/src/views/secrets_tab.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
// Secrets & runtime — screen 7 from the design.
|
||||
//
|
||||
// Honesty note: we don't have a rich secrets metadata store (last-used
|
||||
// timestamps, length, scope etc. are not persisted anywhere). We also
|
||||
// don't read the user's real secrets config file yet — the tab shows
|
||||
// `SecretConfig::default()`, i.e. the two providers that are always
|
||||
// wired (env + file). The header badge therefore says "defaults" so
|
||||
// the user isn't misled into believing a customised config has been
|
||||
// loaded.
|
||||
//
|
||||
// The design included a "reveal 5s" cleartext toggle on individual
|
||||
// secret values. We don't render that: there are no per-secret values
|
||||
// at this layer to reveal, only provider-source descriptors
|
||||
// (a filesystem path or an env-var prefix). Masking a filename is
|
||||
// theatre — it doesn't protect anything and confuses the user about
|
||||
// what "masking" means here. When `SecretManager::list_known_keys()`
|
||||
// lands the reveal toggle can come back attached to actual values.
|
||||
//
|
||||
// A future PR can flesh this out — e.g. plumb through a real config
|
||||
// loader plus a key-list for the left pane — and this layout will
|
||||
// accommodate it without restructure.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::theme::{self, BadgeKind, COLORS};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use wrkflw_executor::RuntimeType;
|
||||
use wrkflw_secrets::{SecretConfig, SecretProviderConfig};
|
||||
|
||||
pub fn render_secrets_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
render_header(f, outer[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Min(0)])
|
||||
.split(outer[1]);
|
||||
|
||||
// One provider read per frame — the three panes share the same slice.
|
||||
let rows = provider_entries();
|
||||
render_providers_pane(f, app, &rows, body[0]);
|
||||
|
||||
let right = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Min(0)])
|
||||
.split(body[1]);
|
||||
render_detail_pane(f, app, &rows, right[0]);
|
||||
render_runtime_pane(f, app, &rows, right[1]);
|
||||
}
|
||||
|
||||
fn render_header(f: &mut Frame<'_>, area: Rect) {
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
"Secrets & runtime",
|
||||
Style::default()
|
||||
.fg(COLORS.text)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" · ", Style::default().fg(COLORS.text_muted)),
|
||||
theme::badge_outline("masking: on", BadgeKind::Success),
|
||||
Span::raw(" "),
|
||||
// "defaults" rather than "configured": the tab reads
|
||||
// `SecretConfig::default()` unconditionally — a custom
|
||||
// config file is not loaded yet. Labelling this "configured"
|
||||
// would be the exact kind of quiet UI lie PR #104 set out to
|
||||
// avoid.
|
||||
theme::badge_outline("default providers", BadgeKind::Info),
|
||||
];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(spans)).alignment(Alignment::Left),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn provider_entries() -> Vec<(String, SecretProviderConfig)> {
|
||||
let cfg = SecretConfig::default();
|
||||
let mut rows: Vec<(String, SecretProviderConfig)> = cfg.providers.into_iter().collect();
|
||||
// Deterministic order — HashMap iteration isn't stable.
|
||||
rows.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_providers_pane(
|
||||
f: &mut Frame<'_>,
|
||||
app: &mut App,
|
||||
rows: &[(String, SecretProviderConfig)],
|
||||
area: Rect,
|
||||
) {
|
||||
let block = theme::block_focused("Providers");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let items: Vec<ListItem> = rows
|
||||
.iter()
|
||||
.map(|(name, cfg)| {
|
||||
let kind = match cfg {
|
||||
SecretProviderConfig::Environment { prefix } => match prefix {
|
||||
Some(p) => format!("env (prefix: {})", p),
|
||||
None => "env".to_string(),
|
||||
},
|
||||
SecretProviderConfig::File { path } => format!("file → {}", path),
|
||||
};
|
||||
let spans = vec![
|
||||
Span::styled("◉ ", Style::default().fg(COLORS.warning)),
|
||||
Span::styled(
|
||||
name.clone(),
|
||||
Style::default()
|
||||
.fg(COLORS.text)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(kind, Style::default().fg(COLORS.text_dim)),
|
||||
];
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(COLORS.bg_selected)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(" ▸ ");
|
||||
|
||||
f.render_stateful_widget(list, inner, &mut app.secrets_list_state);
|
||||
}
|
||||
|
||||
fn render_detail_pane(
|
||||
f: &mut Frame<'_>,
|
||||
app: &App,
|
||||
rows: &[(String, SecretProviderConfig)],
|
||||
area: Rect,
|
||||
) {
|
||||
let sel = app.secrets_list_state.selected().unwrap_or(0);
|
||||
let (name, cfg) = match rows.get(sel) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let block = theme::block("Detail");
|
||||
f.render_widget(block, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let title = format!("Provider · {}", name);
|
||||
let block = theme::block(&title);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
// Source descriptor (file path or env-var prefix) — shown plainly.
|
||||
// These aren't secret values, so bullet-masking them would be
|
||||
// theatre (see module header). Real per-secret values will come
|
||||
// later and land in a dedicated row with an actual reveal toggle.
|
||||
let (source_label, source_value) = match cfg {
|
||||
SecretProviderConfig::Environment { prefix } => (
|
||||
"Source".to_string(),
|
||||
prefix
|
||||
.clone()
|
||||
.map(|p| format!("env vars matching {}*", p))
|
||||
.unwrap_or_else(|| "any env var".to_string()),
|
||||
),
|
||||
SecretProviderConfig::File { path } => ("Path".to_string(), path.clone()),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{}: ", source_label),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::styled(source_value, Style::default().fg(COLORS.text)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let kind_label = match cfg {
|
||||
SecretProviderConfig::Environment { .. } => "environment",
|
||||
SecretProviderConfig::File { .. } => "file",
|
||||
};
|
||||
lines.push(kv("Kind", kind_label));
|
||||
lines.push(kv("Masking", "applies to resolved values (not shown here)"));
|
||||
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn render_runtime_pane(
|
||||
f: &mut Frame<'_>,
|
||||
app: &App,
|
||||
rows: &[(String, SecretProviderConfig)],
|
||||
area: Rect,
|
||||
) {
|
||||
let block = theme::block("Runtime");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Pills row.
|
||||
let pill = |label: &str, kind: BadgeKind, active: bool| -> Vec<Span<'_>> {
|
||||
if active {
|
||||
vec![theme::badge_solid(label.to_string(), kind), Span::raw(" ")]
|
||||
} else {
|
||||
vec![
|
||||
theme::badge_outline(label.to_string(), kind),
|
||||
Span::raw(" "),
|
||||
]
|
||||
}
|
||||
};
|
||||
let mut pills: Vec<Span> = Vec::new();
|
||||
pills.extend(pill(
|
||||
"Docker",
|
||||
BadgeKind::Docker,
|
||||
matches!(app.runtime_type, RuntimeType::Docker),
|
||||
));
|
||||
pills.extend(pill(
|
||||
"Podman",
|
||||
BadgeKind::Podman,
|
||||
matches!(app.runtime_type, RuntimeType::Podman),
|
||||
));
|
||||
pills.extend(pill(
|
||||
"Emulation",
|
||||
BadgeKind::Emulation,
|
||||
matches!(app.runtime_type, RuntimeType::Emulation),
|
||||
));
|
||||
pills.extend(pill(
|
||||
"Secure-emu",
|
||||
BadgeKind::Secure,
|
||||
matches!(app.runtime_type, RuntimeType::SecureEmulation),
|
||||
));
|
||||
let mut lines: Vec<Line> = vec![Line::from(pills)];
|
||||
lines.push(Line::from(""));
|
||||
lines.push(kv("Active", app.runtime_type_name()));
|
||||
lines.push(kv(
|
||||
"Available",
|
||||
if app.runtime_available {
|
||||
"yes"
|
||||
} else {
|
||||
"no (will use emulation)"
|
||||
},
|
||||
));
|
||||
lines.push(kv(
|
||||
"Preserve on failure",
|
||||
if app.preserve_containers_on_failure {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
},
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"PROVIDERS · routing",
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
for (name, cfg) in rows {
|
||||
let right = match cfg {
|
||||
SecretProviderConfig::Environment { prefix } => prefix
|
||||
.clone()
|
||||
.map(|p| format!("{}*", p))
|
||||
.unwrap_or_else(|| "$*".to_string()),
|
||||
SecretProviderConfig::File { path } => path.clone(),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(name.clone(), Style::default().fg(theme::current_accent())),
|
||||
Span::raw(" → "),
|
||||
Span::styled(right, Style::default().fg(COLORS.text)),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
theme::key_chip("e"),
|
||||
Span::raw(" "),
|
||||
Span::styled("cycle runtime", Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn kv<'a>(key: &'a str, value: impl Into<String>) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", key),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::styled(value.into(), Style::default().fg(COLORS.text)),
|
||||
])
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Status bar — left-aligned key chips, right-aligned runtime + meta.
|
||||
use super::{TAB_DAG, TAB_EXECUTION, TAB_HELP, TAB_LOGS, TAB_SECRETS, TAB_TRIGGER, TAB_WORKFLOWS};
|
||||
use crate::app::App;
|
||||
use crate::models::StatusSeverity;
|
||||
use crate::theme::{self, BadgeKind, COLORS};
|
||||
@@ -94,7 +95,7 @@ pub fn render_status_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
|
||||
fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||
match app.selected_tab {
|
||||
0 => {
|
||||
TAB_WORKFLOWS => {
|
||||
if app.job_selection_mode {
|
||||
vec![
|
||||
("Enter", "run"),
|
||||
@@ -124,10 +125,10 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||
]
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
TAB_EXECUTION => {
|
||||
if app.detailed_view {
|
||||
vec![
|
||||
("Tab", "switch pane"),
|
||||
("Tab", "sub-tab"),
|
||||
("↑↓", "steps"),
|
||||
("Esc", "back"),
|
||||
("?", "help"),
|
||||
@@ -136,20 +137,43 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("j/k", "move"),
|
||||
("Enter", "inspect"),
|
||||
("/", "search"),
|
||||
("p", "pause"),
|
||||
("?", "help"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
}
|
||||
2 => vec![
|
||||
TAB_DAG => vec![
|
||||
("g", "graph/list"),
|
||||
("↑↓", "workflows"),
|
||||
(",", "tweaks"),
|
||||
("?", "help"),
|
||||
],
|
||||
TAB_LOGS => vec![
|
||||
("↑↓", "scroll"),
|
||||
("s", "search"),
|
||||
("f", "filter"),
|
||||
("c", "clear"),
|
||||
("n", "next match"),
|
||||
("?", "help"),
|
||||
("q", "quit"),
|
||||
],
|
||||
3 => vec![("↑↓", "scroll"), ("?", "close"), ("q", "quit")],
|
||||
TAB_TRIGGER => vec![
|
||||
("p", "platform"),
|
||||
("↑↓", "workflow"),
|
||||
("b", "edit branch"),
|
||||
("+", "add input"),
|
||||
("Tab", "next field"),
|
||||
("Enter", "dispatch"),
|
||||
("c", "copy curl"),
|
||||
("?", "help"),
|
||||
],
|
||||
TAB_SECRETS => vec![
|
||||
("↑↓", "provider"),
|
||||
("e", "runtime"),
|
||||
(",", "tweaks"),
|
||||
("?", "help"),
|
||||
],
|
||||
TAB_HELP => vec![("↑↓", "scroll"), ("?", "close"), ("q", "quit")],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,27 @@ use ratatui::{
|
||||
};
|
||||
use wrkflw_executor::RuntimeType;
|
||||
|
||||
const TAB_LABELS: [&str; 4] = ["Workflows", "Execution", "Logs", "Help"];
|
||||
pub const TAB_LABELS: [&str; 7] = [
|
||||
"Workflows",
|
||||
"Execution",
|
||||
"DAG",
|
||||
"Logs",
|
||||
"Trigger",
|
||||
"Secrets",
|
||||
"Help",
|
||||
];
|
||||
pub const TAB_COUNT: usize = TAB_LABELS.len();
|
||||
|
||||
// Canonical tab indices. Kept as `usize` constants rather than an enum
|
||||
// so they drop into the existing `selected_tab: usize` comparisons and
|
||||
// `switch_tab(usize)` calls without conversion.
|
||||
pub const TAB_WORKFLOWS: usize = 0;
|
||||
pub const TAB_EXECUTION: usize = 1;
|
||||
pub const TAB_DAG: usize = 2;
|
||||
pub const TAB_LOGS: usize = 3;
|
||||
pub const TAB_TRIGGER: usize = 4;
|
||||
pub const TAB_SECRETS: usize = 5;
|
||||
pub const TAB_HELP: usize = 6;
|
||||
|
||||
pub fn render_title_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
@@ -24,13 +44,12 @@ pub fn render_title_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
.split(area);
|
||||
|
||||
// ─── Brand ────────────────────────────────────────────────
|
||||
let accent = theme::current_accent();
|
||||
let brand = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" w∿w ", Style::default().fg(COLORS.accent)),
|
||||
Span::styled(" w∿w ", Style::default().fg(accent)),
|
||||
Span::styled(
|
||||
"wrkflw",
|
||||
Style::default()
|
||||
.fg(COLORS.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]))
|
||||
.style(Style::default().bg(COLORS.bg_dark))
|
||||
|
||||
332
crates/ui/src/views/trigger_tab.rs
Normal file
332
crates/ui/src/views/trigger_tab.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
// Remote trigger — screen 8 from the design.
|
||||
//
|
||||
// Two-pane layout:
|
||||
// - Left: target form (platform · repo · workflow · branch · token · inputs)
|
||||
// - Right: live curl-equivalent preview of the POST we'd send
|
||||
//
|
||||
// Backing features that already exist and this tab binds to:
|
||||
// - wrkflw_github::get_repo_info (git `origin` → owner/repo/default_branch)
|
||||
// - wrkflw_github::trigger_workflow (workflow_dispatch)
|
||||
// - wrkflw_gitlab::get_repo_info (same, GitLab flavour)
|
||||
// - wrkflw_gitlab::trigger_pipeline
|
||||
//
|
||||
// Repo info is resolved once per platform and cached on `App`
|
||||
// (`trigger_tab_target`) so we don't re-shell `git remote` on every
|
||||
// render. The cache is invalidated on platform toggle.
|
||||
|
||||
use crate::app::{App, TriggerPlatform};
|
||||
use crate::theme::{self, BadgeKind, COLORS};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render_trigger_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
render_header(f, app, outer[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Min(0)])
|
||||
.split(outer[1]);
|
||||
|
||||
render_target_pane(f, app, body[0]);
|
||||
render_preview_pane(f, app, body[1]);
|
||||
}
|
||||
|
||||
/// An env token we treat as "set". Empty string doesn't count — users
|
||||
/// occasionally `export GITHUB_TOKEN=` to clear the value without
|
||||
/// unsetting the var, and calling that "authenticated" would mislead.
|
||||
fn token_is_set(var: &str) -> bool {
|
||||
std::env::var(var).ok().is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn render_header(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let auth_state = match app.trigger_platform {
|
||||
TriggerPlatform::Github => {
|
||||
if token_is_set("GITHUB_TOKEN") {
|
||||
("authenticated", BadgeKind::Success)
|
||||
} else {
|
||||
("GITHUB_TOKEN missing", BadgeKind::Error)
|
||||
}
|
||||
}
|
||||
TriggerPlatform::Gitlab => {
|
||||
if token_is_set("GITLAB_TOKEN") {
|
||||
("authenticated", BadgeKind::Success)
|
||||
} else {
|
||||
("GITLAB_TOKEN missing", BadgeKind::Error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
"TRIGGER REMOTE",
|
||||
Style::default()
|
||||
.fg(COLORS.trigger)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" · dispatch workflow on {} · ",
|
||||
app.trigger_platform.as_str()
|
||||
),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
theme::badge_outline(auth_state.0, auth_state.1),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(header).alignment(Alignment::Left), area);
|
||||
}
|
||||
|
||||
fn render_target_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let block = theme::block_focused("Target");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Clone out of the cache so we don't hold an immutable borrow of
|
||||
// `app` while the rest of this function reads other fields. The
|
||||
// clone is a few small strings; cheap.
|
||||
let target = app.trigger_tab_target().clone();
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Platform row — pill group.
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"PLATFORM",
|
||||
Style::default()
|
||||
.fg(COLORS.text_muted)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
let mk_pill = |label: &str, kind: BadgeKind, active: bool| -> Span<'_> {
|
||||
if active {
|
||||
theme::badge_solid(label.to_string(), kind)
|
||||
} else {
|
||||
theme::badge_outline(label.to_string(), kind)
|
||||
}
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
mk_pill(
|
||||
"github",
|
||||
BadgeKind::Trigger,
|
||||
matches!(app.trigger_platform, TriggerPlatform::Github),
|
||||
),
|
||||
Span::raw(" "),
|
||||
mk_pill(
|
||||
"gitlab",
|
||||
BadgeKind::Warning,
|
||||
matches!(app.trigger_platform, TriggerPlatform::Gitlab),
|
||||
),
|
||||
Span::styled(
|
||||
" press `p` to toggle",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Target rows.
|
||||
lines.push(field_row("Platform", &target.platform_label));
|
||||
lines.push(field_row("Repository", &target.repo_label));
|
||||
let wf_label = app
|
||||
.trigger_selected_workflow_name()
|
||||
.unwrap_or("<no workflow — add one>");
|
||||
let wf_hint = format!(
|
||||
"{}/{}",
|
||||
app.trigger_workflow_idx + 1,
|
||||
app.workflows.len().max(1)
|
||||
);
|
||||
lines.push(field_row_hl("Workflow", wf_label, &wf_hint));
|
||||
let branch_display = if app.trigger_branch.is_empty() {
|
||||
if app.trigger_branch_focused {
|
||||
// Focused but no characters typed yet — show an empty
|
||||
// edit caret rather than the resolved default so the
|
||||
// user can see they're starting fresh.
|
||||
"_".to_string()
|
||||
} else {
|
||||
format!("(default: {})", target.default_branch)
|
||||
}
|
||||
} else {
|
||||
app.trigger_branch.clone()
|
||||
};
|
||||
if app.trigger_branch_focused {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", "Branch / ref"),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::styled(
|
||||
branch_display,
|
||||
Style::default()
|
||||
.fg(COLORS.bg_dark)
|
||||
.bg(theme::current_accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" (Enter/Esc to commit — Esc clears)",
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
),
|
||||
]));
|
||||
} else {
|
||||
lines.push(field_row("Branch / ref", &branch_display));
|
||||
}
|
||||
lines.push(field_row(
|
||||
"Token",
|
||||
match app.trigger_platform {
|
||||
TriggerPlatform::Github => "$GITHUB_TOKEN",
|
||||
TriggerPlatform::Gitlab => "$GITLAB_TOKEN",
|
||||
},
|
||||
));
|
||||
|
||||
if let Some(note) = target.note {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
theme::badge_outline("warn", BadgeKind::Warning),
|
||||
Span::raw(" "),
|
||||
Span::styled(note, Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"INPUTS",
|
||||
Style::default()
|
||||
.fg(COLORS.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
if app.trigger_inputs.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" (none) — press `+` to add a key=value input",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
)]));
|
||||
} else {
|
||||
for (i, (k, v)) in app.trigger_inputs.iter().enumerate() {
|
||||
let editing = app.trigger_input_cursor == Some(i);
|
||||
let k_focus = editing && !app.trigger_input_on_value;
|
||||
let v_focus = editing && app.trigger_input_on_value;
|
||||
let k_display = if k.is_empty() && !k_focus {
|
||||
"<key>".to_string()
|
||||
} else {
|
||||
k.clone()
|
||||
};
|
||||
let v_display = if v.is_empty() && !v_focus {
|
||||
"<value>".to_string()
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
let k_style = if k_focus {
|
||||
Style::default()
|
||||
.fg(COLORS.bg_dark)
|
||||
.bg(theme::current_accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::current_accent())
|
||||
};
|
||||
let v_style = if v_focus {
|
||||
Style::default()
|
||||
.fg(COLORS.bg_dark)
|
||||
.bg(theme::current_accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(COLORS.text)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(k_display, k_style),
|
||||
Span::styled(" = ", Style::default().fg(COLORS.text_muted)),
|
||||
Span::styled(v_display, v_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
theme::key_chip("p"),
|
||||
Span::raw(" "),
|
||||
Span::styled("platform", Style::default().fg(COLORS.text_dim)),
|
||||
Span::raw(" "),
|
||||
theme::key_chip("↑↓"),
|
||||
Span::raw(" "),
|
||||
Span::styled("workflow", Style::default().fg(COLORS.text_dim)),
|
||||
Span::raw(" "),
|
||||
theme::key_chip("b"),
|
||||
Span::raw(" "),
|
||||
Span::styled("edit branch", Style::default().fg(COLORS.text_dim)),
|
||||
Span::raw(" "),
|
||||
theme::key_chip("+"),
|
||||
Span::raw(" "),
|
||||
Span::styled("add input", Style::default().fg(COLORS.text_dim)),
|
||||
Span::raw(" "),
|
||||
theme::key_chip("tab"),
|
||||
Span::raw(" "),
|
||||
Span::styled("next field", Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
theme::key_chip("enter"),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"dispatch (or commit edit)",
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
),
|
||||
Span::raw(" "),
|
||||
theme::key_chip("c"),
|
||||
Span::raw(" "),
|
||||
Span::styled("copy curl → logs", Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn render_preview_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let block = theme::block("Preview · curl equivalent");
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Each flag lives on its own line (joined in `trigger_curl_preview`
|
||||
// with ` \\\n`). Splitting on `\n` gives us one ratatui Line per
|
||||
// flag so a narrow pane doesn't soft-wrap mid-header.
|
||||
let lines: Vec<Line> = app
|
||||
.trigger_curl_preview()
|
||||
.split('\n')
|
||||
.map(|s| {
|
||||
Line::from(Span::styled(
|
||||
s.to_string(),
|
||||
Style::default().fg(COLORS.text),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn field_row<'a>(label: &'a str, value: &'a str) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", label),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::styled(value.to_string(), Style::default().fg(COLORS.text)),
|
||||
])
|
||||
}
|
||||
|
||||
fn field_row_hl<'a>(label: &'a str, value: &'a str, hint: &str) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", label),
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
),
|
||||
Span::styled(
|
||||
value.to_string(),
|
||||
Style::default()
|
||||
.fg(COLORS.text)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" [{}]", hint),
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
),
|
||||
])
|
||||
}
|
||||
117
crates/ui/src/views/tweaks_overlay.rs
Normal file
117
crates/ui/src/views/tweaks_overlay.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
// Tweaks overlay — the design's floating `TweaksPanel`, ported to a
|
||||
// ratatui popup.
|
||||
//
|
||||
// We wire up the knobs that actually plumb through to the theme and
|
||||
// layouts. Anything we *can't* back up end-to-end (e.g. a full light
|
||||
// theme, which would need to re-table all the COLORS constants) is
|
||||
// omitted rather than rendered as a dead toggle — matches the rule
|
||||
// from PR #104: "A UI without backing data is worse than no UI."
|
||||
//
|
||||
// The key dispatch in `app/mod.rs` treats the overlay as modal:
|
||||
// while `tweaks_open` is true, unmatched keys are swallowed instead
|
||||
// of falling through to the global handler. The one exception is `q`,
|
||||
// which always quits — swallowing quit silently is a discoverability
|
||||
// trap, and quit is universally modal-safe in this TUI.
|
||||
//
|
||||
// Controls:
|
||||
// - `a` / `A` : cycle accent forwards (wraps)
|
||||
// - `esc` / `,` : close
|
||||
// - `q` : quit (same as anywhere else)
|
||||
|
||||
use crate::app::{Accent, App};
|
||||
use crate::theme::{self, COLORS};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn render_tweaks_overlay(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
// Anchor the panel to the bottom-right, sized like the design's
|
||||
// 260×auto card. We use absolute dimensions rather than a fraction
|
||||
// so the panel looks right on wide 4K terminals instead of growing
|
||||
// into a banner.
|
||||
let panel_w: u16 = 38;
|
||||
let panel_h: u16 = 8;
|
||||
let x = area.right().saturating_sub(panel_w + 2);
|
||||
let y = area.bottom().saturating_sub(panel_h + 2);
|
||||
let panel_rect = Rect {
|
||||
x,
|
||||
y,
|
||||
width: panel_w.min(area.width),
|
||||
height: panel_h.min(area.height),
|
||||
};
|
||||
|
||||
// Clear behind the panel so the underlying tab doesn't bleed
|
||||
// through.
|
||||
f.render_widget(Clear, panel_rect);
|
||||
|
||||
let block = theme::block_focused("Tweaks");
|
||||
let inner = block.inner(panel_rect);
|
||||
f.render_widget(block, panel_rect);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
render_accent_row(f, app, rows[0]);
|
||||
render_shortcut_hint(f, rows[1]);
|
||||
}
|
||||
|
||||
fn render_accent_row(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let swatch = |c: Accent, active: bool| -> Span<'_> {
|
||||
let (r, g, b) = c.rgb();
|
||||
let bg = Color::Rgb(r, g, b);
|
||||
let label = format!(" {} ", if active { "●" } else { " " });
|
||||
Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.bg(bg)
|
||||
.fg(COLORS.bg_dark)
|
||||
.add_modifier(if active {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut spans: Vec<Span> = vec![Span::styled(
|
||||
"accent ",
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
)];
|
||||
for c in [
|
||||
Accent::Cyan,
|
||||
Accent::Amber,
|
||||
Accent::Green,
|
||||
Accent::Violet,
|
||||
Accent::Coral,
|
||||
] {
|
||||
spans.push(swatch(c, app.tweaks_accent == c));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
app.tweaks_accent.as_str(),
|
||||
Style::default().fg(COLORS.text),
|
||||
));
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn render_shortcut_hint(f: &mut Frame<'_>, area: Rect) {
|
||||
let spans = vec![
|
||||
theme::key_chip("a"),
|
||||
Span::raw(" "),
|
||||
Span::styled("cycle accent", Style::default().fg(COLORS.text_dim)),
|
||||
Span::raw(" "),
|
||||
theme::key_chip(","),
|
||||
Span::raw(" "),
|
||||
Span::styled("close", Style::default().fg(COLORS.text_dim)),
|
||||
];
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
|
||||
if let Some(def) = wf.definition.as_ref() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("name: ", Style::default().fg(COLORS.accent)),
|
||||
Span::styled("name: ", Style::default().fg(theme::current_accent())),
|
||||
Span::styled(
|
||||
def.name.clone(),
|
||||
Style::default()
|
||||
@@ -170,11 +170,11 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
def.on.join(", ")
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("on: ", Style::default().fg(COLORS.accent)),
|
||||
Span::styled("on: ", Style::default().fg(theme::current_accent())),
|
||||
Span::styled(triggers, Style::default().fg(COLORS.text_dim)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("jobs: ", Style::default().fg(COLORS.accent)),
|
||||
Span::styled("jobs: ", Style::default().fg(theme::current_accent())),
|
||||
Span::styled(
|
||||
format!("{}", def.jobs.len()),
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
@@ -234,7 +234,7 @@ fn render_preview(f: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
Style::default().fg(COLORS.text_muted),
|
||||
)));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("jobs: ", Style::default().fg(COLORS.accent)),
|
||||
Span::styled("jobs: ", Style::default().fg(theme::current_accent())),
|
||||
Span::styled(
|
||||
format!("{}", wf.job_names.len()),
|
||||
Style::default().fg(COLORS.text_dim),
|
||||
|
||||
36
examples/ui-demo/01-dag-diamond.yml
Normal file
36
examples/ui-demo/01-dag-diamond.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: DAG — Diamond
|
||||
|
||||
# Classic diamond: one fan-out stage, one fan-in stage.
|
||||
# Exercises:
|
||||
# - DAG tab graph mode: two middle-column nodes sharing a parent + child.
|
||||
# - DAG tab list mode: three topological stages collapsed into rows.
|
||||
# - `needs:` edge rendering in the left gutter.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
checkout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "sources ready"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checkout]
|
||||
steps:
|
||||
- run: echo "linting"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checkout]
|
||||
steps:
|
||||
- run: echo "running unit tests"
|
||||
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- run: echo "packaging artifact"
|
||||
85
examples/ui-demo/02-dag-wide-fan.yml
Normal file
85
examples/ui-demo/02-dag-wide-fan.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
name: DAG — Wide fan-out / fan-in
|
||||
|
||||
# Realistic CI pipeline with several parallel jobs per stage. Good for
|
||||
# stress-testing the DAG tab's column layout (graph mode) and stage
|
||||
# labels (list mode). Also exercises the Workflows tab card with many
|
||||
# jobs summarised.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Stage 0 — single root.
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "installing toolchain"
|
||||
|
||||
# Stage 1 — four parallel checks off the root.
|
||||
lint-rust:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- run: cargo fmt --check
|
||||
|
||||
lint-yaml:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- run: echo "yamllint ."
|
||||
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- run: echo "cargo audit"
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- run: echo "cargo check --all-targets"
|
||||
|
||||
# Stage 2 — tests gated by stage 1.
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-rust, typecheck]
|
||||
steps:
|
||||
- run: echo "cargo test --lib"
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-rust, typecheck]
|
||||
steps:
|
||||
- run: echo "cargo test --test '*'"
|
||||
|
||||
# Stage 3 — build once tests pass.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit-tests, integration-tests, lint-yaml, audit]
|
||||
steps:
|
||||
- run: cargo build --release
|
||||
|
||||
# Stage 4 — fan-out publish.
|
||||
publish-crate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- run: echo "cargo publish --dry-run"
|
||||
|
||||
publish-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- run: echo "docker build + push"
|
||||
|
||||
# Stage 5 — fan-in release.
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-crate, publish-image]
|
||||
steps:
|
||||
- run: echo "cutting release"
|
||||
40
examples/ui-demo/03-dag-linear.yml
Normal file
40
examples/ui-demo/03-dag-linear.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: DAG — Linear chain
|
||||
|
||||
# Five-job linear chain. Renders as five sequential columns in graph
|
||||
# mode and five single-job stages in list mode — the two views should
|
||||
# read identically on this one, which is the quickest way to spot a
|
||||
# regression in either renderer.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
stage-a:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "a"
|
||||
|
||||
stage-b:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [stage-a]
|
||||
steps:
|
||||
- run: echo "b"
|
||||
|
||||
stage-c:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [stage-b]
|
||||
steps:
|
||||
- run: echo "c"
|
||||
|
||||
stage-d:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [stage-c]
|
||||
steps:
|
||||
- run: echo "d"
|
||||
|
||||
stage-e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [stage-d]
|
||||
steps:
|
||||
- run: echo "e"
|
||||
50
examples/ui-demo/04-trigger-dispatch.yml
Normal file
50
examples/ui-demo/04-trigger-dispatch.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Trigger — workflow_dispatch with inputs
|
||||
|
||||
# Primary target for the Trigger tab (screen 8). Renders with:
|
||||
# - A branch input that defaults to the repo's default branch.
|
||||
# - A key=value input grid seeded from the workflow's declared inputs.
|
||||
# - A live curl preview of the POST to the GitHub dispatches endpoint.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment"
|
||||
required: true
|
||||
default: "staging"
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
- canary
|
||||
version:
|
||||
description: "Version tag to deploy (e.g. v1.4.2)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Skip destructive steps"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
notes:
|
||||
description: "Release notes (markdown, optional)"
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "env=${{ inputs.environment }}"
|
||||
echo "version=${{ inputs.version }}"
|
||||
echo "dry_run=${{ inputs.dry_run }}"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dispatch]
|
||||
if: ${{ inputs.dry_run != true }}
|
||||
steps:
|
||||
- run: echo "deploying ${{ inputs.version }} to ${{ inputs.environment }}"
|
||||
61
examples/ui-demo/05-matrix-inspector.yml
Normal file
61
examples/ui-demo/05-matrix-inspector.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Matrix — Step Inspector
|
||||
|
||||
# Drives the Step Inspector's Matrix sub-tab. Uses include/exclude so
|
||||
# the expanded matrix is non-trivial (and so the exclude row disappears
|
||||
# from the rendered list — a good manual correctness check).
|
||||
#
|
||||
# Also has per-step env at workflow/job/step scope so the Env sub-tab
|
||||
# has three layers of inheritance to display.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
WORKFLOW_SCOPE: "visible everywhere"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }} · node ${{ matrix.node }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
JOB_SCOPE: "visible to all steps in this job"
|
||||
LOG_LEVEL: "debug" # overrides workflow-scope to test precedence
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
node: [18, 20, 22]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
node: 22
|
||||
experimental: true
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
node: 18
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: show scope layering
|
||||
env:
|
||||
STEP_SCOPE: "visible only to this step"
|
||||
run: |
|
||||
echo "workflow: $WORKFLOW_SCOPE"
|
||||
echo "job: $JOB_SCOPE"
|
||||
echo "step: $STEP_SCOPE"
|
||||
echo "log: $LOG_LEVEL"
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: run tests
|
||||
run: |
|
||||
echo "os=${{ matrix.os }} node=${{ matrix.node }}"
|
||||
if [ "${{ matrix.experimental }}" = "true" ]; then
|
||||
echo "experimental lane — failures are non-blocking"
|
||||
fi
|
||||
67
examples/ui-demo/06-secrets-runtime.yml
Normal file
67
examples/ui-demo/06-secrets-runtime.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Secrets & runtime
|
||||
|
||||
# Exercises the Secrets tab (screen 7) — the tab itself reads the
|
||||
# configured providers, not the workflow, but running this workflow
|
||||
# gives the Step Inspector realistic secret-using steps to show.
|
||||
#
|
||||
# Also uses `services:` + `container:` so the runtime pane in the
|
||||
# Secrets tab has a non-trivial shape to contrast against.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.82-slim
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
ports:
|
||||
- 5432:5432
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
REDIS_URL: redis://redis:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: authenticate with registry
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: |
|
||||
echo "REGISTRY_TOKEN length: ${#REGISTRY_TOKEN}"
|
||||
echo "API_KEY length: ${#API_KEY}"
|
||||
|
||||
- name: pull private image
|
||||
run: |
|
||||
echo "pulling internal.registry/wrkflw/base:latest"
|
||||
|
||||
- name: run migrations
|
||||
run: |
|
||||
echo "running db migrations against $DATABASE_URL"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
environment: production
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
steps:
|
||||
- name: deploy
|
||||
run: echo "ssh-ing to prod host"
|
||||
|
||||
- name: notify
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{"text":"deploy ok"}' "$SLACK_WEBHOOK"
|
||||
39
examples/ui-demo/07-multi-event.yml
Normal file
39
examples/ui-demo/07-multi-event.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Multi-event triggers
|
||||
|
||||
# Exercises the trigger-matching logic the UI uses for its diff filter
|
||||
# and event badges. Pair this workflow with the `d` key on the
|
||||
# Workflows tab to toggle event-based filtering, and watch the status
|
||||
# badge flip between "matches" / "skipped".
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "Cargo.toml"
|
||||
- "!**/*.md"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
# Every day at 03:30 UTC.
|
||||
- cron: "30 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "Why are you running this manually?"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: what triggered me
|
||||
run: |
|
||||
echo "event=${{ github.event_name }}"
|
||||
echo "ref=${{ github.ref }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "reason=${{ inputs.reason }}"
|
||||
fi
|
||||
61
examples/ui-demo/08-failing.yml
Normal file
61
examples/ui-demo/08-failing.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Status — mixed success / failure
|
||||
|
||||
# Used to validate status rendering across the UI: job cards on the
|
||||
# Workflows tab, coloured nodes on the DAG tab, and the summary badge
|
||||
# on the Execution tab.
|
||||
#
|
||||
# `continue-on-error` on the flaky lane ensures downstream jobs still
|
||||
# run so the DAG shows a mix of states in one run.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "ok"
|
||||
|
||||
flaky:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: coin flip
|
||||
run: |
|
||||
if [ $(( RANDOM % 2 )) -eq 0 ]; then
|
||||
echo "heads"
|
||||
else
|
||||
echo "tails"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
always-fails:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- run: |
|
||||
echo "this job exists to produce a failure state"
|
||||
exit 1
|
||||
|
||||
long-running:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup]
|
||||
steps:
|
||||
- name: sleep 15
|
||||
run: |
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "tick $i"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
downstream:
|
||||
runs-on: ubuntu-latest
|
||||
# Runs regardless of upstream outcomes so the DAG visibly shows a
|
||||
# green node downstream of a red one.
|
||||
needs: [flaky, always-fails, long-running]
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- run: echo "downstream observed upstream outcomes"
|
||||
Reference in New Issue
Block a user