use crate::core::collection::KVParam;
use crate::tui::app::{
App, FocusedPanel, PropertyEditorField, PropertyTab, RequestBarPart, StatsTab,
};
use crate::tui::ui::syntax::{apply_env_vars, format_content, highlight_content};
use crate::tui::ui::utils::{
create_block, get_method_enum_color, highlight_env_vars, title_with_key,
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap},
};
pub fn render_right_column(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Percentage(40), Constraint::Min(0), ])
.split(area);
render_request_bar(f, app, chunks[0]);
render_properties_tabs(f, app, chunks[1]);
render_details_area(f, app, chunks[2]);
let response_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(70), Constraint::Percentage(30), ])
.split(chunks[3]);
let response_block = create_block(
title_with_key("E", "Response"),
app.focused_panel == FocusedPanel::Response,
);
let formatted_body = format_content(&app.response_body, app.response_content_type.as_deref());
let trimmed_body = formatted_body.trim_end();
let response_area_inner = response_block.inner(response_area[0]);
let response_height = response_area_inner.height;
let response_width = response_area_inner.width;
let mut highlighted_body =
highlight_content(trimmed_body, app.response_content_type.as_deref());
let line_count = highlighted_body.lines.len() as u16;
if app.response_cursor_row >= line_count {
app.response_cursor_row = line_count.saturating_sub(1);
}
if app.response_cursor_row < app.response_scroll {
app.response_scroll = app.response_cursor_row;
} else if app.response_cursor_row >= app.response_scroll + response_height {
app.response_scroll = app.response_cursor_row - response_height + 1;
}
if app.response_cursor_col < app.response_horizontal_scroll {
app.response_horizontal_scroll = app.response_cursor_col;
} else if app.response_cursor_col >= app.response_horizontal_scroll + response_width {
app.response_horizontal_scroll = app.response_cursor_col - response_width + 1;
}
if app.focused_panel == FocusedPanel::Response && !highlighted_body.lines.is_empty() {
if let Some(line) = highlighted_body
.lines
.get_mut(app.response_cursor_row as usize)
{
for span in &mut line.spans {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
}
}
let response_content = Paragraph::new(highlighted_body)
.block(response_block)
.scroll((app.response_scroll, app.response_horizontal_scroll))
.wrap(Wrap { trim: false });
f.render_widget(response_content, response_area[0]);
let stats_tab_name = match app.selected_stats_tab {
StatsTab::Overview => "Overview",
StatsTab::Network => "Network",
StatsTab::Payload => "Payload",
StatsTab::Security => "Security",
};
let stat_block = create_block(
title_with_key(
"T",
format!(" Stats: [{}] (Press 't' to change) ", stats_tab_name),
),
app.focused_panel == FocusedPanel::Stats,
);
let mut stat_lines = Vec::new();
let tabs = [
("O", StatsTab::Overview),
("N", StatsTab::Network),
("L", StatsTab::Payload),
("S", StatsTab::Security),
];
let tab_spans: Vec<Span> = tabs
.iter()
.map(|(label, variant)| {
let is_selected = app.selected_stats_tab == *variant;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(Color::DarkGray)
};
let text = if is_selected {
format!(" {} ", stats_tab_name)
} else {
format!(" {} ", label)
};
Span::styled(text, style)
})
.collect();
stat_lines.push(Line::from(tab_spans));
stat_lines.push(Line::raw(""));
if let Some(stats) = &app.response_stats_data {
match app.selected_stats_tab {
StatsTab::Overview => {
stat_lines.push(Line::from(vec![
Span::styled(" URL: ", Style::default().fg(Color::DarkGray)),
Span::raw(&stats.url),
]));
stat_lines.push(Line::from(vec![
Span::styled(" Method: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&stats.method,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
if let Some(status) = &app.response_status {
let color = if status.starts_with('2') {
Color::Green
} else if status.starts_with('4') {
Color::Yellow
} else if status.starts_with('5') || status == "ERROR" {
Color::Red
} else {
Color::White
};
stat_lines.push(Line::from(vec![
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(
status,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
]));
}
stat_lines.push(Line::from(vec![
Span::styled(" Proto: ", Style::default().fg(Color::DarkGray)),
Span::raw(&stats.version),
]));
if let Some(addr) = &stats.remote_addr {
stat_lines.push(Line::from(vec![
Span::styled(" Remote: ", Style::default().fg(Color::DarkGray)),
Span::raw(addr),
]));
}
}
StatsTab::Network => {
stat_lines.push(Line::from(vec![
Span::styled(" Total Time: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:?}", stats.total_time),
Style::default().add_modifier(Modifier::BOLD),
),
]));
stat_lines.push(Line::raw(""));
let total_ms = stats.total_time.as_millis() as f64;
let width = response_area[1].width.saturating_sub(6) as f64;
if total_ms > 0.0 {
let phases = [
("DNS Lookup", stats.dns_time, Color::Blue),
("TCP Connect", stats.connect_time, Color::Cyan),
("TLS Handshake", stats.tls_time, Color::Magenta),
(
"TTFB (Server)",
stats.ttfb.saturating_sub(
stats.dns_time + stats.connect_time + stats.tls_time,
),
Color::Yellow,
),
("Download", stats.download_time, Color::Green),
];
for (name, duration, color) in phases {
let ms = duration.as_millis() as f64;
let p_width = ((ms / total_ms) * width).max(1.0).round() as usize;
let perc = (ms / total_ms) * 100.0;
stat_lines.push(Line::raw(format!(" {}:", name)));
stat_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("â–ˆ".repeat(p_width), Style::default().fg(color)),
Span::raw(format!(" {:?} ({:.0}%)", duration, perc)),
]));
}
}
}
StatsTab::Payload => {
let total_size = stats.header_size + stats.body_size;
stat_lines.push(Line::from(vec![
Span::styled(" Total Size: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_size(total_size),
Style::default().add_modifier(Modifier::BOLD),
),
]));
stat_lines.push(Line::raw(""));
if total_size > 0 {
let h_perc_val = (stats.header_size as f64 / total_size as f64) * 100.0;
let b_perc_val = (stats.body_size as f64 / total_size as f64) * 100.0;
let bar_width: usize = 15;
let h_width = ((stats.header_size as f64 / total_size as f64)
* bar_width as f64)
.round() as usize;
let b_width = ((stats.body_size as f64 / total_size as f64) * bar_width as f64)
.round() as usize;
stat_lines.push(Line::from(vec![
Span::styled(" ├── Headers: ", Style::default().fg(Color::DarkGray)),
Span::raw(format_size(stats.header_size)),
]));
stat_lines.push(Line::from(vec![
Span::raw(" │ ["),
Span::styled("â–ˆ".repeat(h_width), Style::default().fg(Color::Blue)),
Span::styled(
"â–‘".repeat(bar_width.saturating_sub(h_width)),
Style::default().fg(Color::DarkGray),
),
Span::raw(format!("] {:.0}%", h_perc_val)),
]));
stat_lines.push(Line::from(vec![
Span::styled(" └── Body: ", Style::default().fg(Color::DarkGray)),
Span::raw(format_size(stats.body_size)),
]));
stat_lines.push(Line::from(vec![
Span::raw(" ["),
Span::styled("â–ˆ".repeat(b_width), Style::default().fg(Color::Magenta)),
Span::styled(
"â–‘".repeat(bar_width.saturating_sub(b_width)),
Style::default().fg(Color::DarkGray),
),
Span::raw(format!("] {:.0}%", b_perc_val)),
]));
if let Some(enc) = stats.headers.get("content-encoding") {
stat_lines.push(Line::raw(""));
stat_lines.push(Line::from(vec![
Span::styled(" Compression: ", Style::default().fg(Color::DarkGray)),
Span::styled(enc.to_uppercase(), Style::default().fg(Color::Green)),
]));
}
}
}
StatsTab::Security => {
let limit = stats.headers.get("x-ratelimit-limit");
let remaining = stats.headers.get("x-ratelimit-remaining");
let reset = stats.headers.get("x-ratelimit-reset");
if let (Some(l), Some(r)) = (limit, remaining) {
stat_lines.push(Line::styled(
" Rate Limit",
Style::default().fg(Color::Cyan),
));
stat_lines.push(Line::raw(format!(" {} / {}", r, l)));
if let (Ok(rv), Ok(lv)) = (r.parse::<f64>(), l.parse::<f64>()) {
let perc = (rv / lv) * 10.0;
let color = if perc < 2.0 {
Color::Red
} else if perc < 5.0 {
Color::Yellow
} else {
Color::Green
};
stat_lines.push(Line::from(vec![
Span::raw(" ["),
Span::styled(
"â–ˆ".repeat(perc.round() as usize),
Style::default().fg(color),
),
Span::styled(
"â–‘".repeat(10usize.saturating_sub(perc.round() as usize)),
Style::default().fg(Color::DarkGray),
),
Span::raw("]"),
]));
}
if let Some(res) = reset {
stat_lines.push(Line::raw(format!(" Resets in: {}s", res)));
}
stat_lines.push(Line::raw(""));
}
let cache_control = stats.headers.get("cache-control");
let x_cache = stats.headers.get("x-cache");
let age = stats.headers.get("age");
if cache_control.is_some() || x_cache.is_some() {
stat_lines.push(Line::styled(" Caching", Style::default().fg(Color::Cyan)));
if let Some(xc) = x_cache {
stat_lines.push(Line::raw(format!(" Status: {}", xc)));
}
if let Some(a) = age {
stat_lines.push(Line::raw(format!(" Age: {}s", a)));
}
if let Some(cc) = cache_control {
stat_lines.push(Line::raw(format!(" Policy: {}", cc)));
}
}
if let Some(server) = stats.headers.get("server") {
stat_lines.push(Line::raw(""));
stat_lines.push(Line::styled(" Server", Style::default().fg(Color::Cyan)));
stat_lines.push(Line::raw(format!(" {}", server)));
}
if stat_lines.len() <= 2 {
stat_lines.push(Line::raw(" No security/governance"));
stat_lines.push(Line::raw(" headers found."));
}
}
}
} else {
stat_lines.push(Line::raw(" No Data"));
}
let stat_area_inner = stat_block.inner(response_area[1]);
let stat_height = stat_area_inner.height;
let stat_line_count = stat_lines.len() as u16;
if stat_line_count <= stat_height {
app.stats_scroll = 0;
} else {
let max_scroll = stat_line_count.saturating_sub(stat_height);
if app.stats_scroll > max_scroll {
app.stats_scroll = max_scroll;
}
}
let stat_content = Paragraph::new(stat_lines)
.block(stat_block)
.scroll((app.stats_scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(stat_content, response_area[1]);
}
fn format_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.2} KB", bytes as f64 / 1024.0)
} else {
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub fn render_request_bar(f: &mut Frame, app: &App, area: Rect) {
let is_bar_focused = app.focused_panel == FocusedPanel::RequestBar;
let block = Block::default()
.borders(Borders::ALL)
.title(title_with_key("R", "Request"))
.border_style(if is_bar_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
});
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(10), Constraint::Min(0), Constraint::Length(10), ])
.split(block.inner(area));
let method_color = get_method_enum_color(app.method);
let method_style = if is_bar_focused && app.active_request_part == RequestBarPart::Method {
Style::default()
.fg(method_color)
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::default()
.fg(method_color)
.add_modifier(Modifier::BOLD)
};
let method_text = Paragraph::new(format!(" {:?} ", app.method)).style(method_style);
let url_style = if is_bar_focused && app.active_request_part == RequestBarPart::Url {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let url_text = Paragraph::new(highlight_env_vars(app.url.as_str())).style(url_style);
let send_style = if is_bar_focused && app.active_request_part == RequestBarPart::Send {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::default().fg(Color::Yellow)
};
let send_button = Paragraph::new(" [ Send ] ").style(send_style);
f.render_widget(block, area);
f.render_widget(method_text, layout[0]);
f.render_widget(url_text, layout[1]);
f.render_widget(send_button, layout[2]);
}
pub fn render_properties_tabs(f: &mut Frame, app: &App, area: Rect) {
let titles = vec![
Line::from(vec![
Span::styled(
"[P]",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Params "),
]),
Line::from(vec![
Span::styled(
"[H]",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Headers "),
]),
Line::from(vec![
Span::styled(
"[U]",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Auth "),
]),
Line::from(vec![
Span::styled(
"[B]",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Body "),
]),
Line::from(vec![
Span::styled(
"[S]",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Scripts "),
]),
];
let selected_idx = match app.selected_property_tab {
PropertyTab::Params => 0,
PropertyTab::Headers => 1,
PropertyTab::Auth => 2,
PropertyTab::Body => 3,
PropertyTab::Scripts => 4,
};
let tabs = Tabs::new(titles)
.block(create_block(
" Properties ".to_string(),
app.focused_panel == FocusedPanel::Properties,
))
.select(selected_idx)
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.divider("|");
f.render_widget(tabs, area);
}
pub fn render_details_area(f: &mut Frame, app: &mut App, area: Rect) {
match app.selected_property_tab {
PropertyTab::Params => {
let params = app
.get_current_request()
.map(|r| r.params.clone())
.unwrap_or_default();
render_kv_editor(
f,
app,
area,
title_with_key("P", "Query Parameters"),
¶ms,
true,
);
}
PropertyTab::Headers => {
let headers = app
.get_current_request()
.map(|r| r.headers.clone())
.unwrap_or_default();
render_kv_editor(f, app, area, title_with_key("H", "Headers"), &headers, true);
}
PropertyTab::Auth => {
let auth = app
.get_current_request()
.map(|r| r.auth.clone())
.unwrap_or_default();
let title = match auth.selected {
crate::core::collection::AuthType::None => " Auth: None (Press 't' to change) ",
crate::core::collection::AuthType::Bearer => " Auth: Bearer (Press 't' to change) ",
crate::core::collection::AuthType::Basic => " Auth: Basic (Press 't' to change) ",
crate::core::collection::AuthType::ApiKey => " Auth: ApiKey (Press 't' to change) ",
};
let block = create_block(
title_with_key("U", title),
app.focused_panel == FocusedPanel::Details,
);
let mut kv_params = Vec::new();
match auth.selected {
crate::core::collection::AuthType::None => {
f.render_widget(Paragraph::new(" No authentication ").block(block), area);
}
crate::core::collection::AuthType::Bearer => {
kv_params.push(crate::core::collection::KVParam {
key: "Token".to_string(),
value: auth.bearer.token,
enabled: true,
description: None,
});
render_kv_editor(f, app, area, title_with_key("U", title), &kv_params, false);
}
crate::core::collection::AuthType::Basic => {
kv_params.push(crate::core::collection::KVParam {
key: "Username".to_string(),
value: auth.basic.username,
enabled: true,
description: None,
});
kv_params.push(crate::core::collection::KVParam {
key: "Password".to_string(),
value: auth.basic.password,
enabled: true,
description: None,
});
render_kv_editor(f, app, area, title_with_key("U", title), &kv_params, false);
}
crate::core::collection::AuthType::ApiKey => {
kv_params.push(crate::core::collection::KVParam {
key: "Key".to_string(),
value: auth.api_key.key,
enabled: true,
description: None,
});
kv_params.push(crate::core::collection::KVParam {
key: "Value".to_string(),
value: auth.api_key.value,
enabled: true,
description: None,
});
kv_params.push(crate::core::collection::KVParam {
key: "In Header".to_string(),
value: auth.api_key.in_header.to_string(),
enabled: true,
description: None,
});
render_kv_editor(f, app, area, title_with_key("U", title), &kv_params, false);
}
}
}
PropertyTab::Body => {
let body = app
.get_current_request()
.map(|r| r.body.clone())
.unwrap_or_default();
let title = match body.selected {
crate::core::collection::BodyType::None => " Body: None (Press 't' to change) ",
crate::core::collection::BodyType::Raw => " Body: Raw (Press 't' to change) ",
crate::core::collection::BodyType::FormData => {
" Body: Form Data (Press 't' to change) "
}
crate::core::collection::BodyType::XWwwFormUrlEncoded => {
" Body: x-www-form-urlencoded (Press 't' to change) "
}
};
match body.selected {
crate::core::collection::BodyType::None => {
let block = create_block(
title_with_key("B", title),
app.focused_panel == FocusedPanel::Details,
);
f.render_widget(Paragraph::new(" No body ").block(block), area);
}
crate::core::collection::BodyType::Raw => {
let title = format!(
" Body: Raw [{}] (Press 't' to cycle type, 'c' for format) ",
body.raw.content_type
);
let block = create_block(
title_with_key("B", title),
app.focused_panel == FocusedPanel::Details,
);
let formatted_body =
format_content(&body.raw.content, Some(body.raw.content_type.as_str()));
let mut highlighted_body =
highlight_content(&formatted_body, Some(body.raw.content_type.as_str()));
apply_env_vars(&mut highlighted_body);
let p = Paragraph::new(highlighted_body)
.block(block)
.scroll((app.details_scroll as u16, 0))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
crate::core::collection::BodyType::FormData => {
render_kv_editor(
f,
app,
area,
title_with_key("B", title),
&body.form_data.items,
false,
);
}
crate::core::collection::BodyType::XWwwFormUrlEncoded => {
render_kv_editor(
f,
app,
area,
title_with_key("B", title),
&body.x_www_form_urlencoded.items,
false,
);
}
}
}
PropertyTab::Scripts => {
let block = create_block(
title_with_key("S", "Scripts"),
app.focused_panel == FocusedPanel::Details,
);
f.render_widget(
Paragraph::new(" Scripts editor coming soon "),
area.inner(Margin {
vertical: 1,
horizontal: 1,
}),
);
f.render_widget(block, area);
}
}
}
pub fn render_kv_editor<'a, T: Into<ratatui::text::Line<'a>>>(
f: &mut Frame,
app: &mut App,
area: Rect,
title: T,
items: &[KVParam],
show_description: bool,
) {
let block = create_block(title, app.focused_panel == FocusedPanel::Details);
let mut header_cells = vec![
Cell::from("").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Key").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Value").style(Style::default().add_modifier(Modifier::BOLD)),
];
if show_description {
header_cells
.push(Cell::from("Description").style(Style::default().add_modifier(Modifier::BOLD)));
}
let header = Row::new(header_cells).height(1).bottom_margin(0);
let rows: Vec<Row> = items
.iter()
.enumerate()
.map(|(i, item)| {
let is_row_focused =
app.focused_panel == FocusedPanel::Details && app.property_editor_row == i;
let check = if item.enabled { "[x]" } else { "[ ]" };
let mut cells = vec![
Cell::from(check),
Cell::from(highlight_env_vars(item.key.as_str())),
Cell::from(highlight_env_vars(item.value.as_str())),
];
if show_description {
cells.push(Cell::from(highlight_env_vars(
item.description.as_deref().unwrap_or(""),
)));
}
if is_row_focused {
let field_idx = match app.property_editor_field {
PropertyEditorField::Key => 1,
PropertyEditorField::Value => 2,
PropertyEditorField::Description => {
if show_description {
3
} else {
2
}
}
};
cells[field_idx] = cells[field_idx]
.clone()
.style(Style::default().add_modifier(Modifier::REVERSED));
}
Row::new(cells)
})
.collect();
let constraints = if show_description {
vec![
Constraint::Percentage(5),
Constraint::Percentage(30),
Constraint::Percentage(30),
Constraint::Percentage(35),
]
} else {
vec![
Constraint::Percentage(5),
Constraint::Percentage(45),
Constraint::Percentage(50),
]
};
let table = Table::new(rows, constraints)
.header(header)
.block(block)
.row_highlight_style(Style::default().add_modifier(Modifier::BOLD));
app.details_table_state
.select(Some(app.property_editor_row));
f.render_stateful_widget(table, area, &mut app.details_table_state);
}