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.
This commit is contained in:
bahdotsh
2026-04-21 20:41:12 +05:30
parent 14741aaef7
commit 09da60acf7
12 changed files with 524 additions and 235 deletions

View File

@@ -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, TAB_COUNT};
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},
@@ -223,7 +226,7 @@ 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 == 3 && app.log_search_active {
if app.selected_tab == TAB_LOGS && app.log_search_active {
app.handle_log_search_input(key.code);
continue;
}
@@ -251,13 +254,14 @@ fn run_tui_event_loop(
continue;
}
// Trigger tab: if a text input row is focused, route
// printable characters and edit keys straight to that
// input 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 == 4
&& app.trigger_input_cursor.is_some()
// 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;
@@ -303,9 +307,9 @@ 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 == 4 {
} else if app.selected_tab == TAB_TRIGGER {
// In the Trigger tab Tab cycles inputs/fields.
app.trigger_tab_next_field();
} else {
@@ -313,21 +317,21 @@ fn run_tui_event_loop(
}
}
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 == 4 {
} else if app.selected_tab == TAB_TRIGGER {
app.trigger_tab_prev_field();
} else {
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') => app.switch_tab(2),
KeyCode::Char('4') | KeyCode::Char('l') => app.switch_tab(3),
KeyCode::Char('5') => app.switch_tab(4),
KeyCode::Char('6') => app.switch_tab(5),
KeyCode::Char('7') | KeyCode::Char('h') => app.switch_tab(6),
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-
@@ -338,67 +342,70 @@ fn run_tui_event_loop(
app.tweaks_open = !app.tweaks_open;
}
KeyCode::Up | KeyCode::Char('k') => match app.selected_tab {
0 => {
TAB_WORKFLOWS => {
if app.job_selection_mode {
app.previous_available_job();
} else {
app.previous_workflow();
}
}
1 => {
TAB_EXECUTION => {
if app.detailed_view {
app.previous_step();
} else {
app.previous_job();
}
}
3 => {
TAB_LOGS => {
if !app.log_search_matches.is_empty() {
app.previous_search_match();
} else {
app.scroll_logs_up();
}
}
4 => app.trigger_tab_prev_workflow(),
5 => app.secrets_tab_prev(),
6 => app.scroll_help_up(),
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 {
0 => {
TAB_WORKFLOWS => {
if app.job_selection_mode {
app.next_available_job();
} else {
app.next_workflow();
}
}
1 => {
TAB_EXECUTION => {
if app.detailed_view {
app.next_step();
} else {
app.next_job();
}
}
3 => {
TAB_LOGS => {
if !app.log_search_matches.is_empty() {
app.next_search_match();
} else {
app.scroll_logs_down();
}
}
4 => app.trigger_tab_next_workflow(),
5 => app.secrets_tab_next(),
6 => app.scroll_help_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
@@ -419,11 +426,11 @@ fn run_tui_event_loop(
}
}
}
1 => {
TAB_EXECUTION => {
// In execution tab, Enter shows job details
app.toggle_detailed_view();
}
4 => {
TAB_TRIGGER => {
// Trigger tab: Enter on a non-editing row
// begins editing it (value first, then Tab
// to swap); when already editing, Enter
@@ -478,7 +485,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();
}
}
@@ -493,7 +503,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();
}
}
@@ -506,14 +516,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 == 3 && !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;
@@ -553,7 +563,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];
@@ -623,30 +633,30 @@ 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 == 3 {
if app.selected_tab == TAB_LOGS {
app.toggle_log_search();
}
}
KeyCode::Char('f') => {
if app.selected_tab == 3 {
if app.selected_tab == TAB_LOGS {
app.toggle_log_filter();
}
}
KeyCode::Char('c') => {
if app.selected_tab == 3 {
if app.selected_tab == TAB_LOGS {
app.clear_log_search_and_filter();
} else if app.selected_tab == 4 {
} 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
@@ -657,24 +667,15 @@ fn run_tui_event_loop(
}
}
KeyCode::Char('g') => {
if app.selected_tab == 2 {
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('m') => {
if app.selected_tab == 5 {
// Secrets tab: toggle the 5-second value
// reveal. (The design's "reveal 5s" button
// is a soft timer; here we leave it to the
// user to hit `m` again.)
app.secrets_reveal = !app.secrets_reveal;
}
}
KeyCode::Char('p') => {
if app.selected_tab == 4 {
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`.
@@ -682,12 +683,22 @@ fn run_tui_event_loop(
}
}
KeyCode::Char('+') | KeyCode::Char('=') => {
if app.selected_tab == 4 {
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 == 3 && app.log_search_active {
if app.selected_tab == TAB_LOGS && app.log_search_active {
app.handle_log_search_input(KeyCode::Char(c));
}
}

View File

@@ -116,8 +116,15 @@ pub struct App {
/// Cursor into `workflows` for the workflow-to-dispatch selector.
pub trigger_workflow_idx: usize,
/// The branch/ref input — owned by the app so typing doesn't lose
/// state between draws and so ESC reverts to the git-resolved default.
/// state between draws. Empty string means "use the resolved
/// default"; the curl preview and the dispatcher both honour that
/// fallback. Edited when `trigger_branch_focused` is set.
pub trigger_branch: String,
/// True while the Branch / ref row holds the edit focus. Mutually
/// exclusive with `trigger_input_cursor.is_some()` — the main key
/// handler drives them as a single "is something being edited?"
/// question via [`App::trigger_editing`].
pub trigger_branch_focused: bool,
/// Free-form `key=value` pairs to POST as `inputs:` (GitHub) or
/// `variables:` (GitLab). Flat Vec rather than HashMap so the UI
/// can show a deterministic cursor position and preserve user-typed
@@ -151,11 +158,6 @@ pub struct App {
// ── Secrets tab ───────────────────────────────────────────────
/// Selected row in the secrets list.
pub secrets_list_state: ListState,
/// When true, the detail pane shows the value in cleartext. Toggled
/// with `m`. Auto-reverts when switching away from the tab so a
/// user leaving the terminal doesn't accidentally leave a secret
/// exposed (see `switch_tab`).
pub secrets_reveal: bool,
// ── Tweaks overlay ────────────────────────────────────────────
/// When true, the Tweaks panel overlays the current tab. Toggled
@@ -501,6 +503,7 @@ impl App {
trigger_platform: TriggerPlatform::Github,
trigger_workflow_idx: 0,
trigger_branch: String::new(),
trigger_branch_focused: false,
trigger_inputs: Vec::new(),
trigger_input_cursor: None,
trigger_input_on_value: false,
@@ -514,7 +517,6 @@ impl App {
s.select(Some(0));
s
},
secrets_reveal: false,
tweaks_open: false,
tweaks_accent: Accent::default(),
@@ -1087,16 +1089,7 @@ impl App {
}
// Change the tab.
//
// Re-engages secret masking when leaving the Secrets tab so a user
// who hit `m` to reveal and then context-switched isn't left with a
// cleartext-open pane waiting for them on return. This mirrors the
// "reveal 5s" timer in the original design.
pub fn switch_tab(&mut self, tab: usize) {
const SECRETS_TAB: usize = 5;
if self.selected_tab == SECRETS_TAB && tab != SECRETS_TAB {
self.secrets_reveal = false;
}
self.selected_tab = tab;
}
@@ -1659,7 +1652,7 @@ impl App {
self.current_execution = Some(selected_idx);
// Switch to execution tab for better user feedback
self.selected_tab = 1; // Switch to Execution tab manually to avoid the borrowing issue
self.selected_tab = crate::views::TAB_EXECUTION; // avoid the borrowing issue from calling switch_tab()
// Create a thread instead of using tokio runtime directly since send() is not async
std::thread::spawn(move || {
@@ -1932,48 +1925,113 @@ impl App {
self.trigger_target_cache.as_ref().expect("just populated")
}
/// True when any trigger-tab text field (branch or an input row)
/// is holding the edit cursor. The main key router consults this
/// to decide whether to route keystrokes into the field rather
/// than the global key map.
pub fn trigger_editing(&self) -> bool {
self.trigger_branch_focused || self.trigger_input_cursor.is_some()
}
/// Append a blank `key=value` row and put the edit cursor on it.
pub fn trigger_tab_add_input(&mut self) {
self.trigger_inputs.push((String::new(), String::new()));
self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1);
self.trigger_input_on_value = false;
self.trigger_branch_focused = false;
}
/// Focus the Branch / ref row so the next keystrokes type into
/// `trigger_branch`. Clears any active input cursor — only one
/// field edits at a time.
pub fn trigger_tab_edit_branch(&mut self) {
self.trigger_branch_focused = true;
self.trigger_input_cursor = None;
self.trigger_input_on_value = false;
}
/// Field order: Branch → Input(0).key → Input(0).value →
/// Input(1).key → … → wrap back to Branch. Branch is always part
/// of the cycle so the user can reach it via Tab without needing
/// the `b` shortcut.
pub fn trigger_tab_next_field(&mut self) {
if self.trigger_inputs.is_empty() {
// Not focused yet → land on Branch (start of the cycle).
if !self.trigger_editing() {
self.trigger_branch_focused = true;
return;
}
if self.trigger_branch_focused {
// Branch → first input key (if any); else stay on Branch.
if self.trigger_inputs.is_empty() {
return;
}
self.trigger_branch_focused = false;
self.trigger_input_cursor = Some(0);
self.trigger_input_on_value = false;
return;
}
// Inside the input grid.
match self.trigger_input_cursor {
None => {
self.trigger_input_cursor = Some(0);
self.trigger_input_on_value = false;
// Defensive: trigger_editing said yes but cursor is
// None and branch is not focused. Recover to Branch.
self.trigger_branch_focused = true;
}
Some(_) if !self.trigger_input_on_value => {
self.trigger_input_on_value = true;
}
Some(i) => {
self.trigger_input_cursor = Some((i + 1) % self.trigger_inputs.len());
self.trigger_input_on_value = false;
let n = self.trigger_inputs.len();
if i + 1 < n {
self.trigger_input_cursor = Some(i + 1);
self.trigger_input_on_value = false;
} else {
// Past the last value: wrap back to Branch.
self.trigger_input_cursor = None;
self.trigger_input_on_value = false;
self.trigger_branch_focused = true;
}
}
}
}
pub fn trigger_tab_prev_field(&mut self) {
if self.trigger_inputs.is_empty() {
// Not focused yet → wrap to the end of the cycle.
if !self.trigger_editing() {
if self.trigger_inputs.is_empty() {
self.trigger_branch_focused = true;
} else {
self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1);
self.trigger_input_on_value = true;
}
return;
}
if self.trigger_branch_focused {
// Branch → last input value (if any); else stay on Branch.
if self.trigger_inputs.is_empty() {
return;
}
self.trigger_branch_focused = false;
self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1);
self.trigger_input_on_value = true;
return;
}
match self.trigger_input_cursor {
None => {
self.trigger_input_cursor = Some(self.trigger_inputs.len() - 1);
self.trigger_input_on_value = true;
self.trigger_branch_focused = true;
}
Some(_) if self.trigger_input_on_value => {
self.trigger_input_on_value = false;
}
Some(i) => {
let n = self.trigger_inputs.len();
self.trigger_input_cursor = Some((i + n - 1) % n);
self.trigger_input_on_value = true;
if i == 0 {
self.trigger_input_cursor = None;
self.trigger_input_on_value = false;
self.trigger_branch_focused = true;
} else {
self.trigger_input_cursor = Some(i - 1);
self.trigger_input_on_value = true;
}
}
}
}
@@ -1984,8 +2042,9 @@ impl App {
/// and let the user see the result in the Logs tab. Blocking the
/// UI thread on a `reqwest` POST would freeze the animation loop.
pub fn trigger_tab_enter(&mut self) {
if self.trigger_input_cursor.is_some() {
if self.trigger_editing() {
// Commit edit.
self.trigger_branch_focused = false;
self.trigger_input_cursor = None;
self.trigger_input_on_value = false;
return;
@@ -1993,10 +2052,34 @@ impl App {
self.trigger_dispatch();
}
/// Route a key event into the currently-edited trigger input.
/// Returns `true` if the key was consumed (so the caller should
/// skip the global key map); `false` otherwise.
/// Route a key event into whichever trigger-tab text field is
/// focused. Returns `true` if the key was consumed (so the caller
/// should skip the global key map); `false` otherwise.
pub fn trigger_handle_input_key(&mut self, code: KeyCode) -> bool {
// Branch field: write into `self.trigger_branch`.
if self.trigger_branch_focused {
return match code {
KeyCode::Char(c) => {
self.trigger_branch.push(c);
true
}
KeyCode::Backspace => {
self.trigger_branch.pop();
true
}
KeyCode::Esc => {
// Esc discards the branch override — back to the
// git-resolved default, same as the original spec.
self.trigger_branch.clear();
self.trigger_branch_focused = false;
true
}
KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => false,
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => true,
_ => false,
};
}
let Some(idx) = self.trigger_input_cursor else {
return false;
};
@@ -2063,6 +2146,10 @@ impl App {
} else {
self.trigger_branch.clone()
};
// Multi-line curl: each flag on its own line joined by ` \\\n`
// so a copy-paste into a terminal preserves the line
// continuations. The preview view splits on `\n` to render;
// the string itself is a valid shell heredoc-free one-liner.
match self.trigger_platform {
TriggerPlatform::Github => {
// The dispatcher posts to
@@ -2074,17 +2161,22 @@ impl App {
.and_then(|t| split_slug(&t.repo_label))
.unwrap_or(("<owner>".to_string(), "<repo>".to_string()));
let body = github_dispatches_body(&branch_raw, &self.trigger_inputs);
format!(
"curl -X POST -H \"Authorization: Bearer $GITHUB_TOKEN\" \
-H \"Accept: application/vnd.github+json\" \
-H \"Content-Type: application/json\" \
https://api.github.com/repos/{owner}/{repo}/actions/workflows/{wf}.yml/dispatches \
-d '{body}'",
owner = owner,
repo = repo,
wf = strip_yaml_suffix(wf),
body = escape_shell_single(&body),
)
let wf_stripped = strip_yaml_suffix(wf);
let escaped = escape_shell_single(&body);
[
"curl -X POST".to_string(),
" -H \"Authorization: Bearer $GITHUB_TOKEN\"".to_string(),
" -H \"Accept: application/vnd.github+json\"".to_string(),
" -H \"Content-Type: application/json\"".to_string(),
format!(
" https://api.github.com/repos/{owner}/{repo}/actions/workflows/{wf}.yml/dispatches",
owner = owner,
repo = repo,
wf = wf_stripped,
),
format!(" -d '{body}'", body = escaped),
]
.join(" \\\n")
}
TriggerPlatform::Gitlab => {
// The dispatcher posts to
@@ -2100,15 +2192,19 @@ impl App {
let enc_ns = urlencoding::encode(&ns);
let enc_proj = urlencoding::encode(&proj);
let body = gitlab_pipeline_body(&branch_raw, &self.trigger_inputs);
format!(
"curl -X POST -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" \
-H \"Content-Type: application/json\" \
https://gitlab.com/api/v4/projects/{enc_ns}%2F{enc_proj}/pipeline \
-d '{body}'",
enc_ns = enc_ns,
enc_proj = enc_proj,
body = escape_shell_single(&body),
)
let escaped = escape_shell_single(&body);
[
"curl -X POST".to_string(),
" -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\"".to_string(),
" -H \"Content-Type: application/json\"".to_string(),
format!(
" https://gitlab.com/api/v4/projects/{enc_ns}%2F{enc_proj}/pipeline",
enc_ns = enc_ns,
enc_proj = enc_proj,
),
format!(" -d '{body}'", body = escaped),
]
.join(" \\\n")
}
}
}
@@ -2199,12 +2295,46 @@ impl App {
});
}
/// Drain any completed dispatch outcomes and reflect them on the
/// status bar. Called by the event loop once per iteration. Uses
/// `try_recv` so it never blocks; on disconnect the iterator
/// yields `Err` and we stop polling.
/// Drain any completed dispatch outcomes. Each outcome is pushed
/// to `self.logs` so nothing is lost if two arrive in one tick,
/// and the status bar is updated with the most recent one (an
/// Err takes precedence over an Ok that preceded it in the same
/// drain — the user wants to know about the failure). Uses
/// `try_recv` so the call never blocks.
pub fn drain_trigger_outcomes(&mut self) {
let mut last: Option<DispatchOutcome> = None;
let mut drained = 0usize;
while let Ok(outcome) = self.trigger_outcome_rx.try_recv() {
// Log every outcome so the Logs tab is the durable
// record — the status bar can only show one line.
let line = match &outcome.result {
Ok(_) => format!(
"Dispatched {} on {}",
outcome.workflow,
outcome.platform.as_str()
),
Err(e) => format!(
"Dispatch {} on {} failed: {}",
outcome.workflow,
outcome.platform.as_str(),
e
),
};
self.logs.push(line);
// Prefer the most recent error over any later success,
// and the most recent outcome otherwise.
match (&last, &outcome.result) {
(Some(prev), Ok(_)) if prev.result.is_err() => {
// Keep the prior error on screen.
}
_ => last = Some(outcome),
}
drained += 1;
}
if drained > 0 {
self.trim_logs_to_cap();
}
if let Some(outcome) = last {
match outcome.result {
Ok(_) => self.set_info_message(format!(
"Dispatched {} on {}",
@@ -3291,30 +3421,48 @@ mod tests {
}
#[test]
fn trigger_tab_next_and_prev_field_are_no_ops_when_no_inputs() {
fn trigger_tab_next_field_lands_on_branch_when_no_inputs() {
let mut app = make_app();
assert!(app.trigger_inputs.is_empty());
assert!(!app.trigger_editing());
// With no inputs, Tab cycles through Branch only.
app.trigger_tab_next_field();
assert!(app.trigger_branch_focused);
assert!(app.trigger_input_cursor.is_none());
app.trigger_tab_next_field();
assert!(
app.trigger_branch_focused,
"stays on Branch when nothing else to cycle through"
);
// Prev from fresh state also lands on Branch when no inputs.
let mut app = make_app();
app.trigger_tab_prev_field();
assert!(app.trigger_branch_focused);
assert!(app.trigger_input_cursor.is_none());
}
#[test]
fn trigger_tab_next_field_walks_key_then_value_then_wraps() {
fn trigger_tab_next_field_walks_branch_then_inputs_and_wraps() {
let mut app = make_app();
app.trigger_inputs = vec![("a".into(), "1".into()), ("b".into(), "2".into())];
// From fresh state: None → row 0 key → row 0 value → row 1 key …
// From fresh state: None → Branch.
app.trigger_tab_next_field();
assert!(app.trigger_branch_focused);
assert!(app.trigger_input_cursor.is_none());
// Branch → row 0 key.
app.trigger_tab_next_field();
assert!(!app.trigger_branch_focused);
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(0), false)
);
// Row 0 key → row 0 value.
app.trigger_tab_next_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(0), true)
);
// Row 0 value → row 1 key.
app.trigger_tab_next_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
@@ -3325,19 +3473,18 @@ mod tests {
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(1), true)
);
// Wraps back to row 0 key.
// Past the last input value → wraps back to Branch.
app.trigger_tab_next_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(0), false)
);
assert!(app.trigger_branch_focused);
assert!(app.trigger_input_cursor.is_none());
}
#[test]
fn trigger_tab_prev_field_reverses_direction() {
let mut app = make_app();
app.trigger_inputs = vec![("a".into(), "1".into()), ("b".into(), "2".into())];
// From fresh state: None → row 1 value (backward wrap).
// From fresh state: None → row 1 value (backward wrap through
// the input grid; Branch sits before row 0 in forward order).
app.trigger_tab_prev_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
@@ -3353,6 +3500,78 @@ mod tests {
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(0), true)
);
app.trigger_tab_prev_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(0), false)
);
// Row 0 key → Branch (backward).
app.trigger_tab_prev_field();
assert!(app.trigger_branch_focused);
assert!(app.trigger_input_cursor.is_none());
// Branch → last input value (backward wrap).
app.trigger_tab_prev_field();
assert_eq!(
(app.trigger_input_cursor, app.trigger_input_on_value),
(Some(1), true)
);
}
#[test]
fn trigger_tab_edit_branch_writes_into_trigger_branch() {
// Regression: pre-fix, the "Branch / ref" row was rendered as
// editable but no keystroke path ever wrote into
// `self.trigger_branch`. The user was stuck on the resolved
// default forever.
let mut app = make_app();
assert!(app.trigger_branch.is_empty());
app.trigger_tab_edit_branch();
assert!(app.trigger_editing());
assert!(app.trigger_branch_focused);
for c in "release/1.0".chars() {
assert!(app.trigger_handle_input_key(KeyCode::Char(c)));
}
assert_eq!(app.trigger_branch, "release/1.0");
assert!(app.trigger_handle_input_key(KeyCode::Backspace));
assert_eq!(app.trigger_branch, "release/1.");
// Enter falls through so the global handler can commit the
// edit (see `trigger_tab_enter`).
assert!(!app.trigger_handle_input_key(KeyCode::Enter));
app.trigger_tab_enter();
assert!(!app.trigger_branch_focused);
assert_eq!(app.trigger_branch, "release/1.");
}
#[test]
fn trigger_tab_edit_branch_esc_clears_override() {
let mut app = make_app();
app.trigger_tab_edit_branch();
for c in "junk".chars() {
app.trigger_handle_input_key(KeyCode::Char(c));
}
assert_eq!(app.trigger_branch, "junk");
assert!(app.trigger_handle_input_key(KeyCode::Esc));
assert!(!app.trigger_branch_focused);
assert_eq!(
app.trigger_branch, "",
"Esc must revert to the resolved default"
);
}
#[test]
fn trigger_tab_edit_branch_and_add_input_are_mutually_exclusive() {
let mut app = make_app();
app.trigger_tab_edit_branch();
assert!(app.trigger_branch_focused);
// Adding an input takes focus away from the branch row so the
// two invariants ("only one field edits at a time") hold.
app.trigger_tab_add_input();
assert!(!app.trigger_branch_focused);
assert_eq!(app.trigger_input_cursor, Some(0));
// Going back the other way.
app.trigger_tab_edit_branch();
assert!(app.trigger_input_cursor.is_none());
assert!(app.trigger_branch_focused);
}
#[test]
@@ -3558,17 +3777,4 @@ mod tests {
}
}
}
#[test]
fn switch_tab_auto_reverts_secrets_reveal_when_leaving_secrets() {
let mut app = make_app();
app.selected_tab = 5;
app.secrets_reveal = true;
app.switch_tab(0);
assert!(!app.secrets_reveal, "leaving Secrets must re-mask");
// Moving *within* Secrets does not flip reveal.
app.secrets_reveal = true;
app.switch_tab(5);
assert!(app.secrets_reveal);
}
}

View File

@@ -63,9 +63,9 @@ pub fn render_dag_tab(f: &mut Frame<'_>, app: &App, area: Rect) {
.split(outer[1]);
if app.dag_list_view {
render_topo_list(f, app, def, workflow, body[0]);
render_topo_list(f, app, def, workflow, idx, body[0]);
} else {
render_graph(f, app, def, workflow, body[0]);
render_graph(f, app, def, workflow, idx, body[0]);
}
render_legend(f, app, body[1]);
}
@@ -108,8 +108,15 @@ fn render_header(f: &mut Frame<'_>, app: &App, workflow: &crate::models::Workflo
/// State lookup for every named job — consults the current
/// `WorkflowExecution` so a running nightly shows `build` as
/// `Running`, mirroring the design's live DAG.
fn state_for_job(app: &App, workflow: &crate::models::Workflow, name: &str) -> NodeState {
/// `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
@@ -126,14 +133,7 @@ fn state_for_job(app: &App, workflow: &crate::models::Workflow, name: &str) -> N
JobStatus::Skipped => NodeState::Skipped,
},
None => {
if app.current_execution
== Some(
app.workflows
.iter()
.position(|w| std::ptr::eq(w, workflow))
.unwrap_or(usize::MAX),
)
{
if app.current_execution == Some(workflow_idx) {
NodeState::Running
} else {
NodeState::Pending
@@ -147,6 +147,7 @@ fn render_graph(
app: &App,
def: &WorkflowDefinition,
workflow: &crate::models::Workflow,
workflow_idx: usize,
area: Rect,
) {
let block = theme::block_focused("DAG · topological columns");
@@ -209,7 +210,7 @@ fn render_graph(
)]));
lines.push(Line::from(""));
for name in layer {
let st = state_for_job(app, workflow, name);
let st = state_for_job(app, workflow, workflow_idx, name);
let color = match st {
NodeState::Success => COLORS.success,
NodeState::Failure => COLORS.error,
@@ -229,12 +230,14 @@ fn render_graph(
"╭────────────────╮",
Style::default().fg(color),
)]));
let truncated = truncate(name, 12);
let padding = 12usize.saturating_sub(truncated.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(
truncate(name, 12),
truncated,
Style::default()
.fg(if matches!(st, NodeState::Running) {
COLORS.text
@@ -247,10 +250,7 @@ fn render_graph(
Modifier::empty()
}),
),
Span::styled(
" ".repeat(12usize.saturating_sub(truncate(name, 12).chars().count())),
Style::default().fg(color),
),
Span::styled(" ".repeat(padding), Style::default().fg(color)),
Span::styled("", Style::default().fg(color)),
]));
// Matrix badge for nodes that carry a strategy.matrix.
@@ -309,6 +309,7 @@ fn render_topo_list(
app: &App,
def: &WorkflowDefinition,
workflow: &crate::models::Workflow,
workflow_idx: usize,
area: Rect,
) {
let block = theme::block_focused("Jobs · topological order");
@@ -326,7 +327,7 @@ fn render_topo_list(
.add_modifier(Modifier::BOLD),
)]));
for name in layer {
let st = state_for_job(app, workflow, name);
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))

View File

@@ -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)),

View File

@@ -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(
@@ -130,11 +130,11 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize)
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(key_line("m", "Secrets: reveal / mask value"));
right_lines.push(Line::from(""));
right_lines.extend(section_header("TAB OVERVIEW"));
@@ -143,7 +143,7 @@ pub fn render_help_content(f: &mut Frame<'_>, area: Rect, scroll_offset: usize)
(
1u32,
"Workflows",
COLORS.accent,
theme::current_accent(),
"Browse & select workflows",
),
(2, "Execution", COLORS.success, "Monitor job progress"),

View File

@@ -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)),
]));
}
@@ -448,7 +451,7 @@ fn render_matrix_pane(
}
spans.push(Span::styled(
format!("{}=", k),
Style::default().fg(COLORS.accent),
Style::default().fg(theme::current_accent()),
));
let v = c
.values
@@ -502,17 +505,40 @@ fn render_matrix_pane(
fn format_yaml_scalar(v: &serde_yaml::Value) -> String {
match v {
serde_yaml::Value::String(s) => s.clone(),
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 => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.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.

View File

@@ -11,7 +11,9 @@ mod trigger_tab;
mod tweaks_overlay;
mod workflows_tab;
pub use title_bar::TAB_COUNT;
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;
@@ -71,20 +73,19 @@ 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 => dag_tab::render_dag_tab(f, app, main_chunks[1]),
3 => logs_tab::render_logs_tab(f, app, main_chunks[1]),
4 => trigger_tab::render_trigger_tab(f, app, main_chunks[1]),
// (note: 4 takes &mut app; see render_trigger_tab for why)
5 => secrets_tab::render_secrets_tab(f, app, main_chunks[1]),
6 => 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),
_ => {}
}

View File

@@ -8,9 +8,17 @@
// 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 `SecretManager::list_known_keys()` for the left pane —
// and this layout will accommodate it without restructure.
// 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};
@@ -37,14 +45,16 @@ pub fn render_secrets_tab(f: &mut Frame<'_>, app: &mut App, area: Rect) {
.constraints([Constraint::Percentage(55), Constraint::Min(0)])
.split(outer[1]);
render_providers_pane(f, app, body[0]);
// 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, right[0]);
render_runtime_pane(f, app, right[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) {
@@ -79,13 +89,16 @@ fn provider_entries() -> Vec<(String, SecretProviderConfig)> {
rows
}
fn render_providers_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
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 rows = provider_entries();
let items: Vec<ListItem> = rows
.iter()
.map(|(name, cfg)| {
@@ -122,8 +135,12 @@ fn render_providers_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
f.render_stateful_widget(list, inner, &mut app.secrets_list_state);
}
fn render_detail_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
let rows = provider_entries();
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,
@@ -140,9 +157,10 @@ fn render_detail_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
// Value box — we don't have per-secret values at this level, so
// the "value" is the provider's source descriptor, optionally
// masked (matches the design's cleartext↔mask toggle).
// 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(),
@@ -153,25 +171,12 @@ fn render_detail_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
),
SecretProviderConfig::File { path } => ("Path".to_string(), path.clone()),
};
let value_render = if app.secrets_reveal {
source_value.clone()
} else {
"".repeat(source_value.chars().count().min(32))
};
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", source_label),
Style::default().fg(COLORS.text_muted),
),
Span::styled(
value_render,
Style::default().fg(if app.secrets_reveal {
COLORS.error
} else {
COLORS.warning
}),
),
Span::styled(source_value, Style::default().fg(COLORS.text)),
]));
lines.push(Line::from(""));
@@ -180,20 +185,17 @@ fn render_detail_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
SecretProviderConfig::File { .. } => "file",
};
lines.push(kv("Kind", kind_label));
lines.push(kv("Masking", "enabled"));
lines.push(kv(
"Reveal",
if app.secrets_reveal {
"on (press m to hide)"
} else {
"off (press m to reveal)"
},
));
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, area: Rect) {
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);
@@ -256,25 +258,22 @@ fn render_runtime_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
.fg(COLORS.highlight)
.add_modifier(Modifier::BOLD),
)]));
for (name, cfg) in provider_entries() {
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,
SecretProviderConfig::File { path } => path.clone(),
};
lines.push(Line::from(vec![
Span::styled(name, Style::default().fg(COLORS.accent)),
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("m"),
Span::raw(" "),
Span::styled("toggle mask", Style::default().fg(COLORS.text_dim)),
Span::raw(" "),
theme::key_chip("e"),
Span::raw(" "),
Span::styled("cycle runtime", Style::default().fg(COLORS.text_dim)),

View File

@@ -164,6 +164,7 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
4 => vec![
("p", "platform"),
("↑↓", "workflow"),
("b", "edit branch"),
("+", "add input"),
("Tab", "next field"),
("Enter", "dispatch"),
@@ -173,7 +174,6 @@ fn context_hints(app: &App) -> Vec<(&'static str, &'static str)> {
// 5 — Secrets
5 => vec![
("↑↓", "provider"),
("m", "reveal/mask"),
("e", "runtime"),
(",", "tweaks"),
("?", "help"),

View File

@@ -22,6 +22,17 @@ pub const TAB_LABELS: [&str; 7] = [
];
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()
.direction(Direction::Horizontal)

View File

@@ -142,11 +142,38 @@ fn render_target_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
);
lines.push(field_row_hl("Workflow", wf_label, &wf_hint));
let branch_display = if app.trigger_branch.is_empty() {
format!("(default: {})", target.default_branch)
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()
};
lines.push(field_row("Branch / ref", &branch_display));
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 {
@@ -194,15 +221,15 @@ fn render_target_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
let k_style = if k_focus {
Style::default()
.fg(COLORS.bg_dark)
.bg(COLORS.accent)
.bg(theme::current_accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(COLORS.accent)
Style::default().fg(theme::current_accent())
};
let v_style = if v_focus {
Style::default()
.fg(COLORS.bg_dark)
.bg(COLORS.accent)
.bg(theme::current_accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(COLORS.text)
@@ -226,6 +253,10 @@ fn render_target_pane(f: &mut Frame<'_>, app: &mut App, area: Rect) {
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)),
@@ -255,12 +286,15 @@ fn render_preview_pane(f: &mut Frame<'_>, app: &App, area: Rect) {
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(" \\")
.split('\n')
.map(|s| {
Line::from(Span::styled(
s.trim().to_string(),
s.to_string(),
Style::default().fg(COLORS.text),
))
})

View File

@@ -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),