use std::path::PathBuf;
use std::time::Duration;
use crate::http::Request;
use crate::http::script::{AssertionResult, Script};
pub struct RequestPane {
pub source_path: Option<PathBuf>,
pub source_block_name: Option<String>,
pub request: Request,
pub script: Script,
pub job_id: u64,
pub state: RunState,
pub scroll: usize,
pub view: ViewMode,
pub focus: EditField,
pub url_cursor: usize,
pub body_cursor: usize,
pub headers_buffer: String,
pub headers_cursor: usize,
pub edit_tab: EditTab,
pub source_buffer: String,
pub source_cursor: usize,
pub method_cursor_scratch: usize,
pub prev_response: Option<Box<ResponseView>>,
pub filter: String,
pub filter_focused: bool,
pub body_wrap: bool,
pub hover_params_key: Option<String>,
pub hover_vars_key: Option<String>,
pub hover_auth_id: Option<String>,
pub split_orientation: SplitOrientation,
pub response_tab: ResponseTab,
pub params_add: Option<InlineKvDraft>,
pub headers_add: Option<InlineKvDraft>,
pub kv_edit: Option<KvValueEdit>,
pub response_body_format: ResponseBodyFormat,
pub edit_tab_split: Option<EditTab>,
pub edit_split_ratio: u16,
pub summary: Option<String>,
pub is_preview: bool,
}
#[derive(Debug, Default, Clone)]
pub struct InlineKvDraft {
pub key: String,
pub value: String,
pub key_cursor: usize,
pub value_cursor: usize,
pub on_value: bool,
}
pub type ParamsAddDraft = InlineKvDraft;
#[derive(Debug, Clone)]
pub struct KvValueEdit {
pub kind: KvEditKind,
pub original_key: String,
pub buffer: String,
pub cursor: usize,
pub editing_name: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KvEditKind {
Params,
Headers,
Vars,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseBodyFormat {
Auto,
Json,
Xml,
Html,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitOrientation {
Auto,
Vertical,
Horizontal,
}
pub const AUTO_HORIZONTAL_THRESHOLD: u16 = 100;
impl SplitOrientation {
pub fn toggle(self) -> Self {
match self {
SplitOrientation::Auto => SplitOrientation::Vertical,
SplitOrientation::Vertical => SplitOrientation::Horizontal,
SplitOrientation::Horizontal => SplitOrientation::Auto,
}
}
pub fn resolve(self, pane_width: u16) -> Self {
match self {
SplitOrientation::Auto => {
if pane_width >= AUTO_HORIZONTAL_THRESHOLD {
SplitOrientation::Horizontal
} else {
SplitOrientation::Vertical
}
}
other => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseTab {
Body,
Headers,
Timeline,
Tests,
}
impl ResponseTab {
pub const ALL: &'static [ResponseTab] = &[
ResponseTab::Body,
ResponseTab::Headers,
ResponseTab::Timeline,
ResponseTab::Tests,
];
pub fn label(self) -> &'static str {
match self {
ResponseTab::Body => "Body",
ResponseTab::Headers => "Headers",
ResponseTab::Timeline => "Timeline",
ResponseTab::Tests => "Tests",
}
}
pub fn next(self) -> Self {
let i = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
Self::ALL[(i + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
let i = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
Self::ALL[(i + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditTab {
Body,
Headers,
Params,
Auth,
Vars,
Source,
}
impl EditTab {
pub const ALL: &'static [EditTab] = &[
EditTab::Params,
EditTab::Body,
EditTab::Headers,
EditTab::Auth,
EditTab::Vars,
EditTab::Source,
];
pub fn label(self) -> &'static str {
match self {
EditTab::Body => "Body",
EditTab::Headers => "Headers",
EditTab::Params => "Params",
EditTab::Auth => "Auth",
EditTab::Vars => "Vars",
EditTab::Source => "Script",
}
}
pub fn next(self) -> Self {
let i = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
Self::ALL[(i + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
let i = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
Self::ALL[(i + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Response,
Edit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditField {
Url,
Method,
Headers,
Body,
Source,
}
impl EditField {
pub fn next(self) -> Self {
match self {
EditField::Url => EditField::Method,
EditField::Method => EditField::Headers,
EditField::Headers => EditField::Body,
EditField::Body => EditField::Url,
EditField::Source => EditField::Url,
}
}
pub fn prev(self) -> Self {
match self {
EditField::Url => EditField::Body,
EditField::Method => EditField::Url,
EditField::Headers => EditField::Method,
EditField::Body => EditField::Headers,
EditField::Source => EditField::Body,
}
}
pub fn label(self) -> &'static str {
match self {
EditField::Url => "URL",
EditField::Method => "Method",
EditField::Headers => "Headers",
EditField::Body => "Body",
EditField::Source => "Source",
}
}
}
pub fn headers_to_text(headers: &[(String, String)]) -> String {
if headers.is_empty() {
return String::new();
}
let mut out = headers
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
out
}
pub fn parse_headers_text(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|l| {
let l = l.trim();
if l.is_empty() || l.starts_with('#') {
return None;
}
let (k, v) = split_header_line(l)?;
let k = k.trim();
let v = v.trim();
if k.is_empty() {
None
} else {
Some((k.to_string(), v.to_string()))
}
})
.collect()
}
pub fn split_header_line(l: &str) -> Option<(&str, &str)> {
let i = l.find(':')?;
Some((&l[..i], &l[i + 1..]))
}
pub const STANDARD_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
pub fn cycle_method(current: &str) -> String {
let cur = current.trim().to_ascii_uppercase();
let idx = STANDARD_METHODS
.iter()
.position(|m| **m == cur)
.unwrap_or(0);
let next = (idx + 1) % STANDARD_METHODS.len();
STANDARD_METHODS[next].to_string()
}
pub enum RunState {
Sending,
Streaming(Box<ResponseView>),
Done(Box<ResponseView>),
Failed(String),
}
pub enum SseStreamMsg {
Open {
job_id: u64,
status: u16,
status_text: String,
headers: Vec<(String, String)>,
started: std::time::Instant,
},
Event {
job_id: u64,
name: String,
data: String,
},
Close {
job_id: u64,
},
Error {
job_id: u64,
error: String,
},
}
#[derive(Clone)]
pub struct ResponseView {
pub status: u16,
pub status_text: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub body_bytes: Vec<u8>,
pub elapsed: Duration,
pub timing: crate::http::Timing,
pub assertions: Vec<AssertionResult>,
pub captures: Vec<(String, String)>,
pub schema_result: Option<crate::http::schema::SchemaResult>,
pub sse_event_count: u32,
}
impl RequestPane {
pub fn new(
source_path: Option<PathBuf>,
request: Request,
script: Script,
job_id: u64,
) -> Self {
let url_cursor = request.url.len();
let body_cursor = request.body.as_deref().map(str::len).unwrap_or(0);
let headers_buffer = headers_to_text(&request.headers);
let headers_cursor = headers_buffer.len();
RequestPane {
source_path,
source_block_name: None,
request,
script,
job_id,
state: RunState::Sending,
scroll: 0,
view: ViewMode::Response,
focus: EditField::Url,
url_cursor,
body_cursor,
headers_buffer,
headers_cursor,
edit_tab: EditTab::Body,
source_buffer: String::new(),
source_cursor: 0,
method_cursor_scratch: 0,
prev_response: None,
filter: String::new(),
filter_focused: false,
body_wrap: false,
hover_params_key: None,
hover_vars_key: None,
hover_auth_id: None,
split_orientation: SplitOrientation::Auto,
response_tab: ResponseTab::Body,
params_add: None,
headers_add: None,
kv_edit: None,
response_body_format: ResponseBodyFormat::Auto,
edit_tab_split: None,
edit_split_ratio: 50,
summary: None,
is_preview: false,
}
}
pub fn toggle_edit_split(&mut self) {
if self.edit_tab_split.is_some() {
self.edit_tab_split = None;
} else {
let default = if self.edit_tab == EditTab::Vars {
EditTab::Body
} else {
EditTab::Vars
};
self.edit_tab_split = Some(default);
self.edit_split_ratio = 50;
}
}
pub fn as_http_block(&self, name: Option<&str>) -> String {
let mut out = String::new();
if let Some(n) = name {
if n.is_empty() {
out.push_str("###\n");
} else {
out.push_str("### ");
out.push_str(n);
out.push('\n');
}
}
out.push_str(&self.request.method);
out.push(' ');
out.push_str(&self.request.url);
out.push('\n');
for (k, v) in &self.request.headers {
out.push_str(k);
out.push_str(": ");
out.push_str(v);
out.push('\n');
}
if let Some(body) = &self.request.body {
out.push('\n');
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
out
}
pub fn commit_headers(&mut self) {
self.request.headers = parse_headers_text(&self.headers_buffer);
}
pub fn toggle_view(&mut self) {
self.view = match self.view {
ViewMode::Response => ViewMode::Edit,
ViewMode::Edit => ViewMode::Response,
};
if self.view == ViewMode::Edit {
self.focus = EditField::Url;
}
}
pub fn focus_next_field(&mut self) {
self.focus = self.focus.next();
self.sync_edit_tab_to_focus();
}
pub fn focus_prev_field(&mut self) {
self.focus = self.focus.prev();
self.sync_edit_tab_to_focus();
}
fn sync_edit_tab_to_focus(&mut self) {
let target = match self.focus {
EditField::Headers => Some(EditTab::Headers),
EditField::Body => Some(EditTab::Body),
EditField::Source => Some(EditTab::Source),
EditField::Url | EditField::Method => None,
};
if let Some(tab) = target {
self.edit_tab = tab;
}
}
fn focused_text_mut(&mut self) -> Option<(&mut String, &mut usize)> {
match self.focus {
EditField::Url => Some((&mut self.request.url, &mut self.url_cursor)),
EditField::Method => None,
EditField::Headers => Some((&mut self.headers_buffer, &mut self.headers_cursor)),
EditField::Body => {
let body = self.request.body.get_or_insert_with(String::new);
Some((body, &mut self.body_cursor))
}
EditField::Source => Some((&mut self.source_buffer, &mut self.source_cursor)),
}
}
pub fn type_char(&mut self, c: char) {
if self.focus == EditField::Method {
if c == ' ' {
self.request.method = cycle_method(&self.request.method);
}
return;
}
let single_line = self.focus == EditField::Url;
if single_line && c == '\n' {
return;
}
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
let pos = (*cur).min(s.len());
s.insert(pos, c);
*cur = pos + c.len_utf8();
}
pub fn insert_str(&mut self, s: &str) {
if self.focus == EditField::Method {
return;
}
let single_line = self.focus == EditField::Url;
let cleaned: String = if single_line {
s.chars()
.filter(|c| *c != '\0')
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect()
} else {
s.chars().filter(|c| *c != '\0').collect()
};
let Some((buf, cur)) = self.focused_text_mut() else {
return;
};
let pos = (*cur).min(buf.len());
buf.insert_str(pos, &cleaned);
*cur = pos + cleaned.len();
}
pub fn delete_word_back(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
let pos = (*cur).min(s.len());
if pos == 0 {
return;
}
let line_start = s[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let head = &s[line_start..pos];
let trimmed = head.trim_end_matches(char::is_whitespace);
let cut = trimmed
.char_indices()
.rev()
.find(|&(_, c)| c.is_whitespace())
.map(|(i, c)| line_start + i + c.len_utf8())
.unwrap_or(line_start);
s.replace_range(cut..pos, "");
*cur = cut;
}
pub fn delete_to_line_start(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
let pos = (*cur).min(s.len());
let line_start = s[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
if line_start == pos {
return;
}
s.replace_range(line_start..pos, "");
*cur = line_start;
}
pub fn delete_to_line_end(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
let pos = (*cur).min(s.len());
let end = s[pos..].find('\n').map(|rel| pos + rel).unwrap_or(s.len());
if end == pos {
return;
}
s.replace_range(pos..end, "");
}
pub fn backspace(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
if *cur == 0 || s.is_empty() {
return;
}
let prev = s[..*cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
s.replace_range(prev..*cur, "");
*cur = prev;
}
pub fn delete_forward(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
if *cur >= s.len() {
return;
}
let end = s[*cur..]
.char_indices()
.nth(1)
.map(|(i, _)| *cur + i)
.unwrap_or(s.len());
s.replace_range(*cur..end, "");
}
pub fn move_left(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
if *cur == 0 {
return;
}
*cur = s[..*cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
pub fn move_right(&mut self) {
let Some((s, cur)) = self.focused_text_mut() else {
return;
};
if *cur >= s.len() {
return;
}
let step = s[*cur..].chars().next().map(char::len_utf8).unwrap_or(0);
*cur += step;
}
pub fn move_home(&mut self) {
match self.focus {
EditField::Url => self.url_cursor = 0,
EditField::Headers => {
let cur = self.headers_cursor.min(self.headers_buffer.len());
self.headers_cursor = self.headers_buffer[..cur]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
}
EditField::Body => {
let s = self.request.body.as_deref().unwrap_or("");
let cur = self.body_cursor.min(s.len());
self.body_cursor = s[..cur].rfind('\n').map(|i| i + 1).unwrap_or(0);
}
EditField::Source => {
let s = self.source_buffer.as_str();
let cur = self.source_cursor.min(s.len());
self.source_cursor = s[..cur].rfind('\n').map(|i| i + 1).unwrap_or(0);
}
EditField::Method => {}
}
}
pub fn move_end(&mut self) {
match self.focus {
EditField::Url => self.url_cursor = self.request.url.len(),
EditField::Headers => {
let cur = self.headers_cursor.min(self.headers_buffer.len());
let to_eol = self.headers_buffer[cur..]
.find('\n')
.unwrap_or(self.headers_buffer.len() - cur);
self.headers_cursor = cur + to_eol;
}
EditField::Body => {
let s = self.request.body.as_deref().unwrap_or("");
let cur = self.body_cursor.min(s.len());
let to_end_of_line = s[cur..].find('\n').unwrap_or(s.len() - cur);
self.body_cursor = cur + to_end_of_line;
}
EditField::Source => {
let s = self.source_buffer.as_str();
let cur = self.source_cursor.min(s.len());
let to_eol = s[cur..].find('\n').unwrap_or(s.len() - cur);
self.source_cursor = cur + to_eol;
}
EditField::Method => {}
}
}
pub fn title(&self) -> String {
let method = self.request.method.to_uppercase();
let base = if let Some(s) = self.summary.as_deref().filter(|s| !s.trim().is_empty()) {
format!("{method} {s}")
} else if !self.request.url.is_empty() {
let short = self
.request
.url
.strip_prefix("https://")
.or_else(|| self.request.url.strip_prefix("http://"))
.unwrap_or(&self.request.url);
let short = short.split(['?', '#']).next().unwrap_or(short);
format!("{method} {short}")
} else {
self.source_path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{method} new request"))
};
let marker = match &self.state {
RunState::Sending => "…",
RunState::Streaming(_) => "▶",
RunState::Done(r) if r.assertions.iter().any(|a| !a.passed) => "✗",
_ => "",
};
if marker.is_empty() {
base
} else {
format!("{base} {marker}")
}
}
pub fn request_line(&self) -> String {
format!("{} {}", self.request.method, self.request.url)
}
pub fn as_curl(&self) -> String {
let esc = |s: &str| s.replace('\'', "'\\''");
let mut out = format!("curl '{}'", esc(&self.request.url));
if self.request.method != "GET"
&& !(self.request.method == "POST" && self.request.body.is_some())
{
out.push_str(&format!(" -X {}", self.request.method));
}
for (k, v) in &self.request.headers {
out.push_str(&format!(" \\\n -H '{}: {}'", k, esc(v)));
}
if let Some(body) = &self.request.body {
out.push_str(&format!(" \\\n --data-raw '{}'", esc(body)));
}
out
}
pub fn as_python(&self) -> String {
let mut out = String::from("import requests\n\n");
let mut headers_str = String::from("headers = {\n");
for (k, v) in &self.request.headers {
headers_str.push_str(&format!(" {:?}: {:?},\n", k, v));
}
headers_str.push_str("}\n\n");
out.push_str(&headers_str);
let m = self.request.method.to_lowercase();
match &self.request.body {
Some(body) if !body.is_empty() => {
out.push_str(&format!("data = {body:?}\n\n"));
out.push_str(&format!(
"response = requests.{m}({url:?}, headers=headers, data=data)\n",
url = self.request.url,
));
}
_ => {
out.push_str(&format!(
"response = requests.{m}({url:?}, headers=headers)\n",
url = self.request.url,
));
}
}
out.push_str("print(response.status_code)\nprint(response.text)\n");
out
}
pub fn as_js_fetch(&self) -> String {
let mut out = format!(
"const response = await fetch({url:?}, {{\n method: {m:?},\n",
url = self.request.url,
m = self.request.method,
);
out.push_str(" headers: {\n");
for (k, v) in &self.request.headers {
out.push_str(&format!(" {k:?}: {v:?},\n"));
}
out.push_str(" },\n");
if let Some(body) = &self.request.body
&& !body.is_empty()
{
out.push_str(&format!(" body: {body:?},\n"));
}
out.push_str(
"});\nconst data = await response.text();\nconsole.log(response.status, data);\n",
);
out
}
pub fn as_go(&self) -> String {
let has_body = self.request.body.is_some();
let mut out =
String::from("package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n");
if has_body {
out.push_str("\t\"strings\"\n");
}
out.push_str(")\n\nfunc main() {\n");
let body_expr = if let Some(b) = &self.request.body {
format!("strings.NewReader({b:?})")
} else {
"nil".to_string()
};
out.push_str(&format!(
"\treq, err := http.NewRequest({m:?}, {url:?}, {body})\n\tif err != nil {{ panic(err) }}\n",
m = self.request.method,
url = self.request.url,
body = body_expr,
));
for (k, v) in &self.request.headers {
out.push_str(&format!("\treq.Header.Set({k:?}, {v:?})\n"));
}
out.push_str(
"\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil { panic(err) }\n\tdefer resp.Body.Close()\n\tbody, _ := io.ReadAll(resp.Body)\n\tfmt.Println(resp.Status)\n\tfmt.Println(string(body))\n}\n",
);
out
}
pub fn as_wget(&self) -> String {
let mut out = format!(
"wget --method={} '{}'",
self.request.method, self.request.url
);
for (k, v) in &self.request.headers {
out.push_str(&format!(
" \\\n --header='{}: {}'",
k,
v.replace('\'', "'\\''"),
));
}
if let Some(body) = &self.request.body {
out.push_str(&format!(
" \\\n --body-data='{}'",
body.replace('\'', "'\\''"),
));
}
out
}
pub fn as_httpie(&self) -> String {
let mut out = format!("http {} '{}'", self.request.method, self.request.url);
for (k, v) in &self.request.headers {
out.push_str(&format!(" '{}:{}'", k, v));
}
if let Some(body) = &self.request.body
&& !body.is_empty()
{
let escaped = body.replace('\'', "'\\''");
out.push_str(&format!(" <<< '{escaped}'"));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pane() -> RequestPane {
RequestPane::new(
None,
Request {
method: "GET".into(),
url: "https://x.com/a".into(),
headers: Vec::new(),
body: None,
insecure: false,
},
Script::default(),
1,
)
}
#[test]
fn split_header_line_preserves_colons_in_value() {
let (k, v) = split_header_line("X-Redirect-To: https://host/path").unwrap();
assert_eq!(k, "X-Redirect-To");
assert_eq!(v, " https://host/path");
let (k, v) = split_header_line("Timestamp: 2024-01-01T12:00:00Z").unwrap();
assert_eq!(k, "Timestamp");
assert_eq!(v, " 2024-01-01T12:00:00Z");
}
#[test]
fn headers_to_text_emits_trailing_newline() {
let headers = vec![
("Authorization".to_string(), "Bearer abc".to_string()),
("X-Trace".to_string(), "42".to_string()),
];
let text = headers_to_text(&headers);
assert!(
text.ends_with('\n'),
"headers_to_text output must end with \\n so cursor-at-end lands on a fresh line, got {text:?}"
);
assert_eq!(parse_headers_text(&text), headers);
}
#[test]
fn headers_to_text_empty_returns_empty_string() {
assert_eq!(headers_to_text(&[]), "");
}
#[test]
fn typing_after_headers_rebuild_does_not_corrupt_last_value() {
let mut buf = headers_to_text(&[
("Authorization".to_string(), "Bearer TOKEN".to_string()),
("X-Shared".to_string(), "shared-secret".to_string()),
]);
buf.push_str("X-Trace: abc"); let rows = parse_headers_text(&buf);
assert_eq!(rows[0], ("Authorization".into(), "Bearer TOKEN".into()));
assert_eq!(rows[1], ("X-Shared".into(), "shared-secret".into()));
assert_eq!(rows.len(), 3);
assert_eq!(rows[2], ("X-Trace".into(), "abc".into()));
}
#[test]
fn parse_headers_text_preserves_colons_in_value() {
let rows = parse_headers_text(
"Content-Type: application/json\nX-Redirect-To: https://host:443/path\n",
);
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].0, "X-Redirect-To");
assert_eq!(rows[1].1, "https://host:443/path");
}
#[test]
fn toggle_view_lands_on_url_in_edit() {
let mut p = pane();
assert_eq!(p.view, ViewMode::Response);
p.toggle_view();
assert_eq!(p.view, ViewMode::Edit);
assert_eq!(p.focus, EditField::Url);
p.toggle_view();
assert_eq!(p.view, ViewMode::Response);
}
#[test]
fn focus_cycles_url_method_headers_body() {
let mut p = pane();
p.toggle_view();
assert_eq!(p.focus, EditField::Url);
p.focus_next_field();
assert_eq!(p.focus, EditField::Method);
p.focus_next_field();
assert_eq!(p.focus, EditField::Headers);
p.focus_next_field();
assert_eq!(p.focus, EditField::Body);
p.focus_next_field();
assert_eq!(p.focus, EditField::Url);
p.focus_prev_field();
assert_eq!(p.focus, EditField::Body);
p.focus_prev_field();
assert_eq!(p.focus, EditField::Headers);
}
#[test]
fn headers_round_trip_through_buffer() {
let req = Request {
method: "GET".into(),
url: "https://x/".into(),
headers: vec![
("Accept".into(), "application/json".into()),
("Authorization".into(), "Bearer xyz".into()),
],
body: None,
insecure: false,
};
let mut p = RequestPane::new(None, req, Script::default(), 1);
assert_eq!(
p.headers_buffer,
"Accept: application/json\nAuthorization: Bearer xyz\n"
);
p.toggle_view();
p.focus = EditField::Headers;
p.move_end();
p.type_char('\n');
for c in "X-Trace: abc".chars() {
p.type_char(c);
}
p.commit_headers();
assert_eq!(p.request.headers.len(), 3);
assert_eq!(p.request.headers[2], ("X-Trace".into(), "abc".into()));
p.headers_buffer = "Accept: application/json\n\nAuthorization: Bearer xyz".into();
p.commit_headers();
assert_eq!(p.request.headers.len(), 2);
p.headers_buffer = "Accept: application/json\nthis-is-not-a-header".into();
p.commit_headers();
assert_eq!(p.request.headers.len(), 1);
}
#[test]
fn url_field_typing_and_backspace() {
let mut p = pane();
p.toggle_view();
p.move_end();
p.type_char('?');
p.type_char('q');
p.type_char('=');
p.type_char('1');
assert_eq!(p.request.url, "https://x.com/a?q=1");
p.backspace();
p.backspace();
assert_eq!(p.request.url, "https://x.com/a?q");
p.type_char('\n');
assert_eq!(p.request.url, "https://x.com/a?q");
}
#[test]
fn body_field_creates_on_first_keystroke_and_accepts_newlines() {
let mut p = pane();
p.toggle_view();
p.focus = EditField::Body;
assert!(p.request.body.is_none());
for c in "{\"a\":\n 1}".chars() {
p.type_char(c);
}
assert_eq!(p.request.body.as_deref(), Some("{\"a\":\n 1}"));
p.move_home();
assert_eq!(p.body_cursor, "{\"a\":\n".len());
}
#[test]
fn method_cycles_via_space() {
let mut p = pane();
p.toggle_view();
p.focus = EditField::Method;
assert_eq!(p.request.method, "GET");
p.type_char(' ');
assert_eq!(p.request.method, "POST");
p.type_char(' ');
assert_eq!(p.request.method, "PUT");
p.type_char('x');
assert_eq!(p.request.method, "PUT");
}
#[test]
fn cycle_method_wraps() {
assert_eq!(cycle_method("OPTIONS"), "GET");
assert_eq!(cycle_method("get"), "POST");
assert_eq!(cycle_method("FROBNICATE"), "POST");
}
#[test]
fn move_left_right_clamp() {
let mut p = pane();
p.toggle_view();
let len = p.request.url.len();
p.url_cursor = 0;
p.move_left(); assert_eq!(p.url_cursor, 0);
p.url_cursor = len;
p.move_right(); assert_eq!(p.url_cursor, len);
}
#[test]
fn as_go_no_body_omits_strings_import_and_uses_single_braces() {
let mut p = pane();
p.request.method = "GET".into();
p.request.url = "https://api.example.com/x".into();
p.request.body = None;
let go = p.as_go();
assert!(!go.contains("\"strings\""), "no strings import:\n{go}");
assert!(!go.contains("{{"), "no leaked double-braces:\n{go}");
assert!(
go.contains("if err != nil { panic(err) }"),
"single-brace panic block:\n{go}"
);
}
#[test]
fn as_go_with_body_imports_strings() {
let mut p = pane();
p.request.method = "POST".into();
p.request.url = "https://api.example.com/x".into();
p.request.body = Some(r#"{"a":1}"#.into());
let go = p.as_go();
assert!(go.contains("\"strings\""), "strings imported:\n{go}");
assert!(go.contains("strings.NewReader"), "strings used:\n{go}");
}
}