use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::layout::PaneId;
use crate::pane::Pane;
use crate::request_pane::{EditField, RunState};
use crate::ui::theme;
#[allow(dead_code)]
fn section_header_chip(text: &'static str, t: theme::Theme) -> Line<'static> {
Line::from(Span::styled(
text,
Style::default()
.fg(t.bg_dark)
.bg(t.cyan)
.add_modifier(Modifier::BOLD),
))
}
fn method_color(method: &str, t: theme::Theme) -> ratatui::style::Color {
match method.to_uppercase().as_str() {
"GET" => t.green,
"POST" => t.orange,
"PUT" => t.blue,
"PATCH" => t.cyan,
"DELETE" => t.red,
"HEAD" => t.yellow,
"OPTIONS" => t.purple,
_ => t.fg,
}
}
fn header_row(key: &str, value: &str, t: theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
key.to_string(),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
Span::styled(": ", Style::default().fg(t.comment).bg(t.bg_dark)),
Span::styled(value.to_string(), Style::default().fg(t.fg).bg(t.bg_dark)),
])
}
fn header_matches_filter(key: &str, value: &str, q_lower: &str) -> bool {
if q_lower.is_empty() {
return true;
}
key.to_ascii_lowercase().contains(q_lower) || value.to_ascii_lowercase().contains(q_lower)
}
fn floor_boundary(s: &str, mut idx: usize) -> usize {
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
idx
}
fn compute_filter_hits(rp: &crate::request_pane::RequestPane) -> Option<(usize, usize)> {
const BODY_LINE_CAP: usize = 20_000;
let q = rp.filter.trim().to_ascii_lowercase();
if q.is_empty() {
return None;
}
let mut matched = 0usize;
let mut total = 0usize;
for (k, v) in &rp.request.headers {
total += 1;
if header_matches_filter(k, v, &q) {
matched += 1;
}
}
if let RunState::Done(r) = &rp.state {
for (k, v) in &r.headers {
total += 1;
if header_matches_filter(k, v, &q) {
matched += 1;
}
}
for line in r.body.lines().take(BODY_LINE_CAP) {
total += 1;
if line.to_ascii_lowercase().contains(&q) {
matched += 1;
}
}
}
Some((matched, total))
}
fn filter_row(
filter: &str,
focused: bool,
hits: Option<(usize, usize)>,
t: theme::Theme,
) -> Line<'static> {
let display = if filter.is_empty() {
if focused {
"type to filter headers + body…".to_string()
} else {
"/ filter response".to_string()
}
} else {
filter.to_string()
};
let fg = if !filter.is_empty() {
t.fg
} else if focused {
t.cyan
} else {
t.comment
};
let cursor = if focused { "▏" } else { "" };
let search_glyph = "\u{f002}";
let mut spans = vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
format!("{search_glyph} "),
Style::default().fg(t.comment).bg(t.bg_dark),
),
Span::styled(display, Style::default().fg(fg).bg(t.bg_dark)),
Span::styled(cursor, Style::default().fg(t.cyan).bg(t.bg_dark)),
];
if !filter.is_empty()
&& let Some((matched, total)) = hits
{
let count_color = if matched > 0 { t.cyan } else { t.red };
spans.push(Span::styled(
format!(" {matched}/{total}"),
Style::default()
.fg(count_color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
}
Line::from(spans)
}
fn colored_line(
src: &str,
spans: &[crate::highlight::ColoredSpan],
base_fg: ratatui::style::Color,
t: theme::Theme,
) -> Line<'static> {
if spans.is_empty() {
return Line::from(Span::styled(
src.to_string(),
Style::default().fg(base_fg).bg(t.bg_dark),
));
}
let mut ordered: Vec<crate::highlight::ColoredSpan> = spans.to_vec();
ordered.sort_by_key(|(s, _, _)| *s);
let mut out: Vec<Span<'static>> = Vec::new();
let mut cursor = 0usize;
let src_len = src.len();
for (s, e, color) in ordered {
let s = floor_boundary(src, s.min(src_len));
let e = floor_boundary(src, e.min(src_len));
if e <= cursor {
continue;
}
if s > cursor {
out.push(Span::styled(
src[cursor..s].to_string(),
Style::default().fg(base_fg).bg(t.bg_dark),
));
}
let start = s.max(cursor);
out.push(Span::styled(
src[start..e].to_string(),
Style::default().fg(color).bg(t.bg_dark),
));
cursor = e;
}
if cursor < src_len {
out.push(Span::styled(
src[cursor..].to_string(),
Style::default().fg(base_fg).bg(t.bg_dark),
));
}
Line::from(out)
}
#[allow(clippy::too_many_arguments)]
fn colored_line_with_vars(
src: &str,
spans: &[crate::highlight::ColoredSpan],
base_fg: ratatui::style::Color,
t: theme::Theme,
envset: &crate::http::template::EnvSet,
resolved_style: Style,
unresolved_style: Style,
base_x: u16,
row_y: u16,
click_out: Option<&mut Vec<(Rect, String)>>,
) -> Line<'static> {
let tokens = tokenize_vars(src, envset);
if tokens.is_empty() {
return colored_line(src, spans, base_fg, t);
}
let mut ordered: Vec<crate::highlight::ColoredSpan> = spans.to_vec();
ordered.sort_by_key(|(s, _, _)| *s);
let base_style = Style::default().fg(base_fg).bg(t.bg_dark);
let mut out: Vec<Span<'static>> = Vec::new();
let mut run = String::new();
let mut run_style = base_style;
let mut first = true;
let mut click_out_ref = click_out;
for tok in &tokens {
let before = &src[..tok.start];
let width = src[tok.start..tok.end].chars().count() as u16;
if let Some(v) = click_out_ref.as_deref_mut() {
v.push((
Rect {
x: base_x + before.chars().count() as u16,
y: row_y,
width,
height: 1,
},
tok.name.clone(),
));
}
}
let mut byte = 0usize;
let mut tok_idx = 0usize; let mut span_scan_idx = 0usize; while byte < src.len() {
let ch = src[byte..].chars().next().unwrap();
let ch_bytes = ch.len_utf8();
while tok_idx < tokens.len() && tokens[tok_idx].end <= byte {
tok_idx += 1;
}
let style = if tok_idx < tokens.len()
&& byte >= tokens[tok_idx].start
&& byte < tokens[tok_idx].end
{
if tokens[tok_idx].resolved {
resolved_style
} else {
unresolved_style
}
} else {
while span_scan_idx < ordered.len() && ordered[span_scan_idx].1 <= byte {
span_scan_idx += 1;
}
let mut winning_color = None;
for (s, e, color) in &ordered[span_scan_idx..] {
if *s > byte {
break;
}
if byte >= *s && byte < *e {
winning_color = Some(*color);
}
}
match winning_color {
Some(c) => Style::default().fg(c).bg(t.bg_dark),
None => base_style,
}
};
if !first && style != run_style {
out.push(Span::styled(std::mem::take(&mut run), run_style));
}
run.push(ch);
run_style = style;
first = false;
byte += ch_bytes;
}
if !run.is_empty() {
out.push(Span::styled(run, run_style));
}
Line::from(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KvTableKind {
Params,
Headers,
Vars,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_kv_table(
rows: &mut Vec<Line<'static>>,
fields: &mut Vec<(Rect, PaneId, EditField)>,
area: Rect,
t: theme::Theme,
dim: Style,
data: &[(String, String)],
draft: Option<&crate::request_pane::InlineKvDraft>,
kv_edit: Option<&crate::request_pane::KvValueEdit>,
kind: KvTableKind,
hover_key: Option<&str>,
pane_id: PaneId,
focused: bool,
params_rows_local: &mut Vec<(Rect, String, KvTableKind)>,
envset: Option<&crate::http::template::EnvSet>,
var_clicks: Option<&mut Vec<(Rect, String)>>,
) {
const TABLE_MAX_W: u16 = 100;
const TABLE_RIGHT_PAD: u16 = 3;
let _table_x = area.x.saturating_add(2);
let table_w = area
.width
.saturating_sub(2)
.saturating_sub(TABLE_RIGHT_PAD)
.clamp(20, TABLE_MAX_W);
let x_col_w: u16 = 3;
let inner_w = table_w.saturating_sub(x_col_w).saturating_sub(4);
let name_w = (inner_w * 35 / 100).max(8);
let value_w = inner_w.saturating_sub(name_w);
let edit_field = match kind {
KvTableKind::Params => EditField::Url,
KvTableKind::Headers => EditField::Headers,
KvTableKind::Vars => EditField::Url,
};
let register = |fields: &mut Vec<(Rect, PaneId, EditField)>, row_y: u16| {
if !focused {
return;
}
fields.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
pane_id,
edit_field,
));
};
let make_border = |left: char, sep: char, right: char, fill: char| -> Line<'static> {
let n_seg: String = std::iter::repeat_n(fill, (name_w + 2) as usize).collect();
let v_seg: String = std::iter::repeat_n(fill, (value_w + 2) as usize).collect();
let x_seg: String = std::iter::repeat_n(fill, x_col_w as usize).collect();
Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
format!("{}{}{}{}{}{}{}", left, n_seg, sep, v_seg, sep, x_seg, right),
Style::default().fg(t.bg3).bg(t.bg_dark),
),
])
};
let assemble_row = |key_text: String,
key_style: Style,
value_spans: Vec<Span<'static>>,
x_glyph: &str,
x_style: Style|
-> Line<'static> {
let mut key_s = key_text;
if key_s.chars().count() > name_w as usize {
let truncated: String = key_s.chars().take(name_w as usize).collect();
key_s = truncated;
}
let pad_k = (name_w as usize).saturating_sub(key_s.chars().count());
let value_cells: usize = value_spans.iter().map(|s| s.content.chars().count()).sum();
let pad_v = (value_w as usize).saturating_sub(value_cells);
let border = Span::styled("│", Style::default().fg(t.bg3).bg(t.bg_dark));
let mut spans = vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
border.clone(),
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(key_s, key_style),
Span::styled(" ".repeat(pad_k), Style::default().bg(t.bg_dark)),
Span::styled(" ", Style::default().bg(t.bg_dark)),
border.clone(),
Span::styled(" ", Style::default().bg(t.bg_dark)),
];
spans.extend(value_spans);
spans.extend(vec![
Span::styled(" ".repeat(pad_v), Style::default().bg(t.bg_dark)),
Span::styled(" ", Style::default().bg(t.bg_dark)),
border.clone(),
Span::styled(x_glyph.to_string(), x_style),
border,
]);
Line::from(spans)
};
let make_row = |key_text: String,
val_text: String,
key_style: Style,
val_style: Style,
x_glyph: &str,
x_style: Style|
-> Line<'static> {
let mut val_s = val_text;
if val_s.chars().count() > value_w as usize {
let truncated: String = val_s.chars().take(value_w as usize).collect();
val_s = truncated;
}
assemble_row(
key_text,
key_style,
vec![Span::styled(val_s, val_style)],
x_glyph,
x_style,
)
};
let key_color_default = match kind {
KvTableKind::Params => t.fg,
KvTableKind::Headers => t.cyan,
KvTableKind::Vars => t.cyan,
};
rows.push(make_border('┌', '┬', '┐', '─'));
let hdr_style = Style::default()
.fg(t.comment)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
rows.push(make_row(
"Name".to_string(),
"Value".to_string(),
hdr_style,
hdr_style,
" ",
Style::default().bg(t.bg_dark),
));
rows.push(make_border('├', '┼', '┤', '─'));
let edit_target = kv_edit.filter(|e| {
matches!(
(kind, e.kind),
(KvTableKind::Params, crate::request_pane::KvEditKind::Params)
| (
KvTableKind::Headers,
crate::request_pane::KvEditKind::Headers
)
| (KvTableKind::Vars, crate::request_pane::KvEditKind::Vars)
)
});
let name_col_x_off: u16 = 2 + 1 + 1;
let value_col_x_off: u16 = 2 + 1 + 1 + name_w + 1 + 1 + 1;
let x_col_x_off: u16 = value_col_x_off + value_w + 1 + 1 + 1;
let var_resolved_style = Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let var_unresolved_style = Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let mut click_out_wrap = var_clicks;
for (i, (k, v)) in data.iter().enumerate() {
let is_hover = hover_key == Some(k.as_str());
let row_is_editing = edit_target.map(|e| e.original_key == *k).unwrap_or(false);
let editing_name = row_is_editing && edit_target.map(|e| e.editing_name).unwrap_or(false);
let editing_value = row_is_editing && !editing_name;
let name_display = if editing_name {
format!("{}▏", edit_target.unwrap().buffer)
} else {
k.clone()
};
let key_style = if editing_name {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else if is_hover {
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(key_color_default)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
};
let val_display = if editing_value {
format!("{}▏", edit_target.unwrap().buffer)
} else {
v.clone()
};
let val_style = if editing_value {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.fg).bg(t.bg_dark)
};
let x_style = Style::default().fg(t.red).bg(t.bg_dark);
let row_y = rows.len() as u16;
let use_vars = !editing_value && envset.is_some();
let value_spans: Vec<Span<'static>> = if use_vars {
let mut truncated = val_display.clone();
if truncated.chars().count() > value_w as usize {
let clipped: String = truncated.chars().take(value_w as usize).collect();
truncated = clipped;
}
let plain_style = val_style;
let click_target = click_out_wrap.as_deref_mut();
build_var_spans(
&truncated,
envset.unwrap(),
plain_style,
var_resolved_style,
var_unresolved_style,
area.x.saturating_add(value_col_x_off),
row_y,
click_target,
)
} else {
let mut truncated = val_display.clone();
if truncated.chars().count() > value_w as usize {
let clipped: String = truncated.chars().take(value_w as usize).collect();
truncated = clipped;
}
vec![Span::styled(truncated, val_style)]
};
rows.push(assemble_row(
name_display,
key_style,
value_spans,
" ✕ ",
x_style,
));
params_rows_local.push((
Rect {
x: area.x.saturating_add(name_col_x_off),
y: row_y,
width: name_w,
height: 1,
},
format!("\0NAME{k}"),
kind,
));
params_rows_local.push((
Rect {
x: area.x.saturating_add(value_col_x_off),
y: row_y,
width: value_w,
height: 1,
},
format!("\0VAL{k}"),
kind,
));
params_rows_local.push((
Rect {
x: area.x.saturating_add(x_col_x_off),
y: row_y,
width: 3,
height: 1,
},
format!("\0DEL{k}"),
kind,
));
register(fields, row_y);
if i + 1 < data.len() || draft.is_some() {
rows.push(make_border('├', '┼', '┤', '─'));
}
}
if let Some(draft) = draft {
let key_display = if draft.key.is_empty() && !draft.on_value {
"▏".to_string()
} else if draft.key.is_empty() {
"(name)".to_string()
} else if !draft.on_value {
format!("{}▏", draft.key)
} else {
draft.key.clone()
};
let val_display = if draft.value.is_empty() && draft.on_value {
"▏".to_string()
} else if draft.value.is_empty() {
"(value)".to_string()
} else if draft.on_value {
format!("{}▏", draft.value)
} else {
draft.value.clone()
};
let key_style = if draft.on_value {
Style::default().fg(t.comment).bg(t.bg_dark)
} else {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
};
let val_style = if draft.on_value {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg_dark)
};
let draft_y = rows.len() as u16;
let ready = !draft.key.trim().is_empty() && !draft.value.trim().is_empty();
let check_color = if ready { t.green } else { t.comment };
rows.push(make_row(
key_display,
val_display,
key_style,
val_style,
" ✓ ",
Style::default()
.fg(check_color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
let check_rect = Rect {
x: area.x.saturating_add(x_col_x_off),
y: draft_y,
width: 3,
height: 1,
};
params_rows_local.push((check_rect, "\0COMMIT".to_string(), kind));
}
rows.push(make_border('└', '┴', '┘', '─'));
if draft.is_none() {
let add_y = rows.len() as u16;
rows.push(add_action_row("Add row", t));
params_rows_local.push((
Rect {
x: area.x,
y: add_y,
width: area.width,
height: 1,
},
String::new(),
kind,
));
register(fields, add_y);
} else {
let hint_y = rows.len() as u16;
rows.push(Line::from(vec![Span::styled(
" (Tab · `:` · Enter → add + new row · Shift+Enter → done · Esc → cancel)"
.to_string(),
dim,
)]));
register(fields, hint_y);
}
}
fn add_action_row(label: &str, t: theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
format!("+ {label}"),
Style::default()
.fg(t.green)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
])
}
pub fn draw(
frame: &mut Frame,
app: &mut App,
pane_id: PaneId,
area: Rect,
focused: bool,
) -> Option<(u16, u16)> {
if area.width == 0 || area.height == 0 {
return None;
}
let t = theme::cur();
frame.render_widget(
Paragraph::new("").style(Style::default().bg(t.bg_dark)),
area,
);
let mut fields = std::mem::take(&mut app.rects.request_fields);
let Some(Pane::Request(rp)) = app.panes.get_mut(pane_id) else {
app.rects.request_fields = fields;
return None;
};
let mut caret: Option<(u16, u16)> = None;
let mut caret_abs: Option<(u16, u16)> = None;
let mut edit_tabs_local: Vec<(Rect, PaneId, crate::request_pane::EditTab)> = Vec::new();
app.rects
.request_edit_tabs
.retain(|(_, p, _)| *p != pane_id);
let show_ws = app.config.ui.show_whitespace;
let workspace = app.workspace.clone();
let env_override = app.http_env_override.clone();
let envset = crate::http::template::EnvSet::select_with_config_default(
&workspace,
env_override.as_deref(),
app.config.http.default_env.as_deref(),
);
let mut var_click_rects: Vec<(Rect, String)> = Vec::new();
let mut vars_rows_local: Vec<(Rect, String, KvTableKind)> = Vec::new();
let mut params_rows_local: Vec<(Rect, String, KvTableKind)> = Vec::new();
let mut auth_rows_local: Vec<(Rect, String)> = Vec::new();
let top_pad = if area.height >= 8 { 1u16 } else { 0u16 };
let top_bar_height = 3u16.min(area.height.saturating_sub(top_pad));
let ai_height = 3u16.min(
area.height
.saturating_sub(top_pad)
.saturating_sub(top_bar_height),
);
let middle_h = area
.height
.saturating_sub(top_pad)
.saturating_sub(top_bar_height)
.saturating_sub(ai_height);
let top_bar_rect = Rect {
x: area.x,
y: area.y.saturating_add(top_pad),
width: area.width,
height: top_bar_height,
};
let ai_rect = Rect {
x: area.x,
y: area
.y
.saturating_add(top_pad)
.saturating_add(top_bar_height)
.saturating_add(middle_h),
width: area.width,
height: ai_height,
};
let resolved_orient = rp.split_orientation.resolve(area.width);
let (request_rect, response_rect) = match resolved_orient {
crate::request_pane::SplitOrientation::Auto => unreachable!(),
crate::request_pane::SplitOrientation::Vertical => {
let req_h = (middle_h / 2).max(6.min(middle_h));
let res_h = middle_h.saturating_sub(req_h);
(
Rect {
x: area.x,
y: area
.y
.saturating_add(top_pad)
.saturating_add(top_bar_height),
width: area.width,
height: req_h,
},
Rect {
x: area.x,
y: area
.y
.saturating_add(top_pad)
.saturating_add(top_bar_height)
.saturating_add(req_h),
width: area.width,
height: res_h,
},
)
}
crate::request_pane::SplitOrientation::Horizontal => {
let req_w = area.width / 2;
let res_w = area.width.saturating_sub(req_w);
(
Rect {
x: area.x,
y: area
.y
.saturating_add(top_pad)
.saturating_add(top_bar_height),
width: req_w,
height: middle_h,
},
Rect {
x: area.x.saturating_add(req_w),
y: area
.y
.saturating_add(top_pad)
.saturating_add(top_bar_height),
width: res_w,
height: middle_h,
},
)
}
};
const METHOD_BOX_WIDTH: u16 = 14;
const MIN_URL_WIDTH: u16 = 20;
const METHOD_URL_ROW_H: u16 = 3;
const EDGE_PAD: u16 = 0;
const SEND_BOX_WIDTH: u16 = 10;
const SAVE_BOX_WIDTH: u16 = 10;
const CLEAR_BOX_WIDTH: u16 = 11;
const CODE_BOX_WIDTH: u16 = 16;
const ENV_BOX_WIDTH: u16 = 14;
let full_width_needed = METHOD_BOX_WIDTH
+ MIN_URL_WIDTH
+ ENV_BOX_WIDTH
+ SEND_BOX_WIDTH
+ SAVE_BOX_WIDTH
+ CLEAR_BOX_WIDTH
+ CODE_BOX_WIDTH
+ 2 * EDGE_PAD;
let medium_width_needed = METHOD_BOX_WIDTH + MIN_URL_WIDTH + SEND_BOX_WIDTH + 2 * EDGE_PAD;
let small_width_needed = METHOD_BOX_WIDTH + MIN_URL_WIDTH + 2 * EDGE_PAD;
#[derive(Copy, Clone, PartialEq, Eq)]
enum TopBarTier {
Full,
Medium,
Small,
None,
}
let tier = if top_bar_rect.height < METHOD_URL_ROW_H {
TopBarTier::None
} else if top_bar_rect.width >= full_width_needed {
TopBarTier::Full
} else if top_bar_rect.width >= medium_width_needed {
TopBarTier::Medium
} else if top_bar_rect.width >= small_width_needed {
TopBarTier::Small
} else {
TopBarTier::None
};
let show_sub_panels = tier == TopBarTier::Full;
let mut method_url_absolute: Vec<(Rect, EditField)> = Vec::new();
let mut send_button_rect: Option<Rect> = None;
let mut save_button_rect: Option<Rect> = None;
let mut clear_button_rect: Option<Rect> = None;
let mut code_button_rect: Option<Rect> = None;
let mut env_button_rect: Option<Rect> = None;
if show_sub_panels {
let row_y = top_bar_rect.y;
let method_rect = Rect {
x: top_bar_rect.x.saturating_add(EDGE_PAD),
y: row_y,
width: METHOD_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let url_x = method_rect.x.saturating_add(METHOD_BOX_WIDTH);
let url_width = top_bar_rect
.width
.saturating_sub(EDGE_PAD)
.saturating_sub(METHOD_BOX_WIDTH)
.saturating_sub(ENV_BOX_WIDTH)
.saturating_sub(SEND_BOX_WIDTH)
.saturating_sub(SAVE_BOX_WIDTH)
.saturating_sub(CLEAR_BOX_WIDTH)
.saturating_sub(CODE_BOX_WIDTH)
.saturating_sub(EDGE_PAD);
let url_rect = Rect {
x: url_x,
y: row_y,
width: url_width,
height: METHOD_URL_ROW_H,
};
let env_rect = Rect {
x: url_x.saturating_add(url_width),
y: row_y,
width: ENV_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let send_rect = Rect {
x: env_rect.x.saturating_add(ENV_BOX_WIDTH),
y: row_y,
width: SEND_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let save_rect = Rect {
x: send_rect.x.saturating_add(SEND_BOX_WIDTH),
y: row_y,
width: SAVE_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let clear_rect = Rect {
x: save_rect.x.saturating_add(SAVE_BOX_WIDTH),
y: row_y,
width: CLEAR_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let code_rect = Rect {
x: clear_rect.x.saturating_add(CLEAR_BOX_WIDTH),
y: row_y,
width: CODE_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
if let Some(mr) = draw_method_box(frame, rp, method_rect, focused, t) {
method_url_absolute.push((mr, EditField::Method));
app.rects.request_method_button = Some(mr);
}
if let Some(ur) = draw_url_box(
frame,
rp,
url_rect,
focused,
&mut caret_abs,
t,
&envset,
&mut var_click_rects,
) {
method_url_absolute.push((ur, EditField::Url));
}
env_button_rect = draw_env_box(
frame,
env_rect,
&workspace,
env_override.as_deref(),
&rp.request.url,
t,
);
send_button_rect = draw_send_box(frame, rp, send_rect, t);
save_button_rect = draw_save_box(frame, rp, save_rect, t);
clear_button_rect = draw_clear_box(frame, clear_rect, t);
code_button_rect = draw_code_box(frame, code_rect, t);
} else if matches!(tier, TopBarTier::Medium | TopBarTier::Small) {
let row_y = top_bar_rect.y;
let method_rect = Rect {
x: top_bar_rect.x.saturating_add(EDGE_PAD),
y: row_y,
width: METHOD_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
let tail_reserve = if tier == TopBarTier::Medium {
SEND_BOX_WIDTH
} else {
0
};
let url_x = method_rect.x.saturating_add(METHOD_BOX_WIDTH);
let url_width = top_bar_rect
.width
.saturating_sub(EDGE_PAD)
.saturating_sub(METHOD_BOX_WIDTH)
.saturating_sub(tail_reserve)
.saturating_sub(EDGE_PAD);
let url_rect = Rect {
x: url_x,
y: row_y,
width: url_width,
height: METHOD_URL_ROW_H,
};
if let Some(mr) = draw_method_box(frame, rp, method_rect, focused, t) {
method_url_absolute.push((mr, EditField::Method));
app.rects.request_method_button = Some(mr);
}
if let Some(ur) = draw_url_box(
frame,
rp,
url_rect,
focused,
&mut caret_abs,
t,
&envset,
&mut var_click_rects,
) {
method_url_absolute.push((ur, EditField::Url));
}
if tier == TopBarTier::Medium {
let send_rect = Rect {
x: url_x.saturating_add(url_width),
y: row_y,
width: SEND_BOX_WIDTH,
height: METHOD_URL_ROW_H,
};
send_button_rect = draw_send_box(frame, rp, send_rect, t);
}
}
let request_block = crate::ui::design_tokens::bordered_plain("");
let request_inner = request_block.inner(request_rect);
frame.render_widget(request_block, request_rect);
app.rects.request_split_toggle =
paint_split_toggle_chip(frame, rp.split_orientation, request_rect, t);
app.rects.request_edit_split_chip = paint_edit_split_chip(
frame,
rp.edit_tab_split.is_some(),
request_rect,
app.rects.request_split_toggle,
t,
);
let tabs_rect = request_inner;
let mut edit_tabs_split_local: Vec<(Rect, PaneId, crate::request_pane::EditTab)> = Vec::new();
let mut var_clicks_primary: Vec<(Rect, String)> = Vec::new();
if tabs_rect.width > 0 && tabs_rect.height > 0 {
let split_secondary = rp.edit_tab_split;
let (left_rect, divider_rect, right_rect) = if let Some(_secondary) = split_secondary {
const MIN_SIDE: u16 = 24;
if tabs_rect.width > MIN_SIDE * 2 {
let ratio = rp.edit_split_ratio.clamp(10, 90) as u32;
let left_w = ((tabs_rect.width as u32 * ratio) / 100) as u16;
let left_w = left_w.max(MIN_SIDE).min(tabs_rect.width - MIN_SIDE - 1);
let left = Rect {
x: tabs_rect.x,
y: tabs_rect.y,
width: left_w,
height: tabs_rect.height,
};
let divider = Rect {
x: tabs_rect.x + left_w,
y: tabs_rect.y,
width: 1,
height: tabs_rect.height,
};
let right = Rect {
x: tabs_rect.x + left_w + 1,
y: tabs_rect.y,
width: tabs_rect.width - left_w - 1,
height: tabs_rect.height,
};
(left, Some(divider), Some(right))
} else {
(tabs_rect, None, None)
}
} else {
(tabs_rect, None, None)
};
let mut edit_rows: Vec<Line> = Vec::new();
draw_edit(
rp,
t,
&mut edit_rows,
left_rect,
&mut caret,
focused,
pane_id,
&mut fields,
&mut edit_tabs_local,
show_ws,
&workspace,
env_override.as_deref(),
&mut vars_rows_local,
&mut params_rows_local,
&mut auth_rows_local,
&envset,
&mut var_clicks_primary,
None,
);
let edit_view: Vec<Line> = edit_rows
.iter()
.take(left_rect.height as usize)
.cloned()
.collect();
frame.render_widget(
Paragraph::new(edit_view).style(Style::default().bg(t.bg_dark)),
left_rect,
);
app.rects.request_format_button = paint_body_format_chip(frame, rp, left_rect, t);
app.rects.request_regenerate_button =
paint_body_regenerate_chip(frame, rp, left_rect, t, app.rects.request_format_button);
if let Some(div_rect) = divider_rect {
let divider_line = Line::from(Span::styled(
"\u{2502}".to_string(),
Style::default().fg(t.bg3).bg(t.bg_dark),
));
let divider_rows: Vec<Line> =
(0..div_rect.height).map(|_| divider_line.clone()).collect();
frame.render_widget(
Paragraph::new(divider_rows).style(Style::default().bg(t.bg_dark)),
div_rect,
);
app.rects.request_edit_split_divider = Some(div_rect);
}
if let (Some(secondary), Some(right)) = (split_secondary, right_rect) {
let mut edit_rows_r: Vec<Line> = Vec::new();
let mut fields_r: Vec<(Rect, PaneId, EditField)> = Vec::new();
let mut vars_rows_r: Vec<(Rect, String, KvTableKind)> = Vec::new();
let mut params_rows_r: Vec<(Rect, String, KvTableKind)> = Vec::new();
let mut auth_rows_r: Vec<(Rect, String)> = Vec::new();
let mut var_clicks_r: Vec<(Rect, String)> = Vec::new();
let mut caret_r: Option<(u16, u16)> = None;
draw_edit(
rp,
t,
&mut edit_rows_r,
right,
&mut caret_r,
false, pane_id,
&mut fields_r,
&mut edit_tabs_split_local,
show_ws,
&workspace,
env_override.as_deref(),
&mut vars_rows_r,
&mut params_rows_r,
&mut auth_rows_r,
&envset,
&mut var_clicks_r,
Some(secondary),
);
let edit_view_r: Vec<Line> = edit_rows_r
.iter()
.take(right.height as usize)
.cloned()
.collect();
frame.render_widget(
Paragraph::new(edit_view_r).style(Style::default().bg(t.bg_dark)),
right,
);
let right_h = right.height as usize;
let right_origin = right.y;
for (mut r, pid, f) in fields_r.drain(..) {
let row_off = r.y as usize;
if row_off >= right_h {
continue;
}
r.y = right_origin.saturating_add(row_off as u16);
app.rects.request_fields.push((r, pid, f));
}
for (mut r, key, kind) in vars_rows_r.drain(..) {
let row_off = r.y as usize;
if row_off >= right_h {
continue;
}
r.y = right_origin.saturating_add(row_off as u16);
app.rects.request_vars_rows.push((r, key, kind));
}
for (mut r, key, kind) in params_rows_r.drain(..) {
let row_off = r.y as usize;
if row_off >= right_h {
continue;
}
r.y = right_origin.saturating_add(row_off as u16);
app.rects.request_params_rows.push((r, key, kind));
}
for (mut r, id) in auth_rows_r.drain(..) {
let row_off = r.y as usize;
if row_off >= right_h {
continue;
}
r.y = right_origin.saturating_add(row_off as u16);
app.rects.request_auth_rows.push((r, id));
}
for (mut r, name) in var_clicks_r.drain(..) {
let row_off = r.y as usize;
if row_off >= right_h {
continue;
}
r.y = right_origin.saturating_add(row_off as u16);
app.rects.request_var_click_rects.push((r, name));
}
}
} else {
app.rects.request_format_button = None;
app.rects.request_regenerate_button = None;
}
let response_block = {
let mut block = crate::ui::design_tokens::bordered_plain("");
if let Some(status_line) = response_status_title(rp, t) {
block = block.title_top(ratatui::text::Line::from(status_line).right_aligned());
}
block
};
let response_inner = response_block.inner(response_rect);
frame.render_widget(response_block, response_rect);
let content_inner = if response_inner.height >= 3 {
let mut type_chip: Option<Rect> = None;
let mut copy_chip: Option<Rect> = None;
let mut wrap_chip: Option<Rect> = None;
let mut ai_chip: Option<Rect> = None;
app.rects.request_response_tabs = paint_response_tab_strip(
frame,
rp,
response_inner,
t,
&mut type_chip,
&mut copy_chip,
&mut wrap_chip,
&mut ai_chip,
);
app.rects.request_response_type_chip = type_chip;
app.rects.request_response_copy_chip = copy_chip;
app.rects.request_response_wrap_chip = wrap_chip;
app.rects.request_response_ai_prompt_chip = ai_chip;
Rect {
x: response_inner.x,
y: response_inner.y.saturating_add(2),
width: response_inner.width,
height: response_inner.height.saturating_sub(2),
}
} else {
app.rects.request_response_tabs.clear();
response_inner
};
let mut response_rows: Vec<Line> = Vec::new();
if content_inner.width > 0 && content_inner.height > 0 {
if !rp.filter.is_empty() || rp.filter_focused {
let hits = compute_filter_hits(rp);
response_rows.push(filter_row(&rp.filter, rp.filter_focused, hits, t));
}
let wrap_width = if rp.body_wrap {
Some(content_inner.width.saturating_sub(2).max(20))
} else {
None
};
draw_response(rp, t, &mut response_rows, wrap_width);
let h = content_inner.height as usize;
let max_scroll = response_rows
.len()
.saturating_sub(h.min(response_rows.len()));
rp.scroll = rp.scroll.min(max_scroll);
let scroll = rp.scroll;
let response_view: Vec<Line> = response_rows.into_iter().skip(scroll).take(h).collect();
frame.render_widget(
Paragraph::new(response_view).style(Style::default().bg(t.bg_dark)),
content_inner,
);
}
let ai_block = crate::ui::design_tokens::bordered_plain("AI");
let ai_inner = ai_block.inner(ai_rect);
frame.render_widget(ai_block, ai_rect);
if ai_inner.width > 0 && ai_inner.height > 0 {
let ai_line = Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
"click here to ask a custom question".to_string(),
crate::ui::design_tokens::hint_style(),
),
Span::styled(
" \u{00B7} `a` quick debug".to_string(),
Style::default().fg(t.comment).bg(t.bg_dark),
),
]);
frame.render_widget(
Paragraph::new(vec![ai_line]).style(Style::default().bg(t.bg_dark)),
ai_inner,
);
app.rects.request_ai_section = Some(ai_inner);
} else {
app.rects.request_ai_section = None;
}
app.rects.editor_panes.push((area, pane_id));
app.rects.request_var_click_rects.extend(var_click_rects);
let edit_h = tabs_rect.height as usize;
let edit_origin_y = tabs_rect.y;
for (mut r, pid, f) in fields.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_fields.push((r, pid, f));
}
for (rect, field) in method_url_absolute.drain(..) {
app.rects.request_fields.push((rect, pane_id, field));
}
app.rects.request_send_button = send_button_rect;
app.rects.request_save_button = save_button_rect;
app.rects.request_clear_button = clear_button_rect;
app.rects.request_code_button = code_button_rect;
app.rects.request_env_button = env_button_rect;
for (mut r, pid, tab) in edit_tabs_local.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_edit_tabs.push((r, pid, tab));
}
for (mut r, name) in var_clicks_primary.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_var_click_rects.push((r, name));
}
for (mut r, pid, tab) in edit_tabs_split_local.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_edit_tabs_split.push((r, pid, tab));
}
for (mut r, key, kind) in vars_rows_local.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_vars_rows.push((r, key, kind));
}
for (mut r, key, kind) in params_rows_local.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_params_rows.push((r, key, kind));
}
for (mut r, id) in auth_rows_local.drain(..) {
let row_off = r.y as usize;
if row_off >= edit_h {
continue;
}
r.y = edit_origin_y.saturating_add(row_off as u16);
app.rects.request_auth_rows.push((r, id));
}
if caret_abs.is_some() {
return caret_abs;
}
caret.map(|(x, y)| {
let y = (y as usize).min(edit_h.saturating_sub(1)) as u16;
(x, edit_origin_y.saturating_add(y))
})
}
fn response_status_title(
rp: &crate::request_pane::RequestPane,
t: theme::Theme,
) -> Option<Vec<Span<'static>>> {
match &rp.state {
RunState::Sending => Some(vec![Span::styled(
" \u{27F3} sending\u{2026} ",
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]),
RunState::Streaming(r) => Some(vec![Span::styled(
format!(" \u{25B6} streaming \u{00B7} {} events ", r.sse_event_count),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]),
RunState::Failed(e) if is_not_sent_placeholder(e) => None,
RunState::Failed(_) => Some(vec![Span::styled(
" \u{2717} failed ",
Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]),
RunState::Done(r) => {
let status_color = match r.status {
200..=299 => t.green,
300..=399 => t.yellow,
400..=499 => t.orange,
500..=599 => t.red,
_ => t.bg3,
};
let sep = |t: theme::Theme| {
Span::styled(" \u{00B7} ", Style::default().fg(t.comment).bg(t.bg_dark))
};
Some(vec![
Span::styled(
format!(" {} {} ", r.status, r.status_text),
Style::default()
.fg(status_color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
sep(t),
Span::styled(
format!("{}ms", r.elapsed.as_millis()),
Style::default().fg(t.comment).bg(t.bg_dark),
),
sep(t),
Span::styled(
format!("{} ", human_bytes(r.body.len())),
Style::default().fg(t.comment).bg(t.bg_dark),
),
])
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_response_tab_strip(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
response_inner: Rect,
t: theme::Theme,
type_chip_out: &mut Option<Rect>,
copy_chip_out: &mut Option<Rect>,
wrap_chip_out: &mut Option<Rect>,
ai_chip_out: &mut Option<Rect>,
) -> Vec<(Rect, crate::request_pane::ResponseTab)> {
*type_chip_out = None;
*copy_chip_out = None;
*wrap_chip_out = None;
*ai_chip_out = None;
let mut rects = Vec::new();
if response_inner.width == 0 || response_inner.height < 2 {
return rects;
}
let active = rp.response_tab;
let label_rect = Rect {
x: response_inner.x,
y: response_inner.y,
width: response_inner.width,
height: 1,
};
let bar_rect = Rect {
x: response_inner.x,
y: response_inner.y.saturating_add(1),
width: response_inner.width,
height: 1,
};
let header_count: Option<usize> = match &rp.state {
RunState::Done(r) if !r.headers.is_empty() => Some(r.headers.len()),
_ => None,
};
let mut label_spans: Vec<Span> = Vec::new();
label_spans.push(Span::styled(" ", Style::default().bg(t.bg_dark)));
let mut bar_spans: Vec<Span> = Vec::new();
bar_spans.push(Span::styled(" ", Style::default().bg(t.bg_dark)));
let mut col: u16 = 2;
for tab in crate::request_pane::ResponseTab::ALL {
let base = tab.label();
let label = if matches!(tab, crate::request_pane::ResponseTab::Headers)
&& let Some(n) = header_count
{
format!("{base} {n}")
} else {
base.to_string()
};
let is_cur = active == *tab;
let label_style = if is_cur {
Style::default()
.fg(t.fg)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg_dark)
};
let chip_w = label.chars().count() as u16;
label_spans.push(Span::styled(label.clone(), label_style));
label_spans.push(Span::styled(
" ".to_string(),
Style::default().bg(t.bg_dark),
));
let bar_glyph = if is_cur { "\u{2501}" } else { " " };
let bar_style = if is_cur {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(t.bg_dark)
};
bar_spans.push(Span::styled(bar_glyph.repeat(chip_w as usize), bar_style));
bar_spans.push(Span::styled(
" ".to_string(),
Style::default().bg(t.bg_dark),
));
rects.push((
Rect {
x: response_inner.x.saturating_add(col),
y: response_inner.y,
width: chip_w,
height: 1,
},
*tab,
));
col += chip_w + 2;
}
frame.render_widget(
Paragraph::new(vec![Line::from(label_spans)]).style(Style::default().bg(t.bg_dark)),
label_rect,
);
frame.render_widget(
Paragraph::new(vec![Line::from(bar_spans)]).style(Style::default().bg(t.bg_dark)),
bar_rect,
);
let type_label = effective_response_type_label(rp);
let type_text = format!(" {type_label} \u{25BC} ");
let type_w = type_text.chars().count() as u16;
let copy_text = " copy ".to_string();
let copy_w = copy_text.chars().count() as u16;
let wrap_text = " wrap ".to_string();
let wrap_w = wrap_text.chars().count() as u16;
let mut right_edge = label_rect
.x
.saturating_add(label_rect.width)
.saturating_sub(1);
if label_rect.width >= type_w + 2 {
let chip_x = right_edge.saturating_sub(type_w);
let chip_rect = Rect {
x: chip_x,
y: label_rect.y,
width: type_w,
height: 1,
};
*type_chip_out = Some(chip_rect);
frame.render_widget(
Paragraph::new(vec![Line::from(vec![Span::styled(
type_text,
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)])])
.style(Style::default().bg(t.bg_dark)),
chip_rect,
);
right_edge = chip_x.saturating_sub(1);
}
if right_edge > label_rect.x + copy_w + 2 {
let chip_x = right_edge.saturating_sub(copy_w);
let chip_rect = Rect {
x: chip_x,
y: label_rect.y,
width: copy_w,
height: 1,
};
*copy_chip_out = Some(chip_rect);
frame.render_widget(
Paragraph::new(vec![Line::from(vec![Span::styled(
copy_text,
Style::default().fg(t.comment).bg(t.bg_dark),
)])])
.style(Style::default().bg(t.bg_dark)),
chip_rect,
);
right_edge = chip_x.saturating_sub(1);
}
if right_edge > label_rect.x + wrap_w + 2 {
let chip_x = right_edge.saturating_sub(wrap_w);
let chip_rect = Rect {
x: chip_x,
y: label_rect.y,
width: wrap_w,
height: 1,
};
*wrap_chip_out = Some(chip_rect);
let wrap_style = if rp.body_wrap {
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg_dark)
};
frame.render_widget(
Paragraph::new(vec![Line::from(vec![Span::styled(wrap_text, wrap_style)])])
.style(Style::default().bg(t.bg_dark)),
chip_rect,
);
right_edge = chip_x.saturating_sub(1);
}
if is_response_failure(rp) {
let ai_text = " \u{26A1} AI ".to_string();
let ai_w = ai_text.chars().count() as u16;
if right_edge > label_rect.x + ai_w + 2 {
let chip_x = right_edge.saturating_sub(ai_w);
let chip_rect = Rect {
x: chip_x,
y: label_rect.y,
width: ai_w,
height: 1,
};
frame.render_widget(
Paragraph::new(vec![Line::from(vec![Span::styled(
ai_text,
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)])])
.style(Style::default().bg(t.bg_dark)),
chip_rect,
);
*ai_chip_out = Some(chip_rect);
}
}
rects
}
fn is_response_failure(rp: &crate::request_pane::RequestPane) -> bool {
use crate::http::schema::SchemaStatus;
use crate::request_pane::RunState;
match &rp.state {
RunState::Done(r) => {
!(200..300).contains(&r.status)
|| r.schema_result
.as_ref()
.is_some_and(|s| matches!(s.status, SchemaStatus::Invalid))
}
RunState::Failed(_) => true,
_ => false,
}
}
fn effective_response_type_label(rp: &crate::request_pane::RequestPane) -> String {
use crate::request_pane::ResponseBodyFormat;
match rp.response_body_format {
ResponseBodyFormat::Auto => detect_response_content_type(rp),
ResponseBodyFormat::Json => "JSON".to_string(),
ResponseBodyFormat::Xml => "XML".to_string(),
ResponseBodyFormat::Html => "HTML".to_string(),
ResponseBodyFormat::Text => "TEXT".to_string(),
}
}
fn detect_response_content_type(rp: &crate::request_pane::RequestPane) -> String {
let r = match &rp.state {
RunState::Done(r) => r,
RunState::Streaming(r) => r,
_ => return "\u{2014}".to_string(),
};
for (k, v) in &r.headers {
if k.eq_ignore_ascii_case("content-type") {
let vlow = v.to_ascii_lowercase();
if vlow.contains("json") {
return "JSON".to_string();
}
if vlow.contains("html") {
return "HTML".to_string();
}
if vlow.contains("xml") {
return "XML".to_string();
}
if vlow.contains("javascript") || vlow.contains("ecmascript") {
return "JS".to_string();
}
if vlow.contains("css") {
return "CSS".to_string();
}
if vlow.starts_with("image/") {
return "IMAGE".to_string();
}
if vlow.starts_with("video/") {
return "VIDEO".to_string();
}
if vlow.starts_with("audio/") {
return "AUDIO".to_string();
}
if vlow.contains("pdf") {
return "PDF".to_string();
}
if vlow.contains("octet-stream")
|| vlow.contains("zip")
|| vlow.contains("gzip")
|| vlow.contains("tar")
|| vlow.contains("protobuf")
|| vlow.contains("msgpack")
{
return "BINARY".to_string();
}
if vlow.contains("plain") || vlow.contains("text/") {
return "TEXT".to_string();
}
}
}
let head = r.body.trim_start();
match head.chars().next() {
Some('{') | Some('[') => "JSON".to_string(),
Some('<') => "XML".to_string(),
_ => "TEXT".to_string(),
}
}
fn paint_split_toggle_chip(
frame: &mut Frame,
orient: crate::request_pane::SplitOrientation,
request_rect: Rect,
t: theme::Theme,
) -> Option<Rect> {
let chip_w: u16 = 7;
if request_rect.width < chip_w + 4 || request_rect.height == 0 {
return None;
}
let chip_x = request_rect
.x
.saturating_add(request_rect.width)
.saturating_sub(chip_w)
.saturating_sub(2);
let chip_rect = Rect {
x: chip_x,
y: request_rect.y,
width: chip_w,
height: 1,
};
let auto_active = matches!(orient, crate::request_pane::SplitOrientation::Auto);
let vert_active = matches!(orient, crate::request_pane::SplitOrientation::Vertical);
let horiz_active = matches!(orient, crate::request_pane::SplitOrientation::Horizontal);
let active_style = Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let inactive_style = Style::default().fg(t.comment).bg(t.bg_dark);
let bracket = Style::default().fg(t.bg3).bg(t.bg_dark);
let line = Line::from(vec![
Span::styled("[", bracket),
Span::styled(
"A",
if auto_active {
active_style
} else {
inactive_style
},
),
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
"▥",
if vert_active {
active_style
} else {
inactive_style
},
),
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
"▤",
if horiz_active {
active_style
} else {
inactive_style
},
),
Span::styled("]", bracket),
]);
frame.render_widget(
Paragraph::new(vec![line]).style(Style::default().bg(t.bg_dark)),
chip_rect,
);
Some(chip_rect)
}
fn paint_edit_split_chip(
frame: &mut Frame,
split_open: bool,
request_rect: Rect,
orient_chip: Option<Rect>,
t: theme::Theme,
) -> Option<Rect> {
let chip_w: u16 = 3; let right_edge_x = match orient_chip {
Some(r) => r.x,
None => request_rect
.x
.saturating_add(request_rect.width)
.saturating_sub(2),
};
if right_edge_x <= request_rect.x + 4 || request_rect.height == 0 {
return None;
}
let chip_x = right_edge_x.saturating_sub(chip_w).saturating_sub(1);
let chip_rect = Rect {
x: chip_x,
y: request_rect.y,
width: chip_w,
height: 1,
};
let bracket = Style::default().fg(t.bg3).bg(t.bg_dark);
let icon_style = if split_open {
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg_dark)
};
let line = Line::from(vec![
Span::styled("[", bracket),
Span::styled("\u{21D4}", icon_style),
Span::styled("]", bracket),
]);
frame.render_widget(
Paragraph::new(vec![line]).style(Style::default().bg(t.bg_dark)),
chip_rect,
);
Some(chip_rect)
}
fn paint_body_format_chip(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
tabs_rect: Rect,
t: theme::Theme,
) -> Option<Rect> {
if rp.edit_tab != crate::request_pane::EditTab::Body {
return None;
}
let body = rp.request.body.as_deref().unwrap_or("");
if !matches!(detect_body_kind(body), Some("JSON")) {
return None;
}
let chip_text = " { } Format ";
let chip_w = chip_text.chars().count() as u16;
if tabs_rect.width < chip_w + 2 || tabs_rect.height == 0 {
return None;
}
let chip_x = tabs_rect
.x
.saturating_add(tabs_rect.width)
.saturating_sub(chip_w)
.saturating_sub(1); let chip_y = tabs_rect.y;
let chip_rect = Rect {
x: chip_x,
y: chip_y,
width: chip_w,
height: 1,
};
let line = Line::from(vec![Span::styled(
chip_text.to_string(),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(
Paragraph::new(vec![line]).style(Style::default().bg(t.bg_dark)),
chip_rect,
);
Some(chip_rect)
}
fn paint_body_regenerate_chip(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
tabs_rect: Rect,
t: theme::Theme,
format_chip: Option<Rect>,
) -> Option<Rect> {
if rp.edit_tab != crate::request_pane::EditTab::Body {
return None;
}
let body = rp.request.body.as_deref().unwrap_or("");
if !matches!(detect_body_kind(body), Some("JSON")) {
return None;
}
let chip_text = " \u{21BB} Regenerate ";
let chip_w = chip_text.chars().count() as u16;
let (chip_x, chip_y) = if let Some(fc) = format_chip {
let x = fc.x.saturating_sub(chip_w).saturating_sub(1);
if x < tabs_rect.x + 1 {
return None;
}
(x, fc.y)
} else {
let x = tabs_rect
.x
.saturating_add(tabs_rect.width)
.saturating_sub(chip_w)
.saturating_sub(1);
if x < tabs_rect.x + 1 {
return None;
}
(x, tabs_rect.y)
};
let chip_rect = Rect {
x: chip_x,
y: chip_y,
width: chip_w,
height: 1,
};
let line = Line::from(vec![Span::styled(
chip_text.to_string(),
Style::default()
.fg(t.green)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(
Paragraph::new(vec![line]).style(Style::default().bg(t.bg_dark)),
chip_rect,
);
Some(chip_rect)
}
fn draw_save_box(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
rect: Rect,
t: theme::Theme,
) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Save");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let color = if rp.request.url.trim().is_empty() {
t.comment
} else {
t.blue
};
let text = " \u{2398} Save ";
let text_w = text.chars().count() as u16;
let mid_pad = inner.width.saturating_sub(text_w) / 2;
let content = Line::from(vec![
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled(
text.to_string(),
Style::default()
.fg(color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
fn draw_env_box(
frame: &mut Frame,
rect: Rect,
workspace: &std::path::Path,
env_override: Option<&str>,
url: &str,
t: theme::Theme,
) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Env");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let envset = crate::http::template::EnvSet::select(workspace, env_override);
let env_name = envset.name().map(str::to_string);
let has_override = env_override.is_some();
let has_unresolved = has_unresolved_var(url, &envset);
let (label, color) = match env_name {
_ if has_unresolved => (env_name.unwrap_or_else(|| "none".to_string()), t.yellow),
Some(n) if has_override => (n, t.cyan),
Some(n) => (n, t.fg),
None => ("none".to_string(), t.comment),
};
let short = if label.chars().count() > 6 {
let mut s: String = label.chars().take(5).collect();
s.push('\u{2026}');
s
} else {
label
};
let text = format!(" {short} \u{25BE} ");
let text_w = text.chars().count() as u16;
let mid_pad = inner.width.saturating_sub(text_w) / 2;
let content = Line::from(vec![
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled(
text,
Style::default()
.fg(color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
fn has_unresolved_var(text: &str, envset: &crate::http::template::EnvSet) -> bool {
!crate::http::template::unresolved(text, envset).is_empty()
}
#[derive(Debug, Clone)]
struct VarToken {
start: usize,
end: usize,
name: String,
resolved: bool,
}
fn tokenize_vars(text: &str, envset: &crate::http::template::EnvSet) -> Vec<VarToken> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len()
&& bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = text[i + 2..].find("}}")
{
let inner_start = i + 2;
let inner_end = inner_start + end_off;
let name = text[inner_start..inner_end].trim().to_string();
if !name.is_empty() {
let resolved = match name.strip_prefix('$') {
Some(dyn_name) => crate::http::template::dynamic_var(dyn_name).is_some(),
None => envset.lookup(&name).is_some(),
};
out.push(VarToken {
start: i,
end: inner_end + 2,
name,
resolved,
});
}
i = inner_end + 2;
continue;
}
let c = text[i..].chars().next().unwrap();
i += c.len_utf8();
}
out
}
#[allow(clippy::too_many_arguments)]
fn build_var_spans(
text: &str,
envset: &crate::http::template::EnvSet,
plain: Style,
resolved_style: Style,
unresolved_style: Style,
base_x: u16,
base_y: u16,
click_out: Option<&mut Vec<(Rect, String)>>,
) -> Vec<Span<'static>> {
let tokens = tokenize_vars(text, envset);
if tokens.is_empty() {
return vec![Span::styled(text.to_string(), plain)];
}
let mut spans: Vec<Span<'static>> = Vec::with_capacity(tokens.len() * 2 + 1);
let mut cur = 0usize;
let char_x_at = |slice: &str| -> u16 { slice.chars().count() as u16 };
let clicks = click_out;
let (mut push_click_rect, has_click): (Box<dyn FnMut(Rect, String)>, bool) = match clicks {
Some(v) => (Box::new(move |r, n| v.push((r, n))), true),
None => (Box::new(|_r, _n| {}), false),
};
for tok in &tokens {
if tok.start > cur {
let leading = &text[cur..tok.start];
spans.push(Span::styled(leading.to_string(), plain));
}
let token_text = &text[tok.start..tok.end];
let style = if tok.resolved {
resolved_style
} else {
unresolved_style
};
spans.push(Span::styled(token_text.to_string(), style));
if has_click {
let before = &text[..tok.start];
let x = base_x + char_x_at(before);
let width = char_x_at(token_text);
push_click_rect(
Rect {
x,
y: base_y,
width,
height: 1,
},
tok.name.clone(),
);
}
cur = tok.end;
}
if cur < text.len() {
spans.push(Span::styled(text[cur..].to_string(), plain));
}
spans
}
fn draw_clear_box(frame: &mut Frame, rect: Rect, t: theme::Theme) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Clear");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let text = " \u{2715} Clear ";
let text_w = text.chars().count() as u16;
let mid_pad = inner.width.saturating_sub(text_w) / 2;
let content = Line::from(vec![
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled(
text.to_string(),
Style::default()
.fg(t.orange)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
fn draw_code_box(frame: &mut Frame, rect: Rect, t: theme::Theme) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Copy as…");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let text = " </> Copy as… ";
let text_w = text.chars().count() as u16;
let mid_pad = inner.width.saturating_sub(text_w) / 2;
let content = Line::from(vec![
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled(
text.to_string(),
Style::default()
.fg(t.purple)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
fn draw_send_box(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
rect: Rect,
t: theme::Theme,
) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Send");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let (glyph, gap, label, color) = match &rp.state {
crate::request_pane::RunState::Sending => ("\u{27F3}", " ", "Abort", t.yellow),
crate::request_pane::RunState::Streaming(_) => ("\u{25B6}", " ", "Send", t.cyan),
_ if rp.request.url.trim().is_empty() => ("\u{25B6}", " ", "Send", t.comment),
_ => ("\u{25B6}", " ", "Send", t.green),
};
let text = format!(" {glyph}{gap}{label} ");
let text_w = text.chars().count() as u16;
let mid_pad = inner.width.saturating_sub(text_w) / 2;
let content = Line::from(vec![
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled(
text,
Style::default()
.fg(color)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
fn draw_method_box(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
rect: Rect,
_focused: bool,
t: theme::Theme,
) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("Method");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let method = rp.request.method.to_uppercase();
let m_color = method_color(&method, t);
let chip_text = format!(" {method} ");
let chip_width = chip_text.chars().count() as u16;
let mid_pad = inner
.width
.saturating_sub(1) .saturating_sub(chip_width)
.saturating_sub(2); let content = Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
chip_text,
Style::default()
.fg(t.bg_dark)
.bg(m_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(mid_pad as usize), Style::default().bg(t.bg_dark)),
Span::styled("\u{25BC}", Style::default().fg(t.comment).bg(t.bg_dark)),
Span::styled(" ", Style::default().bg(t.bg_dark)),
]);
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
Some(inner)
}
#[allow(clippy::too_many_arguments)]
fn draw_url_box(
frame: &mut Frame,
rp: &crate::request_pane::RequestPane,
rect: Rect,
focused: bool,
caret: &mut Option<(u16, u16)>,
t: theme::Theme,
envset: &crate::http::template::EnvSet,
var_clicks: &mut Vec<(Rect, String)>,
) -> Option<Rect> {
let block = crate::ui::design_tokens::bordered_plain("URL");
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return None;
}
let url_text = rp.request.url.clone();
let content = if url_text.is_empty() {
Line::from(vec![
Span::styled(" ", Style::default().bg(t.bg_dark)),
Span::styled(
"Enter request URL".to_string(),
Style::default()
.fg(t.comment)
.bg(t.bg_dark)
.add_modifier(Modifier::ITALIC),
),
])
} else {
let plain = Style::default().fg(t.fg).bg(t.bg_dark);
let resolved = Style::default().fg(t.cyan).bg(t.bg_dark);
let unresolved = Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let mut spans = vec![Span::styled(" ", Style::default().bg(t.bg_dark))];
let mut url_var_clicks: Vec<(Rect, String)> = Vec::new();
spans.extend(build_var_spans(
&url_text,
envset,
plain,
resolved,
unresolved,
inner.x + 1,
inner.y,
Some(&mut url_var_clicks),
));
let url_right_edge = inner.x.saturating_add(inner.width);
for (mut r, name) in url_var_clicks {
if r.x >= url_right_edge {
continue;
}
let max_w = url_right_edge.saturating_sub(r.x);
if r.width > max_w {
r.width = max_w;
}
var_clicks.push((r, name));
}
Line::from(spans)
};
frame.render_widget(
Paragraph::new(vec![content]).style(Style::default().bg(t.bg_dark)),
inner,
);
if focused && rp.focus == EditField::Url {
let caret_col = 1u16 + url_chars_before_cursor(&url_text, rp.url_cursor) as u16;
let cx = inner.x + caret_col.min(inner.width.saturating_sub(1));
*caret = Some((cx, inner.y));
}
Some(inner)
}
#[allow(clippy::too_many_arguments)]
fn draw_edit(
rp: &crate::request_pane::RequestPane,
t: theme::Theme,
rows: &mut Vec<Line<'static>>,
area: Rect,
caret: &mut Option<(u16, u16)>,
focused: bool,
pane_id: PaneId,
fields: &mut Vec<(Rect, PaneId, EditField)>,
tabs: &mut Vec<(Rect, PaneId, crate::request_pane::EditTab)>,
show_ws: bool,
workspace: &std::path::Path,
env_override: Option<&str>,
vars_rows_local: &mut Vec<(Rect, String, KvTableKind)>,
params_rows_local: &mut Vec<(Rect, String, KvTableKind)>,
auth_rows_local: &mut Vec<(Rect, String)>,
envset: &crate::http::template::EnvSet,
var_clicks_local: &mut Vec<(Rect, String)>,
tab_override: Option<crate::request_pane::EditTab>,
) {
let register_field =
|fields: &mut Vec<(Rect, PaneId, EditField)>, row_y: u16, field: EditField| {
if !focused {
return;
}
fields.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
pane_id,
field,
));
};
let body_style = Style::default().fg(t.fg).bg(t.bg_dark);
let plain = |s: String, st: Style| Line::from(Span::styled(s, st));
let dim = Style::default().fg(t.comment).bg(t.bg_dark);
let _ = t;
let _ = caret;
{
use crate::request_pane::EditTab;
let label_y = rows.len() as u16;
let bar_y = label_y + 1;
let mut label_spans: Vec<Span> = Vec::new();
let mut bar_spans: Vec<Span> = Vec::new();
let mut col: u16 = 2;
label_spans.push(Span::styled(" ", Style::default().bg(t.bg_dark)));
bar_spans.push(Span::styled(" ", Style::default().bg(t.bg_dark)));
let strip_tab = tab_override.unwrap_or(rp.edit_tab);
for tab in EditTab::ALL {
let label = tab.label();
let is_cur = strip_tab == *tab;
let label_style = if is_cur {
Style::default()
.fg(t.fg)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.comment).bg(t.bg_dark)
};
let chip_w = label.chars().count() as u16;
label_spans.push(Span::styled(label.to_string(), label_style));
label_spans.push(Span::styled(
" ".to_string(),
Style::default().bg(t.bg_dark),
));
let bar_glyph = if is_cur { "\u{2501}" } else { " " };
let bar_style = if is_cur {
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(t.bg_dark)
};
bar_spans.push(Span::styled(bar_glyph.repeat(chip_w as usize), bar_style));
bar_spans.push(Span::styled(
" ".to_string(),
Style::default().bg(t.bg_dark),
));
tabs.push((
Rect {
x: area.x + col,
y: label_y,
width: chip_w,
height: 1,
},
pane_id,
*tab,
));
col += chip_w + 2;
}
rows.push(Line::from(label_spans));
rows.push(Line::from(bar_spans));
let _ = bar_y;
}
let cur_tab = tab_override.unwrap_or(rp.edit_tab);
if cur_tab == crate::request_pane::EditTab::Headers {
let headers: Vec<(String, String)> = rp
.headers_buffer
.lines()
.filter_map(|l| {
let t = l.trim();
if t.is_empty() || t.starts_with('#') {
return None;
}
let (k, v) = crate::request_pane::split_header_line(t)?;
Some((k.trim().to_string(), v.trim().to_string()))
})
.collect();
render_kv_table(
rows,
fields,
area,
t,
dim,
&headers,
rp.headers_add.as_ref(),
rp.kv_edit.as_ref(),
KvTableKind::Headers,
None,
pane_id,
focused,
params_rows_local,
Some(envset),
Some(var_clicks_local),
);
}
if cur_tab == crate::request_pane::EditTab::Body {
let b_focus = rp.focus == EditField::Body;
let body = rp.request.body.as_deref().unwrap_or("");
let detected = detect_body_kind(body);
if body.is_empty() {
let empty_y = rows.len() as u16;
rows.push(Line::from(vec![
Span::styled(" 1 ", Style::default().fg(t.comment).bg(t.bg_dark)),
Span::styled(String::new(), Style::default().bg(t.bg_dark)),
]));
register_field(fields, empty_y, EditField::Body);
if b_focus && focused && caret.is_none() {
*caret = Some((area.x + 3, empty_y));
}
} else {
let json_spans: Vec<Vec<crate::highlight::ColoredSpan>> = if detected == Some("JSON") {
crate::highlight::highlight_lines(body, "json")
} else {
Vec::new()
};
let total_lines = body.lines().count().max(1);
let gutter_w = total_lines.to_string().len();
let gutter = |n: usize, t: theme::Theme| {
Span::styled(
format!(" {:>width$} ", n, width = gutter_w),
Style::default().fg(t.comment).bg(t.bg_dark),
)
};
let body_bytes = body.as_bytes();
let mut line_start_offset: usize = 0;
for (i, line) in body.lines().enumerate() {
let row_y = rows.len() as u16;
let n = i + 1;
let body_offset_of_line_start = line_start_offset;
let body_offset_of_line_end = line_start_offset + line.len();
let sep_len = if body_bytes.get(body_offset_of_line_end).copied() == Some(b'\r')
&& body_bytes.get(body_offset_of_line_end + 1).copied() == Some(b'\n')
{
2
} else {
1
};
line_start_offset = body_offset_of_line_end + sep_len;
let rendered = if show_ws {
line.replace('\t', "→")
} else {
line.to_string()
};
let resolved_style = Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let unresolved_style = Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD);
let gutter_prefix_cols = (gutter_w as u16).saturating_add(2);
let content_line = if let Some(spans) = json_spans.get(i) {
let mut inner = colored_line_with_vars(
&rendered,
spans,
t.grey_fg,
t,
envset,
resolved_style,
unresolved_style,
area.x.saturating_add(gutter_prefix_cols),
row_y,
Some(var_clicks_local),
);
inner.spans.insert(0, gutter(n, t));
inner
} else {
let plain_style = Style::default().fg(t.grey_fg).bg(t.bg_dark);
let mut inner_spans = vec![gutter(n, t)];
inner_spans.extend(build_var_spans(
&rendered,
envset,
plain_style,
resolved_style,
unresolved_style,
area.x.saturating_add(gutter_prefix_cols),
row_y,
Some(var_clicks_local),
));
Line::from(inner_spans)
};
rows.push(content_line);
register_field(fields, row_y, EditField::Body);
if b_focus
&& focused
&& caret.is_none()
&& rp.body_cursor >= body_offset_of_line_start
&& rp.body_cursor <= body_offset_of_line_end
{
let col_in_line = body
[body_offset_of_line_start..rp.body_cursor.min(body.len())]
.chars()
.count() as u16;
let y = (rows.len() - 1) as u16;
let prefix_cols = (gutter_w as u16).saturating_add(2);
*caret = Some((area.x + prefix_cols + col_in_line, y));
}
}
if b_focus && focused && caret.is_none() && body.ends_with('\n') {
let y = rows.len() as u16;
rows.push(plain(String::new(), body_style));
let prefix_cols = (gutter_w as u16).saturating_add(2);
*caret = Some((area.x + prefix_cols, y));
}
}
}
let register_tab_row = |fields: &mut Vec<(Rect, PaneId, EditField)>, row_y: u16| {
if !focused {
return;
}
fields.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
pane_id,
EditField::Url,
));
};
if cur_tab == crate::request_pane::EditTab::Params {
let url = &rp.request.url;
let params: Vec<(String, String)> = match url.find('?') {
Some(i) => url[i + 1..]
.split('&')
.filter(|s| !s.is_empty())
.map(|kv| match kv.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (kv.to_string(), String::new()),
})
.collect(),
None => Vec::new(),
};
render_kv_table(
rows,
fields,
area,
t,
dim,
¶ms,
rp.params_add.as_ref(),
rp.kv_edit.as_ref(),
KvTableKind::Params,
rp.hover_params_key.as_deref(),
pane_id,
focused,
params_rows_local,
Some(envset),
Some(var_clicks_local),
);
}
if cur_tab == crate::request_pane::EditTab::Auth {
let current = rp
.request
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone());
let summary = match current.as_deref() {
Some(v) if v.starts_with("Bearer ") => {
format!("Bearer · {}", v[7..].chars().take(20).collect::<String>())
}
Some(v) if v.starts_with("Basic ") => "Basic · (base64 user:pass)".to_string(),
Some(v) if v.len() > 24 => format!("{}…", &v[..22]),
Some(v) => v.to_string(),
None => "(no Authorization header — request will be unauthenticated)".to_string(),
};
let summary_y = rows.len() as u16;
rows.push(Line::from(vec![
Span::styled(" Current: ".to_string(), dim),
Span::styled(
summary,
Style::default()
.fg(if current.is_some() { t.cyan } else { t.comment })
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]));
register_tab_row(fields, summary_y);
rows.push(plain(String::new(), body_style));
let actions: &[(&str, &str, &str, char)] = &[
("set_bearer", " ", "Set Bearer token…", '+'),
("set_basic", " ", "Set Basic auth (user:pass)…", '+'),
("set_api_key", " ", "Set X-Api-Key…", '+'),
("apply_preset", " ", "Apply saved preset…", '⟳'),
("save_preset", " ", "Save current as preset…", '⇓'),
("clear", " ", "Clear Authorization", '×'),
];
for (id, gap, label, icon) in actions {
let row_y = rows.len() as u16;
let base_color = match *id {
"clear" => t.red,
"save_preset" => t.green,
_ => t.fg,
};
let is_hover = rp.hover_auth_id.as_deref() == Some(*id);
let row_bg = if is_hover { t.cyan } else { t.bg_dark };
let text_fg = if is_hover { t.bg_dark } else { base_color };
rows.push(Line::from(vec![
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(
format!("{icon}{gap}{label}"),
Style::default()
.fg(text_fg)
.bg(row_bg)
.add_modifier(Modifier::BOLD),
),
]));
auth_rows_local.push((
Rect {
x: area.x,
y: row_y,
width: area.width,
height: 1,
},
id.to_string(),
));
register_tab_row(fields, row_y);
}
}
if cur_tab == crate::request_pane::EditTab::Vars {
let env_name = crate::http::template::EnvSet::select(workspace, env_override)
.name()
.map(str::to_string)
.unwrap_or_else(|| "dev".to_string());
let mut by_key: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for sub in [".rqst", ".mnml"] {
let path = workspace
.join(sub)
.join("env")
.join(format!("{env_name}.env"));
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((k, v)) = trimmed.split_once('=') {
by_key.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
}
let name_y = rows.len() as u16;
rows.push(Line::from(vec![
Span::styled(" env: ", dim),
Span::styled(
format!("{env_name}.env"),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · click cell to edit · Tab commits · Esc cancels", dim),
]));
register_tab_row(fields, name_y);
rows.push(plain(String::new(), body_style));
let data: Vec<(String, String)> = by_key.into_iter().collect();
render_kv_table(
rows,
fields,
area,
t,
dim,
&data,
None, rp.kv_edit.as_ref(),
KvTableKind::Vars,
rp.hover_vars_key.as_deref(),
pane_id,
focused,
vars_rows_local,
None,
None,
);
}
if cur_tab == crate::request_pane::EditTab::Source {
let s_focus = rp.focus == EditField::Source;
let hint_y = rows.len() as u16;
rows.push(Line::from(vec![Span::styled(
" Source — type / paste curl or .http here · :http.paste_source (Ctrl+Enter)"
.to_string(),
dim,
)]));
register_tab_row(fields, hint_y);
rows.push(plain(String::new(), body_style));
let src = &rp.source_buffer;
let val_style = Style::default().fg(t.fg).bg(t.bg_dark);
if src.is_empty() {
let y = rows.len() as u16;
rows.push(Line::from(vec![Span::styled(
" (empty — paste here, or Ctrl+Shift+V to read clipboard)".to_string(),
dim,
)]));
register_tab_row(fields, y);
if s_focus && focused && caret.is_none() {
let y = (rows.len() - 1) as u16;
*caret = Some((area.x + 4, y));
}
} else {
let src_bytes = src.as_bytes();
let mut src_line_start_offset: usize = 0;
for line in src.lines() {
let y = rows.len() as u16;
register_tab_row(fields, y);
rows.push(Line::from(vec![Span::styled(
format!(" {line}"),
val_style,
)]));
let start = src_line_start_offset;
let end = src_line_start_offset + line.len();
let sep_len = if src_bytes.get(end).copied() == Some(b'\r')
&& src_bytes.get(end + 1).copied() == Some(b'\n')
{
2
} else {
1
};
src_line_start_offset = end + sep_len;
if s_focus
&& focused
&& caret.is_none()
&& rp.source_cursor >= start
&& rp.source_cursor <= end
{
let col = src[start..rp.source_cursor.min(src.len())].chars().count() as u16;
let yy = (rows.len() - 1) as u16;
*caret = Some((area.x + 4 + col, yy));
}
}
if s_focus && focused && caret.is_none() && src.ends_with('\n') {
let y = rows.len() as u16;
rows.push(plain(String::new(), body_style));
*caret = Some((area.x + 4, y));
}
}
}
if !matches!(&rp.state, RunState::Failed(e) if is_not_sent_placeholder(e)) {
rows.push(plain(String::new(), body_style));
}
match &rp.state {
RunState::Sending => rows.push(plain(
" ⟳ sending…".to_string(),
Style::default().fg(t.yellow).bg(t.bg_dark),
)),
RunState::Streaming(r) => rows.push(plain(
format!(" ▶ streaming · {} events received", r.sse_event_count),
Style::default().fg(t.cyan).bg(t.bg_dark),
)),
RunState::Failed(e) if !is_not_sent_placeholder(e) => rows.push(plain(
format!(" ✗ last send: {e}"),
Style::default().fg(t.red).bg(t.bg_dark),
)),
RunState::Failed(_) => {}
RunState::Done(_) => {}
}
}
fn is_not_sent_placeholder(msg: &str) -> bool {
msg.contains("not sent")
}
fn human_bytes(n: usize) -> String {
const KB: usize = 1024;
const MB: usize = 1024 * 1024;
if n < KB {
format!("{n} B")
} else if n < MB {
format!("{:.1} KB", n as f64 / KB as f64)
} else {
format!("{:.1} MB", n as f64 / MB as f64)
}
}
fn detect_body_kind(body: &str) -> Option<&'static str> {
let head = body.trim_start();
if head.is_empty() {
return None;
}
let sample: String = head.chars().take(256).collect();
let first = sample.chars().next()?;
match first {
'{' | '[' => Some("JSON"),
'<' => {
Some("XML")
}
_ => {
if sample.contains('=')
&& sample.contains('&')
&& !sample.contains('{')
&& !sample.contains('[')
{
Some("form")
} else {
Some("text")
}
}
}
}
fn url_chars_before_cursor(text: &str, byte_cursor: usize) -> usize {
text[..byte_cursor.min(text.len())].chars().count()
}
fn draw_response(
rp: &crate::request_pane::RequestPane,
t: theme::Theme,
rows: &mut Vec<Line<'static>>,
body_wrap_width: Option<u16>,
) {
let body_style = Style::default().fg(t.fg).bg(t.bg_dark);
let plain = |s: String, st: Style| Line::from(Span::styled(s, st));
let q_lower = rp.filter.trim().to_ascii_lowercase();
match &rp.state {
RunState::Sending => {
rows.push(plain(
" ⟳ sending…".to_string(),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
}
RunState::Streaming(r) => {
rows.push(plain(
format!(" ▶ streaming · {} {}", r.status, r.status_text),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
rows.push(plain(String::new(), body_style));
for l in r.body.lines() {
rows.push(plain(l.to_string(), body_style));
}
}
RunState::Failed(e) if is_not_sent_placeholder(e) => {
rows.push(plain(
format!(" {e}"),
Style::default().fg(t.comment).bg(t.bg_dark),
));
}
RunState::Failed(e) => {
rows.push(plain(
format!(" ✗ {e}"),
Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
}
RunState::Done(r) => {
use crate::request_pane::ResponseTab;
match rp.response_tab {
ResponseTab::Headers => {
for (k, v) in &r.headers {
if header_matches_filter(k, v, &q_lower) {
rows.push(header_row(k, v, t));
}
}
return;
}
ResponseTab::Timeline => {
let wait_ms = r.timing.wait.as_millis() as u64;
let recv_ms = r.timing.receive.as_millis() as u64;
let total_ms = r.elapsed.as_millis() as u64;
let max = wait_ms.max(recv_ms).max(1);
const BAR_W: u64 = 40;
let make_bar = |ms: u64, color: ratatui::style::Color| {
let filled = (ms * BAR_W / max) as usize;
Line::from(vec![
Span::styled(" ".to_string(), Style::default().bg(t.bg_dark)),
Span::styled(
"█".repeat(filled),
Style::default().fg(color).bg(t.bg_dark),
),
Span::styled(
"░".repeat(BAR_W as usize - filled),
Style::default().fg(t.bg3).bg(t.bg_dark),
),
Span::styled(
format!(" {ms} ms"),
Style::default().fg(t.comment).bg(t.bg_dark),
),
])
};
rows.push(Line::from(vec![
Span::styled(
" Wait ".to_string(),
Style::default()
.fg(t.comment)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"(connect + TLS + send + headers received)".to_string(),
Style::default().fg(t.comment).bg(t.bg_dark),
),
]));
rows.push(make_bar(wait_ms, t.blue));
rows.push(plain(String::new(), body_style));
rows.push(Line::from(vec![
Span::styled(
" Receive ".to_string(),
Style::default()
.fg(t.comment)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"(body read)".to_string(),
Style::default().fg(t.comment).bg(t.bg_dark),
),
]));
rows.push(make_bar(recv_ms, t.green));
rows.push(plain(String::new(), body_style));
rows.push(plain(
format!(" Total {total_ms} ms"),
Style::default()
.fg(t.fg)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
return;
}
ResponseTab::Tests => {
if r.assertions.is_empty() {
rows.push(plain(
" (no assertions in this request)".to_string(),
Style::default().fg(t.comment).bg(t.bg_dark),
));
return;
}
for a in &r.assertions {
if a.passed {
rows.push(plain(
format!(" ✓ {}", a.label),
Style::default().fg(t.green).bg(t.bg_dark),
));
} else {
rows.push(plain(
format!(" ✗ {}", a.label),
Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
));
}
}
return;
}
ResponseTab::Body => {}
}
rows.push(plain(String::new(), body_style));
let ct_lower = r
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.to_ascii_lowercase())
.unwrap_or_default();
let is_binary_ct = ct_lower.starts_with("image/")
|| ct_lower.starts_with("video/")
|| ct_lower.starts_with("audio/")
|| ct_lower.contains("octet-stream")
|| ct_lower.contains("pdf")
|| ct_lower.contains("zip")
|| ct_lower.contains("gzip")
|| ct_lower.contains("tar")
|| ct_lower.contains("protobuf")
|| ct_lower.contains("msgpack");
let size = if !r.body_bytes.is_empty() {
r.body_bytes.len()
} else {
r.body.len()
};
if is_binary_ct {
let kind = if ct_lower.starts_with("image/") {
"image"
} else if ct_lower.starts_with("video/") {
"video"
} else if ct_lower.starts_with("audio/") {
"audio"
} else if ct_lower.contains("pdf") {
"pdf"
} else {
"binary"
};
rows.push(plain(
format!(" [binary {kind} · {size} bytes · Ctrl+S to save the raw body]"),
body_style,
));
rows.push(plain(
format!(" content-type: {}", ct_lower.trim()),
body_style,
));
return;
}
use crate::request_pane::ResponseBodyFormat;
let pretty = if matches!(rp.response_body_format, ResponseBodyFormat::Text) {
r.body.clone()
} else {
pretty_body(&r.body, &r.headers)
};
let highlight_lang: Option<&'static str> = match rp.response_body_format {
ResponseBodyFormat::Json => Some("json"),
ResponseBodyFormat::Xml | ResponseBodyFormat::Html => Some("html"),
ResponseBodyFormat::Text => None,
ResponseBodyFormat::Auto => {
let ct = r
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.to_ascii_lowercase())
.unwrap_or_default();
if ct.contains("json") {
Some("json")
} else if ct.contains("html") || ct.contains("xml") {
Some("html")
} else {
let b = pretty.trim_start();
if b.starts_with('{') || b.starts_with('[') {
Some("json")
} else if b.starts_with('<') {
Some("html")
} else {
None
}
}
}
};
let json_spans: Vec<Vec<crate::highlight::ColoredSpan>> = match highlight_lang {
Some(lang) => crate::highlight::highlight_lines(&pretty, lang),
None => Vec::new(),
};
let get_spans = |i: usize| -> &[crate::highlight::ColoredSpan] {
json_spans.get(i).map(|v| v.as_slice()).unwrap_or(&[])
};
let total_lines = pretty.lines().count().max(1);
let gutter_w = total_lines.to_string().len();
let gutter = |n: usize, t: theme::Theme| {
Span::styled(
format!(" {:>width$} ", n, width = gutter_w),
Style::default().fg(t.comment).bg(t.bg_dark),
)
};
let wrap_plain =
|s: &str, out: &mut Vec<Line<'static>>, style: Style, n: usize, t: theme::Theme| {
let blank_gutter =
Span::styled(" ".repeat(gutter_w + 2), Style::default().bg(t.bg_dark));
match body_wrap_width {
Some(w) if s.chars().count() > w as usize => {
let chars: Vec<char> = s.chars().collect();
for (chunk_i, chunk) in chars.chunks(w as usize).enumerate() {
let g = if chunk_i == 0 {
gutter(n, t)
} else {
blank_gutter.clone()
};
let text: String = chunk.iter().collect();
out.push(Line::from(vec![g, Span::styled(text, style)]));
}
}
_ => out.push(Line::from(vec![
gutter(n, t),
Span::styled(s.to_string(), style),
])),
}
};
let with_gutter =
|mut line: Line<'static>, n: usize, t: theme::Theme| -> Line<'static> {
line.spans.insert(0, gutter(n, t));
line
};
if q_lower.is_empty() {
for (i, l) in pretty.lines().enumerate() {
let n = i + 1;
let spans = get_spans(i);
if body_wrap_width.is_none() && !spans.is_empty() {
rows.push(with_gutter(colored_line(l, spans, t.fg, t), n, t));
} else {
wrap_plain(l, rows, body_style, n, t);
}
}
} else {
let lines: Vec<&str> = pretty.lines().collect();
let matches: Vec<bool> = lines
.iter()
.map(|l| l.to_ascii_lowercase().contains(&q_lower))
.collect();
let hits = matches.iter().filter(|m| **m).count();
for (i, l) in lines.iter().enumerate() {
let show = matches[i]
|| (i > 0 && matches[i - 1])
|| (i > 1 && matches[i - 2])
|| (i + 1 < matches.len() && matches[i + 1])
|| (i + 2 < matches.len() && matches[i + 2]);
if !show {
continue;
}
let n = i + 1;
let style = if matches[i] {
Style::default().fg(t.fg).bg(t.bg2)
} else {
body_style
};
let spans = get_spans(i);
if !matches[i] && body_wrap_width.is_none() && !spans.is_empty() {
rows.push(with_gutter(colored_line(l, spans, t.fg, t), n, t));
} else {
wrap_plain(l, rows, style, n, t);
}
}
if hits == 0 {
rows.push(plain(
format!(" (no lines match \"{}\")", rp.filter),
Style::default()
.fg(t.comment)
.bg(t.bg_dark)
.add_modifier(Modifier::DIM),
));
}
}
if !r.assertions.is_empty() {
rows.push(plain(String::new(), body_style));
for a in &r.assertions {
if a.passed {
rows.push(plain(
format!(" ✓ {}", a.label),
Style::default().fg(t.green).bg(t.bg_dark),
));
} else {
let line = match &a.detail {
Some(d) => format!(" ✗ {} — {d}", a.label),
None => format!(" ✗ {}", a.label),
};
rows.push(plain(line, Style::default().fg(t.red).bg(t.bg_dark)));
}
}
}
if !r.captures.is_empty() {
rows.push(plain(String::new(), body_style));
for (name, value) in &r.captures {
rows.push(Line::from(vec![
Span::styled(
format!(" ⇒ {name} = "),
Style::default().fg(t.cyan).bg(t.bg_dark),
),
Span::styled(
value.clone(),
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
]));
}
}
if let Some(sr) = &r.schema_result {
rows.push(plain(String::new(), body_style));
rows.push(schema_footer_line(sr, &t));
}
}
}
}
fn schema_footer_line(
sr: &crate::http::schema::SchemaResult,
t: &crate::ui::theme::Theme,
) -> Line<'static> {
use crate::http::schema::SchemaStatus;
let sidecar = sr
.schema_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("");
match &sr.status {
SchemaStatus::Valid => Line::from(vec![Span::styled(
format!(" ✓ Schema valid ({sidecar})"),
Style::default()
.fg(t.green)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)]),
SchemaStatus::Invalid => {
let n = sr.errors.len();
let plural = if n == 1 { "error" } else { "errors" };
Line::from(vec![Span::styled(
format!(" ✗ Schema: {n} {plural} ({sidecar}) — :http.show_schema_errors"),
Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)])
}
SchemaStatus::NoSidecar => Line::from(vec![]),
SchemaStatus::ReadError(e) => Line::from(vec![Span::styled(
format!(" ⚠ Schema read error ({sidecar}): {e}"),
Style::default().fg(t.yellow).bg(t.bg_dark),
)]),
SchemaStatus::SchemaParseError(e) => Line::from(vec![Span::styled(
format!(" ⚠ Schema parse error ({sidecar}): {e}"),
Style::default().fg(t.yellow).bg(t.bg_dark),
)]),
SchemaStatus::NotJson => Line::from(vec![Span::styled(
format!(" ⚠ Body isn't JSON — schema ({sidecar}) skipped"),
Style::default().fg(t.yellow).bg(t.bg_dark),
)]),
}
}
fn pretty_body(body: &str, headers: &[(String, String)]) -> String {
let is_json = headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("json"))
|| {
let b = body.trim_start();
b.starts_with('{') || b.starts_with('[')
};
if is_json
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(body)
&& let Ok(p) = serde_json::to_string_pretty(&v)
{
return p;
}
body.to_string()
}