From 513d4fd8f8d8d58a129ca1595385b97acb7d8d12 Mon Sep 17 00:00:00 2001 From: Willians Faria Date: Mon, 27 May 2024 16:11:27 -0300 Subject: [PATCH] feat: displaying error messages on request --- Cargo.lock | 21 + reqtui/Cargo.toml | 1 + reqtui/src/net/request_manager.rs | 30 +- reqtui/src/net/request_strategies.rs | 2 +- .../net/request_strategies/http_strategy.rs | 103 +++-- reqtui/src/net/response_decoders.rs | 3 +- .../src/net/response_decoders/json_decoder.rs | 30 +- tui/Cargo.toml | 1 + tui/src/ascii.rs | 83 ++++ tui/src/lib.rs | 1 + tui/src/pages.rs | 1 + .../collection_viewer/collection_viewer.rs | 38 +- tui/src/pages/collection_viewer/req_editor.rs | 11 +- tui/src/pages/collection_viewer/res_viewer.rs | 399 ++++++++++++------ tui/src/pages/spinner.rs | 108 +++++ 15 files changed, 607 insertions(+), 225 deletions(-) create mode 100644 tui/src/ascii.rs create mode 100644 tui/src/pages/spinner.rs diff --git a/Cargo.lock b/Cargo.lock index c766fe2..0551ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,6 +624,15 @@ dependencies = [ "slab", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.14" @@ -852,6 +861,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonxf" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d6889ea54a6add10ed8a757719ec88293201265fa7fe56e09ae66b6df038a6" +dependencies = [ + "getopts", + "memchr", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1285,6 +1304,7 @@ dependencies = [ "async-trait", "cc", "config", + "jsonxf", "lazy_static", "ratatui", "reqwest", @@ -1969,6 +1989,7 @@ dependencies = [ "futures", "lazy_static", "mutants", + "rand", "ratatui", "reqtui", "reqwest", diff --git a/reqtui/Cargo.toml b/reqtui/Cargo.toml index 36e2f5c..82735e9 100644 --- a/reqtui/Cargo.toml +++ b/reqtui/Cargo.toml @@ -17,6 +17,7 @@ tree-sitter-json.workspace = true lazy_static.workspace = true ropey = "1.6.1" +jsonxf = "1.1.1" async-trait = "0.1.80" [build-dependencies] diff --git a/reqtui/src/net/request_manager.rs b/reqtui/src/net/request_manager.rs index 4185500..d17c276 100644 --- a/reqtui/src/net/request_manager.rs +++ b/reqtui/src/net/request_manager.rs @@ -11,23 +11,24 @@ use tokio::sync::mpsc::UnboundedSender; pub struct Response { pub body: Option, pub pretty_body: Option>, - pub headers: HeaderMap, + pub headers: Option>, pub duration: Duration, - pub status: reqwest::StatusCode, - pub headers_size: u64, - pub body_size: u64, - pub size: u64, + pub status: Option, + pub headers_size: Option, + pub body_size: Option, + pub size: Option, + pub is_error: bool, + pub cause: Option, } pub struct RequestManager; impl RequestManager { - pub async fn handle(strategy: S, request: Request) -> anyhow::Result + pub async fn handle(strategy: S, request: Request) -> Response where S: RequestStrategy, { - let response = strategy.handle(request).await?; - Ok(response) + strategy.handle(request).await } } @@ -59,7 +60,7 @@ impl From<&str> for ContentType { pub fn handle_request(request: Request, response_tx: UnboundedSender) { tracing::debug!("starting to handle user request"); tokio::spawn(async move { - let result = match request.body_type.as_ref() { + let response = match request.body_type.as_ref() { // if we dont have a body type, this is a GET request, so we use HTTP strategy None => RequestManager::handle(HttpResponse, request).await, Some(body_type) => match body_type { @@ -67,12 +68,9 @@ pub fn handle_request(request: Request, response_tx: UnboundedSender) }, }; - match result { - Ok(response) => response_tx.send(response).ok(), - Err(e) => { - tracing::error!("{e:?}"); - todo!(); - } - } + response_tx + .send(response) + .is_err() + .then(|| std::process::abort()); }); } diff --git a/reqtui/src/net/request_strategies.rs b/reqtui/src/net/request_strategies.rs index d422b81..9daac2d 100644 --- a/reqtui/src/net/request_strategies.rs +++ b/reqtui/src/net/request_strategies.rs @@ -4,5 +4,5 @@ use crate::{collection::types::Request, net::request_manager::Response}; #[async_trait::async_trait] pub trait RequestStrategy { - async fn handle(&self, request: Request) -> anyhow::Result; + async fn handle(&self, request: Request) -> Response; } diff --git a/reqtui/src/net/request_strategies/http_strategy.rs b/reqtui/src/net/request_strategies/http_strategy.rs index e6dbc66..7127312 100644 --- a/reqtui/src/net/request_strategies/http_strategy.rs +++ b/reqtui/src/net/request_strategies/http_strategy.rs @@ -1,5 +1,3 @@ -use std::error::Error; - use crate::{ collection::types::{Request, RequestMethod}, net::{ @@ -13,7 +11,7 @@ pub struct HttpResponse; #[async_trait::async_trait] impl RequestStrategy for HttpResponse { - async fn handle(&self, request: Request) -> anyhow::Result { + async fn handle(&self, request: Request) -> Response { let client = reqwest::Client::new(); match request.method { @@ -27,30 +25,29 @@ impl RequestStrategy for HttpResponse { } impl HttpResponse { - async fn handle_get_request( - &self, - client: reqwest::Client, - request: Request, - ) -> anyhow::Result { + async fn handle_get_request(&self, client: reqwest::Client, request: Request) -> Response { let now = std::time::Instant::now(); match client.get(request.uri).send().await { Ok(response) => { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await } - Err(e) => { - tracing::error!("{:?}", e.source()); - tracing::debug!("{e:?}"); - todo!(); - } + Err(e) => Response { + is_error: true, + cause: Some(e.to_string()), + body: None, + pretty_body: None, + body_size: None, + size: None, + headers_size: None, + status: None, + headers: None, + duration: now.elapsed(), + }, } } - async fn handle_post_request( - &self, - client: reqwest::Client, - request: Request, - ) -> anyhow::Result { + async fn handle_post_request(&self, client: reqwest::Client, request: Request) -> Response { let now = std::time::Instant::now(); match client .post(request.uri) @@ -62,15 +59,22 @@ impl HttpResponse { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await } - Err(_) => todo!(), + Err(e) => Response { + is_error: true, + cause: Some(e.to_string()), + body: None, + pretty_body: None, + body_size: None, + size: None, + headers_size: None, + status: None, + headers: None, + duration: now.elapsed(), + }, } } - async fn handle_put_request( - &self, - client: reqwest::Client, - request: Request, - ) -> anyhow::Result { + async fn handle_put_request(&self, client: reqwest::Client, request: Request) -> Response { let now = std::time::Instant::now(); match client .put(request.uri) @@ -82,15 +86,22 @@ impl HttpResponse { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await } - Err(_) => todo!(), + Err(e) => Response { + is_error: true, + cause: Some(e.to_string()), + body: None, + pretty_body: None, + body_size: None, + size: None, + headers_size: None, + status: None, + headers: None, + duration: now.elapsed(), + }, } } - async fn handle_patch_request( - &self, - client: reqwest::Client, - request: Request, - ) -> anyhow::Result { + async fn handle_patch_request(&self, client: reqwest::Client, request: Request) -> Response { let now = std::time::Instant::now(); match client .patch(request.uri) @@ -102,15 +113,22 @@ impl HttpResponse { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await } - Err(_) => todo!(), + Err(e) => Response { + is_error: true, + cause: Some(e.to_string()), + body: None, + pretty_body: None, + body_size: None, + size: None, + headers_size: None, + status: None, + headers: None, + duration: now.elapsed(), + }, } } - async fn handle_delete_request( - &self, - client: reqwest::Client, - request: Request, - ) -> anyhow::Result { + async fn handle_delete_request(&self, client: reqwest::Client, request: Request) -> Response { let now = std::time::Instant::now(); match client .delete(request.uri) @@ -122,7 +140,18 @@ impl HttpResponse { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await } - Err(_) => todo!(), + Err(e) => Response { + is_error: true, + cause: Some(e.to_string()), + body: None, + pretty_body: None, + body_size: None, + size: None, + headers_size: None, + status: None, + headers: None, + duration: now.elapsed(), + }, } } } diff --git a/reqtui/src/net/response_decoders.rs b/reqtui/src/net/response_decoders.rs index a384aa3..66f12af 100644 --- a/reqtui/src/net/response_decoders.rs +++ b/reqtui/src/net/response_decoders.rs @@ -9,8 +9,7 @@ use std::time::Instant; #[async_trait::async_trait] pub trait ResponseDecoder { - async fn decode(&self, response: reqwest::Response, start: Instant) - -> anyhow::Result; + async fn decode(&self, response: reqwest::Response, start: Instant) -> Response; } pub fn decoder_from_headers(headers: &HeaderMap) -> impl ResponseDecoder { diff --git a/reqtui/src/net/response_decoders/json_decoder.rs b/reqtui/src/net/response_decoders/json_decoder.rs index b6045f3..adc4856 100644 --- a/reqtui/src/net/response_decoders/json_decoder.rs +++ b/reqtui/src/net/response_decoders/json_decoder.rs @@ -8,14 +8,10 @@ pub struct JsonDecoder; #[async_trait::async_trait] impl ResponseDecoder for JsonDecoder { - async fn decode( - &self, - response: reqwest::Response, - start: Instant, - ) -> anyhow::Result { + async fn decode(&self, response: reqwest::Response, start: Instant) -> Response { let duration = start.elapsed(); - let headers = response.headers().to_owned(); - let status = response.status(); + let headers = Some(response.headers().to_owned()); + let status = Some(response.status()); let headers_size: u64 = response .headers() .iter() @@ -24,23 +20,29 @@ impl ResponseDecoder for JsonDecoder { let mut body: Option = None; let mut pretty_body = None; + if response.content_length().is_some_and(|len| len.gt(&0)) { - body = Some(response.json().await?); - pretty_body = Some(TextObject::from(&serde_json::to_string_pretty(&body)?)); + if let Ok(body_str) = response.text().await { + let pretty_body_str = jsonxf::pretty_print(&body_str).unwrap_or_default(); + pretty_body = Some(TextObject::from(&pretty_body_str)); + body = Some(body_str); + }; } let body_size = body.as_ref().map(|body| body.len()).unwrap_or_default() as u64; let size = headers_size.add(body_size); - Ok(Response { + Response { body, pretty_body, headers, duration, status, - size, - headers_size, - body_size, - }) + size: Some(size), + headers_size: Some(headers_size), + body_size: Some(body_size), + cause: None, + is_error: false, + } } } diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 2e825f1..acb54f3 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -25,6 +25,7 @@ tui-big-text = { version = "0.4.3" } tracing-subscriber = { version = "0.3.18" } tracing-appender = "0.2.3" uuid = { version = "1.8.0", features = ["v4", "fast-rng", "macro-diagnostics"] } +rand = "0.8.5" [dev-dependencies] tempfile = "3.10.1" diff --git a/tui/src/ascii.rs b/tui/src/ascii.rs new file mode 100644 index 0000000..c0892ed --- /dev/null +++ b/tui/src/ascii.rs @@ -0,0 +1,83 @@ +pub static BIG_ERROR_ARTS: &[&[&str]] = &[ + &[ + r#" ,adPPYba, 8b,dPPYba, 8b,dPPYba, ,adPPYba, 8b,dPPYba, "#, + r#"a8P_____88 88P' "Y8 88P' "Y8 a8" "8a 88P' "Y8 "#, + r#"8PP""""""" 88 88 8b d8 88 "#, + r#""8b, ,aa 88 88 "8a, ,a8" 88 "#, + r#" `"Ybbd8"' 88 88 `"YbbdP"' 88 "#, + ], + &[ + r#".d88b. 888d888888d888 .d88b. 888d888 "#, + r#"d8P Y8b888P" 888P" d88""88b888P" "#, + r#"88888888888 888 888 888888 "#, + r#"Y8b. 888 888 Y88..88P888 "#, + r#" "Y8888 888 888 "Y88P" 888 "#, + ], + &[ + r#" __ ,-. __ ,-. ,---. __ ,-. "#, + r#" ,' ,'/ /|,' ,'/ /| ' ,'\ ,' ,'/ /| "#, + r#" ,---. ' | |' |' | |' | / / |' | |' | "#, + r#" / \ | | ,'| | ,'. ; ,. :| | ,' "#, + r#" / / |' : / ' : / ' | |: :' : / "#, + r#". ' / || | ' | | ' ' | .; :| | ' "#, + r#"' ; /|; : | ; : | | : |; : | "#, + r#"' | / || , ; | , ; \ \ / | , ; "#, + r#"| : | ---' ---' `----' ---' "#, + r#" \ \ / "#, + r#" `----' "#, + ], + &[ + r#" /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ "#, + r#" /$$__ $$ /$$__ $$ /$$__ $$ /$$__ $$ /$$__ $$"#, + r#"| $$$$$$$$| $$ \__/| $$ \__/| $$ \ $$| $$ \__/"#, + r#"| $$_____/| $$ | $$ | $$ | $$| $$ "#, + r#"| $$$$$$$| $$ | $$ | $$$$$$/| $$ "#, + r#" \_______/|__/ |__/ \______/ |__/ "#, + ], + &[ + r#" .u . .u . u. .u . "#, + r#" .u .d88B :@8c .d88B :@8c ...ue888b .d88B :@8c "#, + r#" ud8888. ="8888f8888r ="8888f8888r 888R Y888r ="8888f8888r "#, + r#":888'8888. 4888>'88" 4888>'88" 888R I888> 4888>'88" "#, + r#"d888 '88%" 4888> ' 4888> ' 888R I888> 4888> ' "#, + r#"8888.+" 4888> 4888> 888R I888> 4888> "#, + r#"8888L .d888L .+ .d888L .+ u8888cJ888 .d888L .+ "#, + r#"'8888c. .+ ^"8888*" ^"8888*" "*888*P" ^"8888*" "#, + r#" "88888% "Y" "Y" 'Y" "Y" "#, + r#" "YP' "#, + ], +]; + +pub static SMALL_ERROR_ARTS: &[&[&str]] = &[ + &[ + r#" ___ _ __ _ __ ___ _ __ "#, + r#" / _ \ '__| '__/ _ \| '__|"#, + r#" | __/ | | | | (_) | | "#, + r#" \___|_| |_| \___/|_| "#, + ], + &[ + r#" __ _ __ _ __ ___ _ __ "#, + r#" /'__`\/\`'__\/\`'__\/ __`\/\`'__\"#, + r#"/\ __/\ \ \/ \ \ \//\ \L\ \ \ \/ "#, + r#"\ \____\\ \_\ \ \_\\ \____/\ \_\ "#, + r#" \/____/ \/_/ \/_/ \/___/ \/_/ "#, + ], + &[ + r#" dBBBP dBBBBBb dBBBBBb dBBBBP dBBBBBb"#, + r#" dBP dBP dBP.BP dBP"#, + r#" dBBP dBBBBK dBBBBK dBP.BP dBBBBK "#, + r#" dBP dBP BB dBP BB dBP.BP dBP BB "#, + r#" dBBBBP dBP dB' dBP dB' dBBBBP dBP dB' "#, + ], + &[ + r#".d88b 8d8b 8d8b .d8b. 8d8b "#, + r#"8.dP' 8P 8P 8' .8 8P "#, + r#"`Y88P 8 8 `Y8P' 8 "#, + ], + &[ + r#" ____ ____ ____ ___ ____ "#, + r#" / _ )/ ___) ___) _ \ / ___)"#, + r#"( (/ /| | | | | |_| | | "#, + r#" \____)_| |_| \___/|_| "#, + ], +]; diff --git a/tui/src/lib.rs b/tui/src/lib.rs index 1371c14..8fb919c 100644 --- a/tui/src/lib.rs +++ b/tui/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +mod ascii; pub mod event_pool; pub mod pages; pub mod screen_manager; diff --git a/tui/src/pages.rs b/tui/src/pages.rs index 2e9d2b2..a078000 100644 --- a/tui/src/pages.rs +++ b/tui/src/pages.rs @@ -4,6 +4,7 @@ pub mod confirm_popup; pub mod error_popup; pub mod input; mod overlay; +pub mod spinner; pub mod terminal_too_small; use crate::event_pool::Event; diff --git a/tui/src/pages/collection_viewer/collection_viewer.rs b/tui/src/pages/collection_viewer/collection_viewer.rs index c690d01..484769d 100644 --- a/tui/src/pages/collection_viewer/collection_viewer.rs +++ b/tui/src/pages/collection_viewer/collection_viewer.rs @@ -148,6 +148,7 @@ pub struct CollectionViewer<'ae> { editor: ReqEditor<'ae>, sender: Option>, + pending_request: bool, responses_map: HashMap>>, } @@ -187,7 +188,7 @@ impl<'ae> CollectionViewer<'ae> { editor: ReqEditor::new(colors, selected_request.clone(), layout.req_editor, config), - res_viewer: ResViewer::new(colors, None), + res_viewer: ResViewer::new(colors, None, layout.response_preview), hovered_request, selected_request, @@ -195,6 +196,7 @@ impl<'ae> CollectionViewer<'ae> { responses_map: HashMap::default(), sender: None, + pending_request: false, preview_tab: ResViewerTabs::Preview, raw_preview_scroll: 0, @@ -291,9 +293,14 @@ impl<'ae> CollectionViewer<'ae> { } } KeyCode::Enter => { - if let Some(req) = self.selected_request.as_ref() { + if self + .selected_request + .as_ref() + .is_some_and(|_| !self.pending_request) + { + self.pending_request = true; reqtui::net::handle_request( - req.clone().borrow().clone(), + self.selected_request.as_ref().unwrap().borrow().clone(), self.request_tx.clone(), ) } @@ -347,18 +354,20 @@ impl<'ae> CollectionViewer<'ae> { } fn draw_res_viewer(&mut self, frame: &mut Frame) { - let mut state = ResViewerState::new( - self.focused_pane.eq(&PaneFocus::Preview), - self.selected_pane + let mut state = ResViewerState { + is_focused: self.focused_pane.eq(&PaneFocus::Preview), + is_selected: self + .selected_pane .as_ref() .map(|sel| sel.eq(&PaneFocus::Preview)) .unwrap_or(false), - &self.preview_tab, - &mut self.raw_preview_scroll, - &mut self.pretty_preview_scroll, - &mut self.preview_header_scroll_y, - &mut self.preview_header_scroll_x, - ); + curr_tab: &self.preview_tab, + raw_scroll: &mut self.raw_preview_scroll, + pretty_scroll: &mut self.pretty_preview_scroll, + headers_scroll_y: &mut self.preview_header_scroll_y, + headers_scroll_x: &mut self.preview_header_scroll_x, + pending_request: self.pending_request, + }; frame.render_stateful_widget( self.res_viewer.clone(), @@ -387,6 +396,10 @@ impl<'ae> CollectionViewer<'ae> { .insert(req.borrow().clone(), Rc::clone(&res)) }); self.res_viewer.update(Some(Rc::clone(&res))); + + self.response_rx + .is_empty() + .then(|| self.pending_request = false); } } @@ -920,6 +933,7 @@ impl Page for CollectionViewer<'_> { fn resize(&mut self, new_size: Rect) { let new_layout = build_layout(new_size); self.editor.resize(new_layout.req_editor); + self.res_viewer.resize(new_layout.response_preview); self.layout = new_layout; } } diff --git a/tui/src/pages/collection_viewer/req_editor.rs b/tui/src/pages/collection_viewer/req_editor.rs index 152164e..b0adc92 100644 --- a/tui/src/pages/collection_viewer/req_editor.rs +++ b/tui/src/pages/collection_viewer/req_editor.rs @@ -60,7 +60,7 @@ pub struct ReqEditorState { is_selected: bool, } -impl<'re> ReqEditorState { +impl ReqEditorState { pub fn new(is_focused: bool, is_selected: bool) -> Self { ReqEditorState { is_focused, @@ -129,7 +129,7 @@ impl<'re> ReqEditor<'re> { curr_tab: request .as_ref() .map(request_has_no_body) - .unwrap() + .unwrap_or(false) .then_some(ReqEditorTabs::Headers) .unwrap_or_default(), request, @@ -242,7 +242,12 @@ impl<'re> ReqEditor<'re> { } fn draw_tabs(&self, buf: &mut Buffer, size: Rect) { - let (tabs, active) = if self.request.as_ref().map(request_has_no_body).unwrap() { + let (tabs, active) = if self + .request + .as_ref() + .map(request_has_no_body) + .unwrap_or(true) + { let tabs = vec!["Headers", "Query", "Auth"]; let active = match self.curr_tab { ReqEditorTabs::Headers => 0, diff --git a/tui/src/pages/collection_viewer/res_viewer.rs b/tui/src/pages/collection_viewer/res_viewer.rs index c2a41e0..766c721 100644 --- a/tui/src/pages/collection_viewer/res_viewer.rs +++ b/tui/src/pages/collection_viewer/res_viewer.rs @@ -1,32 +1,38 @@ +use rand::Rng; use reqtui::{net::request_manager::Response, syntax::highlighter::HIGHLIGHTER}; -use crate::utils::build_syntax_highlighted_lines; +use crate::{ + ascii::{BIG_ERROR_ARTS, SMALL_ERROR_ARTS}, + pages::spinner::Spinner, + utils::build_syntax_highlighted_lines, +}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, text::{Line, Span}, widgets::{ - Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Tabs, Widget, }, }; use std::{ cell::RefCell, iter, - ops::{Add, Deref}, + ops::{Add, Deref, Sub}, rc::Rc, }; use tree_sitter::Tree; pub struct ResViewerState<'a> { - is_focused: bool, - is_selected: bool, - curr_tab: &'a ResViewerTabs, - raw_scroll: &'a mut usize, - pretty_scroll: &'a mut usize, - headers_scroll_y: &'a mut usize, - headers_scroll_x: &'a mut usize, + pub is_focused: bool, + pub is_selected: bool, + pub curr_tab: &'a ResViewerTabs, + pub raw_scroll: &'a mut usize, + pub pretty_scroll: &'a mut usize, + pub headers_scroll_y: &'a mut usize, + pub headers_scroll_x: &'a mut usize, + pub pending_request: bool, } #[derive(Debug, Clone, PartialEq)] @@ -59,32 +65,17 @@ impl From for usize { } } +#[derive(Debug, Clone)] pub struct ResViewerLayout { tabs_pane: Rect, content_pane: Rect, summary_pane: Rect, } -impl<'a> ResViewerState<'a> { - pub fn new( - is_focused: bool, - is_selected: bool, - curr_tab: &'a ResViewerTabs, - raw_scroll: &'a mut usize, - pretty_scroll: &'a mut usize, - headers_scroll_y: &'a mut usize, - headers_scroll_x: &'a mut usize, - ) -> Self { - ResViewerState { - is_focused, - curr_tab, - is_selected, - raw_scroll, - pretty_scroll, - headers_scroll_y, - headers_scroll_x, - } - } +#[derive(Debug, Clone)] +struct PreviewLayout { + content_pane: Rect, + scrollbar: Rect, } #[derive(Debug, Clone)] @@ -93,10 +84,17 @@ pub struct ResViewer<'a> { response: Option>>, tree: Option, lines: Vec>, + error_lines: Option>>, + preview_layout: PreviewLayout, + layout: ResViewerLayout, } impl<'a> ResViewer<'a> { - pub fn new(colors: &'a colors::Colors, response: Option>>) -> Self { + pub fn new( + colors: &'a colors::Colors, + response: Option>>, + size: Rect, + ) -> Self { let tree = response.as_ref().and_then(|response| { if let Some(ref pretty_body) = response.borrow().pretty_body { let pretty_body = pretty_body.to_string(); @@ -107,14 +105,24 @@ impl<'a> ResViewer<'a> { } }); + let layout = build_layout(size); + ResViewer { colors, response, tree, lines: vec![], + error_lines: None, + preview_layout: build_preview_layout(layout.content_pane), + layout, } } + pub fn resize(&mut self, new_size: Rect) { + self.layout = build_layout(new_size); + self.preview_layout = build_preview_layout(self.layout.content_pane); + } + pub fn update(&mut self, response: Option>>) { let body_str = response .as_ref() @@ -134,6 +142,23 @@ impl<'a> ResViewer<'a> { self.lines = vec![]; } + if let Some(res) = response.as_ref() { + let cause: String = res + .borrow() + .cause + .as_ref() + .map(|cause| cause.to_string()) + .unwrap_or(String::default()); + + self.error_lines = Some( + get_error_ascii_art(self.preview_layout.content_pane.width) + .iter() + .map(|line| line.to_string().into()) + .chain(vec!["".into(), cause.fg(self.colors.normal.red).into()]) + .collect::>(), + ) + }; + self.response = response; } @@ -164,93 +189,160 @@ impl<'a> ResViewer<'a> { tabs.render(size, buf); } - fn draw_current_tab(&self, state: &mut ResViewerState, buf: &mut Buffer, size: Rect) { - match state.curr_tab { - ResViewerTabs::Preview => self.draw_pretty_response(state, buf, size), - ResViewerTabs::Raw => self.draw_raw_response(state, buf, size), - ResViewerTabs::Headers => self.draw_response_headers(state, buf, size), - ResViewerTabs::Cookies => {} - } + fn draw_spinner(&self, buf: &mut Buffer) { + let request_pane = self.preview_layout.content_pane; + let center = request_pane.y.add(request_pane.height.div_ceil(2)); + let size = Rect::new(request_pane.x, center, request_pane.width, 1); + let spinner = Spinner::default() + .with_label("Sending request".fg(self.colors.bright.black)) + .into_centered_line(); + + Widget::render(Clear, request_pane, buf); + Widget::render( + Block::default().bg(self.colors.primary.background), + request_pane, + buf, + ); + Widget::render(spinner, size, buf); } - fn draw_response_headers(&self, state: &mut ResViewerState, buf: &mut Buffer, size: Rect) { - if let Some(res) = self.response.as_ref() { - let headers = &res.borrow().headers; - let mut longest_line: usize = 0; + fn draw_network_error(&self, buf: &mut Buffer) { + if self.response.as_ref().is_some() { + let request_pane = self.preview_layout.content_pane; + Widget::render(Clear, request_pane, buf); + Widget::render( + Block::default().bg(self.colors.primary.background), + request_pane, + buf, + ); - let mut lines: Vec = vec![ - Line::from("Headers".fg(self.colors.normal.red).bold()), - Line::from(""), - ]; + let center = request_pane + .y + .add(request_pane.height.div_ceil(2)) + .sub(self.error_lines.as_ref().unwrap().len().div_ceil(2) as u16); - for (name, value) in headers { - if let Ok(value) = value.to_str() { - let name_string = name.to_string(); - let aux = name_string.len().max(value.len()); - longest_line = aux.max(longest_line); - lines.push(Line::from( - name_string - .chars() - .skip(*state.headers_scroll_x) - .collect::() - .bold() - .yellow(), - )); - lines.push(Line::from( - value - .chars() - .skip(*state.headers_scroll_x) - .collect::(), - )); - lines.push(Line::from("")); - } - } + let size = Rect::new( + request_pane.x, + center, + request_pane.width, + self.error_lines.as_ref().unwrap().len() as u16, + ); - if state - .headers_scroll_y - .deref() - // we add a blank line after every entry, we account for that here - .ge(&lines.len().saturating_sub(2)) - { - *state.headers_scroll_y = lines.len().saturating_sub(2); - } + Paragraph::new(self.error_lines.clone().unwrap()) + .fg(self.colors.bright.black) + .centered() + .render(size, buf); + } + } - if state - .headers_scroll_x - .deref() - .ge(&longest_line.saturating_sub(1)) - { - *state.headers_scroll_x = longest_line.saturating_sub(1); + fn draw_current_tab(&self, state: &mut ResViewerState, buf: &mut Buffer, size: Rect) { + if self + .response + .as_ref() + .is_some_and(|res| res.borrow().is_error) + { + self.draw_network_error(buf); + } else { + match state.curr_tab { + ResViewerTabs::Preview => self.draw_pretty_response(state, buf, size), + ResViewerTabs::Raw => self.draw_raw_response(state, buf, size), + ResViewerTabs::Headers => self.draw_response_headers(state, buf), + ResViewerTabs::Cookies => {} } + } - let [left_pane, y_scrollbar_pane] = build_preview_layout(size); - let [headers_pane, x_scrollbar_pane] = build_horizontal_scrollbar(left_pane); - self.draw_scrollbar(lines.len(), *state.headers_scroll_y, buf, y_scrollbar_pane); + if state.pending_request { + self.draw_spinner(buf); + } + } - let lines_to_show = if longest_line > left_pane.width as usize { - headers_pane.height - } else { - left_pane.height - }; + fn draw_response_headers(&self, state: &mut ResViewerState, buf: &mut Buffer) { + if let Some(response) = self.response.as_ref() { + if let Some(headers) = response.borrow().headers.as_ref() { + let mut longest_line: usize = 0; + + let mut lines: Vec = vec![ + Line::from("Headers".fg(self.colors.normal.red).bold()), + Line::from(""), + ]; + + for (name, value) in headers { + if let Ok(value) = value.to_str() { + let name_string = name.to_string(); + let aux = name_string.len().max(value.len()); + longest_line = aux.max(longest_line); + lines.push(Line::from( + name_string + .chars() + .skip(*state.headers_scroll_x) + .collect::() + .bold() + .yellow(), + )); + lines.push(Line::from( + value + .chars() + .skip(*state.headers_scroll_x) + .collect::(), + )); + lines.push(Line::from("")); + } + } - let lines = lines - .into_iter() - .skip(*state.headers_scroll_y) - .chain(iter::repeat(Line::from("~".fg(self.colors.bright.black)))) - .take(lines_to_show as usize) - .collect::>(); - - let block = Block::default().padding(Padding::left(1)); - if longest_line > left_pane.width as usize { - self.draw_horizontal_scrollbar( - longest_line, - *state.headers_scroll_x, + if state + .headers_scroll_y + .deref() + // we add a blank line after every entry, we account for that here + .ge(&lines.len().saturating_sub(2)) + { + *state.headers_scroll_y = lines.len().saturating_sub(2); + } + + if state + .headers_scroll_x + .deref() + .ge(&longest_line.saturating_sub(1)) + { + *state.headers_scroll_x = longest_line.saturating_sub(1); + } + + let [headers_pane, x_scrollbar_pane] = + build_horizontal_scrollbar(self.preview_layout.content_pane); + self.draw_scrollbar( + lines.len(), + *state.headers_scroll_y, buf, - x_scrollbar_pane, + self.preview_layout.scrollbar, ); - Paragraph::new(lines).block(block).render(headers_pane, buf); - } else { - Paragraph::new(lines).block(block).render(left_pane, buf); + + let lines_to_show = + if longest_line > self.preview_layout.content_pane.width as usize { + headers_pane.height + } else { + self.preview_layout.content_pane.height + }; + + let lines = lines + .into_iter() + .skip(*state.headers_scroll_y) + .chain(iter::repeat(Line::from("~".fg(self.colors.bright.black)))) + .take(lines_to_show as usize) + .collect::>(); + + let block = Block::default().padding(Padding::left(1)); + if longest_line > self.preview_layout.content_pane.width as usize { + self.draw_horizontal_scrollbar( + longest_line, + *state.headers_scroll_x, + buf, + x_scrollbar_pane, + ); + Paragraph::new(lines).block(block).render(headers_pane, buf); + } else { + Paragraph::new(lines) + .block(block) + .render(self.preview_layout.content_pane, buf); + } } } } @@ -277,9 +369,12 @@ impl<'a> ResViewer<'a> { *state.raw_scroll = lines.len().saturating_sub(1); } - let [request_pane, scrollbar_pane] = build_preview_layout(size); - - self.draw_scrollbar(lines.len(), *state.raw_scroll, buf, scrollbar_pane); + self.draw_scrollbar( + lines.len(), + *state.raw_scroll, + buf, + self.preview_layout.scrollbar, + ); let lines_in_view = lines .into_iter() @@ -289,7 +384,7 @@ impl<'a> ResViewer<'a> { .collect::>(); let raw_response = Paragraph::new(lines_in_view); - raw_response.render(request_pane, buf); + raw_response.render(self.preview_layout.content_pane, buf); } } @@ -337,9 +432,12 @@ impl<'a> ResViewer<'a> { *state.pretty_scroll = self.lines.len().saturating_sub(1); } - let [request_pane, scrollbar_pane] = build_preview_layout(size); - - self.draw_scrollbar(self.lines.len(), *state.raw_scroll, buf, scrollbar_pane); + self.draw_scrollbar( + self.lines.len(), + *state.raw_scroll, + buf, + self.preview_layout.scrollbar, + ); let lines = if self.lines.len().gt(&0) { self.lines.clone() @@ -355,36 +453,36 @@ impl<'a> ResViewer<'a> { .collect::>(); let pretty_response = Paragraph::new(lines_in_view); - pretty_response.render(request_pane, buf); + pretty_response.render(self.preview_layout.content_pane, buf); } } fn draw_summary(&self, buf: &mut Buffer, size: Rect) { if let Some(ref response) = self.response { - let status_color = match response.borrow().status.as_u16() { + let status_color = match response + .borrow() + .status + .map(|status| status.as_u16()) + .unwrap_or_default() + { s if s < 400 => self.colors.normal.green, _ => self.colors.normal.red, }; - let status = if size.width.gt(&50) { - format!( + + let status = match response.borrow().status { + Some(status) if size.width.gt(&50) => format!( "{} ({})", - response.borrow().status.as_str(), - response - .borrow() - .status + status.as_str(), + status .canonical_reason() .expect("tried to get a canonical_reason from a invalid status code") ) - .fg(status_color) - } else { - response - .borrow() - .status - .as_str() - .to_string() - .fg(status_color) + .fg(status_color), + Some(status) => status.as_str().to_string().fg(status_color), + None => "Error".fg(self.colors.normal.red), }; - let pieces: Vec = vec![ + + let mut pieces: Vec = vec![ "Status: ".fg(self.colors.bright.black), status, " ".into(), @@ -392,10 +490,13 @@ impl<'a> ResViewer<'a> { format!("{}ms", response.borrow().duration.as_millis()) .fg(self.colors.normal.green), " ".into(), - "Size: ".fg(self.colors.bright.black), - format!("{} B", response.borrow().size).fg(self.colors.normal.green), ]; + if let Some(size) = response.borrow().size { + pieces.push("Size: ".fg(self.colors.bright.black)); + pieces.push(format!("{} B", size).fg(self.colors.normal.green)) + }; + Line::from(pieces).render(size, buf); } } @@ -405,12 +506,10 @@ impl<'a> StatefulWidget for ResViewer<'a> { type State = ResViewerState<'a>; fn render(self, size: Rect, buf: &mut Buffer, state: &mut Self::State) { - let layout = build_layout(size); - + self.draw_tabs(buf, state, self.layout.tabs_pane); + self.draw_current_tab(state, buf, self.layout.content_pane); + self.draw_summary(buf, self.layout.summary_pane); self.draw_container(size, buf, state); - self.draw_tabs(buf, state, layout.tabs_pane); - self.draw_current_tab(state, buf, layout.content_pane); - self.draw_summary(buf, layout.summary_pane); } } @@ -452,8 +551,8 @@ fn build_horizontal_scrollbar(size: Rect) -> [Rect; 2] { [request_pane, scrollbar_pane] } -fn build_preview_layout(size: Rect) -> [Rect; 2] { - let [request_pane, _, scrollbar_pane] = Layout::default() +fn build_preview_layout(size: Rect) -> PreviewLayout { + let [content_pane, _, scrollbar] = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Fill(1), @@ -462,5 +561,25 @@ fn build_preview_layout(size: Rect) -> [Rect; 2] { ]) .areas(size); - [request_pane, scrollbar_pane] + PreviewLayout { + content_pane, + scrollbar, + } +} + +fn get_error_ascii_art(width: u16) -> &'static [&'static str] { + match width.gt(&60) { + true => { + let index = rand::thread_rng().gen_range(0..SMALL_ERROR_ARTS.len()); + SMALL_ERROR_ARTS[index] + } + false => { + let full_range_arts = SMALL_ERROR_ARTS + .iter() + .chain(BIG_ERROR_ARTS) + .collect::>(); + let index = rand::thread_rng().gen_range(0..full_range_arts.len()); + full_range_arts[index] + } + } } diff --git a/tui/src/pages/spinner.rs b/tui/src/pages/spinner.rs new file mode 100644 index 0000000..3647e0a --- /dev/null +++ b/tui/src/pages/spinner.rs @@ -0,0 +1,108 @@ +use std::ops::Add; + +use rand::Rng; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::Widget, +}; + +/// Renders a spinning widget to the screen. +/// +/// Can either be used as a StatefulWidget which will allow fine grained control, +/// or used as a Widget, which will render a random symbol that updates +#[derive(Debug, Clone)] +pub struct Spinner { + step: usize, + symbol_set: usize, + spinner_style: Style, + label: Option>, +} + +impl Default for Spinner { + fn default() -> Self { + Spinner::new() + } +} + +impl Spinner { + const DOTS: &'static [&'static str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const DOTS_BLOCK: &'static [&'static str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]; + const VERTICAL: &'static [&'static str] = &["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"]; + const SYMBOL_SET: &'static [&'static [&'static str]] = + &[Spinner::DOTS, Spinner::DOTS_BLOCK, Spinner::VERTICAL]; + + /// Creates a new Spinner using a random symbol from the `DOTS` set + pub fn new() -> Self { + Spinner { + step: 0, + symbol_set: 0, + spinner_style: Style::default(), + label: None, + } + } + + /// Specify the styling for the spinner symbol + pub fn with_style(self, spinner_style: Style) -> Self { + Spinner { + spinner_style, + step: self.step, + symbol_set: self.symbol_set, + label: self.label, + } + } + + pub fn with_label(self, label: S) -> Self + where + S: Into>, + { + Spinner { + label: Some(label.into()), + step: self.step, + symbol_set: self.symbol_set, + spinner_style: self.spinner_style, + } + } + + /// converts the spinner into a ratatui line + pub fn into_line(self) -> Line<'static> { + let mut pieces = vec![]; + let step = rand::thread_rng().gen_range(0..Spinner::SYMBOL_SET[self.symbol_set].len()); + let symbol = Spinner::SYMBOL_SET[self.symbol_set][step]; + pieces.push(Span::styled(symbol.to_string(), self.spinner_style)); + pieces.push(" ".into()); + if let Some(label) = self.label { + pieces.push(label); + } + Line::from(pieces) + } + + /// converts the spinner into a ratatui centered line + pub fn into_centered_line(self) -> Line<'static> { + self.into_line().centered() + } +} + +impl Widget for Spinner { + fn render(self, size: Rect, buf: &mut Buffer) + where + Self: Sized, + { + if size.height < 1 { + return; + } + + let step = rand::thread_rng().gen_range(0..Spinner::SYMBOL_SET[self.symbol_set].len()); + let symbol = Spinner::SYMBOL_SET[self.symbol_set][step]; + let span = Span::styled(symbol.to_string(), self.spinner_style); + + buf.set_style(size, self.spinner_style); + let (col, row) = buf.set_span(size.x, size.y, &span, size.width); + + if let Some(label) = self.label { + buf.set_span(col.add(1), row, &label, label.content.len() as u16); + } + } +}