use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
symbols,
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use super::{App, Mode, ResponseKind};
use super::tree::{Entry, EntryKind, VisibleRow, visible_rows};
pub(super) fn draw(frame: &mut Frame, app: &mut App) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(outer[0]);
let filtered = app.filtered_indices();
let tree_rows_storage: Option<Vec<VisibleRow>> = if app.using_tree() {
Some(visible_rows(&app.tree, &app.collapsed_folders))
} else {
None
};
let tree_rows = tree_rows_storage.as_deref();
draw_list(frame, panes[0], &app.entries, tree_rows, &filtered, &mut app.list_state, &app.filter, app.filter_active, app.use_nerd_fonts);
let mut response_view_height = None;
match &app.mode {
Mode::Browse => {
let selected = app.selected_entry_index().map(|i| &app.entries[i]);
draw_detail_browse(frame, panes[1], selected);
}
Mode::ProfileSelect { profiles, selected, entry_name, .. } => {
draw_profile_select(frame, panes[1], profiles, *selected, entry_name);
}
Mode::VarInput { vars, focused, entry_name, .. } => {
draw_var_input(frame, panes[1], vars, *focused, entry_name);
}
Mode::TestInput { vars, focused, iterations, entry_name } => {
draw_test_input(frame, panes[1], vars, iterations, *focused, entry_name);
}
Mode::Response { kind, body, scroll , response_filter, response_filter_active} => {
response_view_height = Some(draw_response(frame, panes[1], kind, body, *scroll, response_filter, *response_filter_active));
}
Mode::TestResponse { results } => {
draw_test_results(frame, panes[1], results);
}
Mode::NewRequest { draft, error } => {
draw_new_request(frame, panes[1], &draft.fields, draft.focused, &draft.profiles, draft.original_name.is_some(), draft.global, error.as_deref());
}
Mode::ImportCurl { buffer, error } => {
draw_import_curl(frame, panes[1], buffer, error.as_deref());
}
Mode::NewProfile { name, params, focused, error, .. } => {
draw_new_profile(frame, panes[1], name, params, *focused, error.as_deref());
}
Mode::ConfirmDelete { entry_name, ..} => {
draw_confirm_delete(frame, panes[1], entry_name);
}
}
if let Some(h) = response_view_height {
app.response_view_height = h;
}
draw_help(frame, outer[1], &app.mode);
}
#[allow(clippy::too_many_arguments)]
fn draw_list(
frame: &mut Frame,
area: Rect,
entries: &[Entry],
tree_rows: Option<&[VisibleRow]>,
filtered: &[usize],
list_state: &mut ListState,
filter: &str,
filter_active: bool,
use_nerd_fonts: bool,
) {
let show_filter = filter_active || !filter.is_empty();
let chunks = if show_filter {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(0), Constraint::Min(0)])
.split(area)
};
if show_filter {
let display = if filter_active { format!("{}█", filter) } else { filter.to_string() };
let filter_widget = Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(display, Style::default().fg(Color::White)),
]))
.block(
Block::default()
.title(" Filter ")
.borders(Borders::ALL)
.border_style(Style::default().fg(if filter_active { Color::Yellow } else { Color::DarkGray })),
);
frame.render_widget(filter_widget, chunks[0]);
}
let (items, highlight_sym): (Vec<ListItem>, &str) = if let Some(rows) = tree_rows {
let items = rows.iter().map(|row| match row {
VisibleRow::Folder { path, expanded, prefix, .. } => {
make_tree_folder_item(path, *expanded, prefix, use_nerd_fonts)
}
VisibleRow::Leaf { entry_idx, prefix, .. } => {
make_tree_leaf_item(&entries[*entry_idx], prefix, use_nerd_fonts)
}
}).collect();
(items, "▶ ")
} else {
let items = filtered.iter().map(|&i| make_list_item(&entries[i])).collect();
(items, "▶ ")
};
let list = List::new(items)
.block(
Block::default()
.title(" Requests ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
)
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(highlight_sym);
frame.render_stateful_widget(list, chunks[1], list_state);
}
fn draw_detail_browse(frame: &mut Frame, area: Rect, selected: Option<&Entry>) {
let lines = selected
.map(make_detail_lines)
.unwrap_or_else(|| vec![Line::from(" No request selected.")]);
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(" Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(paragraph, area);
}
fn draw_profile_select(
frame: &mut Frame,
area: Rect,
profiles: &[crate::config::Profile],
selected: usize,
entry_name: &str,
) {
let title = format!(" Profile — {} ", entry_name);
let mut lines: Vec<Line<'static>> = vec![Line::raw("")];
let is_none_selected = selected == 0;
let none_style = if is_none_selected {
Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
lines.push(Line::from(Span::styled(" (no profile)", none_style)));
lines.push(Line::raw(""));
for (i, profile) in profiles.iter().enumerate() {
let is_selected = selected == i + 1;
let name_style = if is_selected {
Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(
format!(" {}", profile.name),
name_style,
)));
for (k, v) in &profile.params {
lines.push(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(Color::DarkGray)),
Span::styled(v.clone(), Style::default().fg(Color::DarkGray)),
]));
}
lines.push(Line::raw(""));
}
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
);
frame.render_widget(paragraph, area);
}
#[allow(clippy::too_many_arguments)]
fn draw_new_request(
frame: &mut Frame,
area: Rect,
fields: &[(String, String)],
focused: usize,
profiles: &[crate::config::Profile],
is_edit: bool,
global: bool,
error: Option<&str>,
) {
let mut lines: Vec<Line<'static>> = vec![Line::raw("")];
for (i, (label, value)) in fields.iter().enumerate() {
let is_focused = i == focused;
let is_required = i < 3;
let mut label_spans = vec![Span::styled(
format!(" {}", label),
Style::default()
.fg(if is_focused { Color::Green } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)];
if is_required {
label_spans.push(Span::styled(" *", Style::default().fg(Color::Red)));
}
lines.push(Line::from(label_spans));
let prompt = Span::styled(" > ", Style::default().fg(Color::DarkGray));
let display = if is_focused { format!("{}█", value) } else { value.clone() };
let input = Span::styled(
display,
Style::default().fg(if is_focused { Color::White } else { Color::DarkGray }),
);
lines.push(Line::from(vec![prompt, input]));
lines.push(Line::raw(""));
}
let (global_check, global_color) = if global {
("[x] global", Color::Cyan)
} else {
("[ ] global", Color::DarkGray)
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(global_check, Style::default().fg(global_color).add_modifier(Modifier::BOLD)),
Span::styled(" Ctrl+g to toggle", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::raw(""));
if !profiles.is_empty() {
lines.push(Line::from(Span::styled(
" profiles",
Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD),
)));
for profile in profiles {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(profile.name.clone(), Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
]));
for (k, v) in &profile.params {
lines.push(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(Color::DarkGray)),
Span::styled(v.clone(), Style::default().fg(Color::White)),
]));
}
}
lines.push(Line::raw(""));
}
if let Some(err) = error {
lines.push(Line::from(Span::styled(
format!(" error: {}", err),
Style::default().fg(Color::Red),
)));
}
let title = if is_edit { " Edit Request " } else { " New Request " };
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
frame.render_widget(paragraph, area);
}
fn draw_import_curl(frame: &mut Frame, area: Rect, buffer: &str, error: Option<&str>) {
let mut lines: Vec<Line<'static>> = vec![
Line::raw(""),
Line::from(Span::styled(
" Paste a cURL command, then Ctrl+s to import",
Style::default().fg(Color::DarkGray),
)),
Line::raw(""),
];
if buffer.is_empty() {
lines.push(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::DarkGray)),
Span::styled("█", Style::default().fg(Color::White)),
]));
} else {
let last = buffer.lines().count().saturating_sub(1);
for (i, line) in buffer.lines().enumerate() {
let text = if i == last { format!("{}█", line) } else { line.to_string() };
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(text, Style::default().fg(Color::White)),
]));
}
}
if let Some(err) = error {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
format!(" error: {}", err),
Style::default().fg(Color::Red),
)));
}
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" Import cURL ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
frame.render_widget(paragraph, area);
}
fn draw_new_profile(
frame: &mut Frame,
area: Rect,
name: &str,
params: &[(String, String)],
focused: usize,
error: Option<&str>,
) {
let mut lines: Vec<Line<'static>> = vec![Line::raw("")];
let name_focused = focused == 0;
lines.push(Line::from(vec![
Span::styled(
" profile name",
Style::default()
.fg(if name_focused { Color::Green } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
),
Span::styled(" *", Style::default().fg(Color::Red)),
]));
let display = if name_focused { format!("{}█", name) } else { name.to_string() };
lines.push(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::DarkGray)),
Span::styled(display, Style::default().fg(if name_focused { Color::White } else { Color::DarkGray })),
]));
lines.push(Line::raw(""));
for (i, (key, value)) in params.iter().enumerate() {
let key_focused = focused == 1 + 2 * i;
let val_focused = focused == 2 + 2 * i;
lines.push(Line::from(Span::styled(
format!(" param {} key", i + 1),
Style::default()
.fg(if key_focused { Color::Green } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)));
let display = if key_focused { format!("{}█", key) } else { key.clone() };
lines.push(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::DarkGray)),
Span::styled(display, Style::default().fg(if key_focused { Color::White } else { Color::DarkGray })),
]));
lines.push(Line::from(Span::styled(
format!(" param {} value", i + 1),
Style::default()
.fg(if val_focused { Color::Green } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)));
let display = if val_focused { format!("{}█", value) } else { value.clone() };
lines.push(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::DarkGray)),
Span::styled(display, Style::default().fg(if val_focused { Color::White } else { Color::DarkGray })),
]));
lines.push(Line::raw(""));
}
if let Some(err) = error {
lines.push(Line::from(Span::styled(
format!(" error: {}", err),
Style::default().fg(Color::Red),
)));
}
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(" New Profile ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
);
frame.render_widget(paragraph, area);
}
fn draw_var_input(
frame: &mut Frame,
area: Rect,
vars: &[(String, String)],
focused: usize,
entry_name: &str,
) {
let title = format!(" Variables — {} ", entry_name);
let mut lines: Vec<Line<'static>> = vec![Line::raw("")];
for (i, (name, value)) in vars.iter().enumerate() {
let is_focused = i == focused;
lines.push(Line::from(Span::styled(
format!(" {}", name),
Style::default()
.fg(if is_focused { Color::Yellow } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)));
let prompt = Span::styled(" > ", Style::default().fg(Color::DarkGray));
let display = if is_focused { format!("{}█", value) } else { value.clone() };
let input = Span::styled(
display,
Style::default().fg(if is_focused { Color::White } else { Color::DarkGray }),
);
lines.push(Line::from(vec![prompt, input]));
lines.push(Line::raw(""));
}
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
frame.render_widget(paragraph, area);
}
fn draw_test_input(
frame: &mut Frame,
area: Rect,
vars: &[(String, String)],
iterations: &str,
focused: usize,
entry_name: &str,
) {
let title = format!(" Test — {} ", entry_name);
let mut lines: Vec<Line<'static>> = vec![Line::raw("")];
for (i, (name, value)) in vars.iter().enumerate() {
let is_focused = i == focused;
lines.push(Line::from(Span::styled(
format!(" {}", name),
Style::default()
.fg(if is_focused { Color::Yellow } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)));
let prompt = Span::styled(" > ", Style::default().fg(Color::DarkGray));
let display = if is_focused { format!("{}█", value) } else { value.clone() };
let input = Span::styled(
display,
Style::default().fg(if is_focused { Color::White } else { Color::DarkGray }),
);
lines.push(Line::from(vec![prompt, input]));
lines.push(Line::raw(""));
}
let iter_focused = focused == vars.len();
lines.push(Line::from(Span::styled(
" iterations",
Style::default()
.fg(if iter_focused { Color::Yellow } else { Color::DarkGray })
.add_modifier(Modifier::BOLD),
)));
let prompt = Span::styled(" > ", Style::default().fg(Color::DarkGray));
let display = if iter_focused { format!("{}█", iterations) } else { iterations.to_string() };
let input = Span::styled(
display,
Style::default().fg(if iter_focused { Color::White } else { Color::DarkGray }),
);
lines.push(Line::from(vec![prompt, input]));
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(paragraph, area);
}
fn draw_test_results(frame: &mut Frame, area: Rect, results: &crate::tester::TestResults) {
let iterations = results.timings.len();
let title = Line::from(vec![
Span::raw(" Test Results "),
Span::styled(
format!(" {} iterations ", iterations),
Style::default().fg(Color::Black).bg(Color::Cyan),
),
]);
let outer = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = outer.inner(area);
frame.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3), Constraint::Length(5)])
.split(inner);
let max_count = results.statuses.iter().map(|s| s.count).max().unwrap_or(1);
let bar_width = 30usize;
let mut gauge_lines: Vec<Line<'static>> = vec![Line::raw("")];
for sc in &results.statuses {
let color = status_color(sc.status);
let filled = ((sc.count * bar_width) / max_count).max(1);
let empty = bar_width - filled;
gauge_lines.push(Line::from(vec![
Span::styled(format!(" {:>3} ", sc.status), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled("█".repeat(filled), Style::default().fg(color)),
Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" ×{}", sc.count), Style::default().fg(Color::White)),
]));
}
let gauge_widget = Paragraph::new(gauge_lines).block(
Block::default()
.title(" Status Distribution ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(gauge_widget, chunks[0]);
let stats = Paragraph::new(Line::from(vec![
Span::styled(" avg ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:.2?}", results.avg), Style::default().fg(Color::Yellow)),
Span::styled(" min ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:.2?}", results.min), Style::default().fg(Color::Green)),
Span::styled(" max ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:.2?}", results.max), Style::default().fg(Color::Red)),
]));
frame.render_widget(stats, chunks[1]);
let max_ms = results.timings.iter().copied().max().unwrap_or(1);
let latency_points: Vec<(f64, f64)> = results
.timings
.iter()
.enumerate()
.map(|(i, &ms)| (i as f64, ms as f64))
.collect();
let latency_dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::Cyan))
.data(&latency_points);
let latency_chart = Chart::new(vec![latency_dataset])
.block(
Block::default()
.title(" Latency (ms) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.x_axis(
Axis::default()
.bounds([0.0, results.timings.len().saturating_sub(1) as f64]),
)
.y_axis(
Axis::default()
.bounds([0.0, max_ms as f64])
.labels(vec![Span::raw("0"), Span::raw(format!("{}ms", max_ms))]),
);
frame.render_widget(latency_chart, chunks[2]);
}
fn status_color(status: u16) -> Color {
if status < 300 { Color::Green }
else if status < 400 { Color::Cyan }
else if status < 500 { Color::Yellow }
else { Color::Red }
}
fn draw_response(frame: &mut Frame, area: Rect, kind: &ResponseKind, body: &str, scroll: u16, response_filter: &str, response_filter_active: bool) -> u16 {
let (heading, badge_color, badge) = match kind {
ResponseKind::Error => (" Response ", Color::Red, " Error ".to_string()),
ResponseKind::Http(status) => {
let color = if *status < 300 {
Color::Green
} else if *status < 400 {
Color::Cyan
} else if *status < 500 {
Color::Yellow
} else {
Color::Red
};
(" Response ", color, format!(" {} ", status))
}
ResponseKind::Curl { copied: Ok(()) } => (" cURL ", Color::Green, " copied ".to_string()),
ResponseKind::Curl { copied: Err(e) } => {
(" cURL ", Color::Red, format!(" copy failed: {} ", e))
}
};
let title = Line::from(vec![
Span::raw(heading),
Span::styled(badge, Style::default().fg(Color::Black).bg(badge_color)),
]);
let show_filter = response_filter_active || !response_filter.is_empty();
let chunks = if show_filter {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(0), Constraint::Min(0)])
.split(area)
};
if show_filter {
let display = if response_filter_active { format!("{}█", response_filter) } else { response_filter.to_string() };
let filter_widget = Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(display, Style::default().fg(Color::White)),
]))
.block(
Block::default()
.title(" Filter ")
.borders(Borders::ALL)
.border_style(Style::default().fg(if response_filter_active { Color::Yellow } else { Color::DarkGray })),
);
frame.render_widget(filter_widget, chunks[0]);
}
let q = response_filter.to_lowercase();
let lines: Vec<Line<'static>> = body
.lines()
.filter(|line| q.is_empty() || line.to_lowercase().contains(&q))
.map(colorize_json_line)
.collect();
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(badge_color)),
)
.scroll((scroll, 0));
frame.render_widget(paragraph, chunks[1]);
chunks[1].height.saturating_sub(2)
}
fn draw_help(frame: &mut Frame, area: Rect, mode: &Mode) {
let spans: Vec<Span<'static>> = match mode {
Mode::Browse => vec![
Span::styled(" q ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("quit", Style::default().fg(Color::DarkGray)),
Span::styled(" j/k ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("navigate", Style::default().fg(Color::DarkGray)),
Span::styled(" gg ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("top", Style::default().fg(Color::DarkGray)),
Span::styled(" G ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("bottom", Style::default().fg(Color::DarkGray)),
Span::styled(" r/Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("run", Style::default().fg(Color::DarkGray)),
Span::styled(" t ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("test", Style::default().fg(Color::DarkGray)),
Span::styled(" n ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("new", Style::default().fg(Color::DarkGray)),
Span::styled(" e ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("edit", Style::default().fg(Color::DarkGray)),
Span::styled(" c ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("copy", Style::default().fg(Color::DarkGray)),
Span::styled(" d ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("delete", Style::default().fg(Color::DarkGray)),
Span::styled(" f ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("filter", Style::default().fg(Color::DarkGray)),
Span::styled(" R ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("refresh", Style::default().fg(Color::DarkGray)),
Span::styled(" y ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("copy cURL", Style::default().fg(Color::DarkGray)),
Span::styled(" i ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("import cURL", Style::default().fg(Color::DarkGray)),
],
Mode::ImportCurl { .. } => vec![
Span::styled(" Ctrl+s ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("import", Style::default().fg(Color::DarkGray)),
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("newline", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::ProfileSelect { .. } => vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("select", Style::default().fg(Color::DarkGray)),
Span::styled(" j/k ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("navigate", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::VarInput { .. } => vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("run", Style::default().fg(Color::DarkGray)),
Span::styled(" Tab/S-Tab ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("switch field", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::TestInput { .. } => vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("run test", Style::default().fg(Color::DarkGray)),
Span::styled(" Tab/S-Tab ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("switch field", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::NewRequest { .. } => vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("save", Style::default().fg(Color::DarkGray)),
Span::styled(" Tab/S-Tab ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("next/prev field", Style::default().fg(Color::DarkGray)),
Span::styled(" Ctrl+g ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("toggle global", Style::default().fg(Color::DarkGray)),
Span::styled(" Ctrl+p ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("add profile", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::NewProfile { .. } => vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("save profile", Style::default().fg(Color::DarkGray)),
Span::styled(" Tab/S-Tab ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("next/prev field", Style::default().fg(Color::DarkGray)),
Span::styled(" Ctrl+a ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("add param", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::ConfirmDelete { .. } => vec![
Span::styled(" y/Enter ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("confirm delete", Style::default().fg(Color::DarkGray)),
Span::styled(" n/Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
],
Mode::Response { .. } | Mode::TestResponse { .. } => vec![
Span::styled(" j/k ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("scroll", Style::default().fg(Color::DarkGray)),
Span::styled(" gg ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("top", Style::default().fg(Color::DarkGray)),
Span::styled(" G ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("bottom", Style::default().fg(Color::DarkGray)),
Span::styled(" f ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("filter", Style::default().fg(Color::DarkGray)),
Span::styled(" c ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("copy", Style::default().fg(Color::DarkGray)),
Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("back", Style::default().fg(Color::DarkGray)),
Span::styled(" q ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("quit", Style::default().fg(Color::DarkGray)),
],
};
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_confirm_delete(frame: &mut Frame, area: Rect, entry_name: &str) {
let lines: Vec<Line<'static>> = vec![
Line::raw(""),
Line::from(vec![
Span::styled(" Delete ", Style::default().fg(Color::DarkGray)),
Span::styled(entry_name.to_string(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled("?", Style::default().fg(Color::DarkGray)),
]),
Line::raw(""),
Line::from(Span::styled(
" This action cannot be undone.",
Style::default().fg(Color::DarkGray),
)),
Line::raw(""),
Line::from(vec![
Span::styled(" y / Enter ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled("yes, delete", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" n / Esc ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled("cancel", Style::default().fg(Color::DarkGray)),
]),
];
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(" Confirm Delete ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red)),
);
frame.render_widget(paragraph, area);
}
fn colorize_json_value(val: &str) -> Vec<Span<'static>> {
let (content, trailing) = val.strip_suffix(',').map_or((val, ""), |s| (s, ","));
let color = if content.starts_with('"') {
Color::Green
} else if matches!(content, "true" | "false") {
Color::Magenta
} else if content == "null" {
Color::Red
} else if matches!(content, "{" | "}" | "[" | "]" | "{}" | "[]") {
Color::DarkGray
} else {
Color::Cyan };
let mut spans = vec![Span::styled(content.to_string(), Style::default().fg(color))];
if !trailing.is_empty() {
spans.push(Span::styled(",".to_string(), Style::default().fg(Color::DarkGray)));
}
spans
}
fn colorize_json_line(line: &str) -> Line<'static> {
let indent_len = line.len() - line.trim_start().len();
let indent = line[..indent_len].to_string();
let trimmed = &line[indent_len..];
if matches!(trimmed, "{" | "}" | "[" | "]" | "}," | "]," | "{}" | "[]" | "{}," | "[],") {
return Line::from(vec![
Span::raw(indent),
Span::styled(trimmed.to_string(), Style::default().fg(Color::DarkGray)),
]);
}
if trimmed.starts_with('"')
&& let Some(pos) = trimmed.find("\": ") {
let key = trimmed[..pos + 1].to_string(); let value = &trimmed[pos + 3..]; let mut spans = vec![
Span::raw(indent),
Span::styled(key, Style::default().fg(Color::Yellow)),
Span::styled(": ".to_string(), Style::default().fg(Color::DarkGray)),
];
spans.extend(colorize_json_value(value));
return Line::from(spans);
}
let mut spans = vec![Span::raw(indent)];
spans.extend(colorize_json_value(trimmed));
Line::from(spans)
}
const CLIPBOARD_BACKENDS: &[(&str, &[&str])] = &[
("pbcopy", &[]),
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("xsel", &["--clipboard", "--input"]),
];
pub(super) fn copy_to_clipboard(text: &str) -> Result<(), String> {
for (program, args) in CLIPBOARD_BACKENDS {
if run_clipboard_backend(program, args, text) {
return Ok(());
}
}
Err(format!(
"no clipboard tool found (tried {})",
CLIPBOARD_BACKENDS
.iter()
.map(|(p, _)| *p)
.collect::<Vec<_>>()
.join(", ")
))
}
fn run_clipboard_backend(program: &str, args: &[&str], text: &str) -> bool {
use std::io::Write;
use std::process::{Command, Stdio};
let Ok(mut child) = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let wrote = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(text.as_bytes()).is_ok(),
None => false,
};
let exited_ok = matches!(child.wait(), Ok(status) if status.success());
wrote && exited_ok
}
fn method_color(method: &str) -> Color {
match method {
"GET" => Color::Cyan,
"POST" => Color::Green,
"PUT" | "PATCH" => Color::Yellow,
"DELETE" => Color::Red,
_ => Color::White,
}
}
pub(super) fn format_test_results_text(results: &crate::tester::TestResults) -> String {
let mut lines = vec!["Results".to_string(), "".to_string()];
for sc in &results.statuses {
let reason = reqwest::StatusCode::from_u16(sc.status)
.ok()
.and_then(|s| s.canonical_reason())
.unwrap_or("Unknown");
lines.push(format!(" {} {} x {}", sc.status, reason, sc.count));
}
lines.push("".to_string());
lines.push("Timings".to_string());
lines.push("".to_string());
lines.push(format!(" avg: {:.2?}", results.avg));
lines.push(format!(" min: {:.2?}", results.min));
lines.push(format!(" max: {:.2?}", results.max));
lines.join("\n")
}
fn folder_name_spans(name: &str) -> Vec<Span<'static>> {
match name.rfind('/') {
Some(pos) => vec![
Span::styled(
format!("{}/ ", &name[..pos]),
Style::default().fg(Color::DarkGray),
),
Span::styled(
name[pos + 1..].to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
],
None => vec![Span::styled(
name.to_string(),
Style::default().add_modifier(Modifier::BOLD),
)],
}
}
fn make_list_item(entry: &Entry) -> ListItem<'static> {
let scope = if entry.global { " global" } else { " local" };
let line = match &entry.kind {
EntryKind::Chain { .. } => {
let mut spans = vec![
Span::styled(
" CHAIN ",
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
spans.extend(folder_name_spans(&entry.name));
spans.push(Span::styled(scope, Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
EntryKind::Request { method, description, .. } => {
let color = method_color(method);
let mut spans = vec![
Span::styled(
format!(" {:<6}", method),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
spans.extend(folder_name_spans(&entry.name));
if let Some(desc) = description
&& !desc.is_empty()
{
spans.push(Span::styled(
format!(" — {}", desc),
Style::default().fg(Color::DarkGray),
));
}
spans.push(Span::styled(scope, Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
};
ListItem::new(line)
}
fn make_tree_folder_item(path: &str, expanded: bool, prefix: &str, use_nerd_fonts: bool) -> ListItem<'static> {
let display_name = path.rsplit('/').next().unwrap_or(path).to_string();
let line = if use_nerd_fonts {
let icon = if expanded { "\u{f07c} " } else { "\u{f07b} " };
Line::from(vec![
Span::styled(prefix.to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(icon.to_string(), Style::default().fg(Color::Yellow)),
Span::styled(display_name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
])
} else {
Line::from(vec![
Span::styled(prefix.to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}/", display_name), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
])
};
ListItem::new(line)
}
fn make_tree_leaf_item(entry: &Entry, prefix: &str, _use_nerd_fonts: bool) -> ListItem<'static> {
let leaf_name = entry.name.rsplit('/').next().unwrap_or(&entry.name).to_string();
let line = match &entry.kind {
EntryKind::Chain { .. } => Line::from(vec![
Span::styled(prefix.to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(" CHAIN ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(leaf_name, Style::default().add_modifier(Modifier::BOLD)),
]),
EntryKind::Request { method, description, .. } => {
let color = method_color(method);
let mut spans = vec![
Span::styled(prefix.to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {:<6}", method), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(leaf_name, Style::default().add_modifier(Modifier::BOLD)),
];
if let Some(desc) = description.as_ref().filter(|d| !d.is_empty()) {
spans.push(Span::styled(format!(" — {}", desc), Style::default().fg(Color::DarkGray)));
}
Line::from(spans)
}
};
ListItem::new(line)
}
fn make_detail_lines(entry: &Entry) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
match &entry.kind {
EntryKind::Chain { steps } => {
let mut header = vec![
Span::styled(
" CHAIN ",
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
header.extend(folder_name_spans(&entry.name));
lines.push(Line::from(header));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" Steps",
Style::default().fg(Color::DarkGray),
)));
for (i, step) in steps.iter().enumerate() {
lines.push(Line::from(vec![
Span::styled(
format!(" {}. ", i + 1),
Style::default().fg(Color::DarkGray),
),
Span::raw(step.clone()),
]));
}
}
EntryKind::Request { method, url, description, headers, query, .. } => {
let color = method_color(method);
let mut header = vec![
Span::styled(
format!(" {} ", method),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
header.extend(folder_name_spans(&entry.name));
lines.push(Line::from(header));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" url ", Style::default().fg(Color::DarkGray)),
Span::styled(url.clone(), Style::default().fg(Color::Cyan)),
]));
if let Some(desc) = description
&& !desc.is_empty()
{
lines.push(Line::from(vec![
Span::styled(" desc ", Style::default().fg(Color::DarkGray)),
Span::raw(desc.clone()),
]));
}
if !headers.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" Headers",
Style::default().fg(Color::DarkGray),
)));
for (k, v) in headers {
lines.push(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(Color::Yellow)),
Span::raw(v.clone()),
]));
}
}
if !query.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" Query",
Style::default().fg(Color::DarkGray),
)));
for (k, v) in query {
lines.push(Line::from(vec![
Span::styled(format!(" {}: ", k), Style::default().fg(Color::Yellow)),
Span::raw(v.clone()),
]));
}
}
}
}
lines
}