mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user