From b90d8b3fdc3be412b5ccd2bdf887959b1d05d23f Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Fri, 26 Apr 2024 15:53:10 -0400 Subject: [PATCH] Add action to save response body to file Closes #183 --- CHANGELOG.md | 1 + src/http.rs | 1 + src/http/cereal.rs | 92 ++++++++++ src/http/record.rs | 172 ++++++++--------- src/template/prompt.rs | 8 +- src/tui.rs | 11 +- src/tui/message.rs | 21 ++- src/tui/signal.rs | 50 ----- src/tui/util.rs | 164 +++++++++++++++++ src/tui/view.rs | 2 +- src/tui/view/common.rs | 1 + src/tui/view/common/actions.rs | 4 +- src/tui/view/common/button.rs | 102 +++++++++++ src/tui/view/component.rs | 2 - src/tui/view/component/misc.rs | 109 ++++++++++- src/tui/view/component/recipe_pane.rs | 12 +- src/tui/view/component/record_body.rs | 2 +- src/tui/view/component/response_pane.rs | 234 ++++++++++++++++++++---- src/tui/view/event.rs | 10 +- src/tui/view/util.rs | 12 +- 20 files changed, 792 insertions(+), 218 deletions(-) create mode 100644 src/http/cereal.rs delete mode 100644 src/tui/signal.rs create mode 100644 src/tui/util.rs create mode 100644 src/tui/view/common/button.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7294e947..aa6053e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - Add option to chain values from response header rather than body ([#184](https://github.com/LucasPickering/slumber/issues/184)) +- Add action to save response body to file ([#183](https://github.com/LucasPickering/slumber/issues/183)) ### Changed diff --git a/src/http.rs b/src/http.rs index 93ca9ad7..c712c651 100644 --- a/src/http.rs +++ b/src/http.rs @@ -33,6 +33,7 @@ //! | RequestRecord | //! +---------------+ +mod cereal; mod content_type; mod query; mod record; diff --git a/src/http/cereal.rs b/src/http/cereal.rs new file mode 100644 index 00000000..6bb4abdf --- /dev/null +++ b/src/http/cereal.rs @@ -0,0 +1,92 @@ +//! Serialization/deserialization for HTTP-releated types + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialization/deserialization for [reqwest::Method] +pub mod serde_method { + use super::*; + use reqwest::Method; + + pub fn serialize( + method: &Method, + serializer: S, + ) -> Result + where + S: Serializer, + { + serializer.serialize_str(method.as_str()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + <&str>::deserialize(deserializer)? + .parse() + .map_err(de::Error::custom) + } +} + +/// Serialization/deserialization for [reqwest::header::HeaderMap] +pub mod serde_header_map { + use super::*; + use indexmap::IndexMap; + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + + pub fn serialize( + headers: &HeaderMap, + serializer: S, + ) -> Result + where + S: Serializer, + { + // HeaderValue -> str is fallible, so we'll serialize as bytes instead + >::serialize( + &headers + .into_iter() + .map(|(k, v)| (k.as_str(), v.as_bytes())) + .collect(), + serializer, + ) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + >>::deserialize(deserializer)? + .into_iter() + .map::, _>(|(k, v)| { + // Fallibly map each key and value to header types + Ok(( + k.try_into().map_err(de::Error::custom)?, + v.try_into().map_err(de::Error::custom)?, + )) + }) + .collect() + } +} + +/// Serialization/deserialization for [reqwest::StatusCode] +pub mod serde_status_code { + use super::*; + use reqwest::StatusCode; + + pub fn serialize( + status_code: &StatusCode, + serializer: S, + ) -> Result + where + S: Serializer, + { + serializer.serialize_u16(status_code.as_u16()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + StatusCode::from_u16(u16::deserialize(deserializer)?) + .map_err(de::Error::custom) + } +} diff --git a/src/http/record.rs b/src/http/record.rs index ab93222d..5ff7ba29 100644 --- a/src/http/record.rs +++ b/src/http/record.rs @@ -2,7 +2,7 @@ use crate::{ collection::{ProfileId, RecipeId}, - http::{ContentType, ResponseContent}, + http::{cereal, ContentType, ResponseContent}, util::ResultExt, }; use anyhow::Context; @@ -10,9 +10,9 @@ use bytes::Bytes; use bytesize::ByteSize; use chrono::{DateTime, Duration, Utc}; use derive_more::{Display, From}; -use indexmap::IndexMap; +use mime::Mime; use reqwest::{ - header::{self, HeaderMap, HeaderValue}, + header::{self, HeaderMap}, Method, StatusCode, }; use serde::{Deserialize, Serialize}; @@ -111,11 +111,11 @@ pub struct Request { /// The recipe used to generate this request (for historical context) pub recipe_id: RecipeId, - #[serde(with = "serde_method")] + #[serde(with = "cereal::serde_method")] pub method: Method, /// URL, including query params/fragment pub url: Url, - #[serde(with = "serde_header_map")] + #[serde(with = "cereal::serde_header_map")] pub headers: HeaderMap, /// Body content as bytes. This should be decoded as needed pub body: Option, @@ -171,9 +171,9 @@ impl Request { /// potentially be very large. #[derive(Debug, Serialize, Deserialize)] pub struct Response { - #[serde(with = "serde_status_code")] + #[serde(with = "cereal::serde_status_code")] pub status: StatusCode, - #[serde(with = "serde_header_map")] + #[serde(with = "cereal::serde_header_map")] pub headers: HeaderMap, pub body: Body, } @@ -194,11 +194,39 @@ impl Response { } } - /// Get the value of the `content-type` header - pub fn content_type(&self) -> Option<&[u8]> { + /// Get a suggested file name for the content of this response. First we'll + /// check the Content-Disposition header. If it's missing or doesn't have a + /// file name, we'll check the Content-Type to at least guess at an + /// extension. + pub fn file_name(&self) -> Option { self.headers - .get(header::CONTENT_TYPE) - .map(HeaderValue::as_bytes) + .get(header::CONTENT_DISPOSITION) + .and_then(|value| { + // Parse header for the `filename="{}"` parameter + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + let value = value.to_str().ok()?; + value.split(';').find_map(|part| { + let (key, value) = part.trim().split_once('=')?; + if key == "filename" { + Some(value.trim_matches('"').to_owned()) + } else { + None + } + }) + }) + .or_else(|| { + // Grab the extension from the Content-Type header. Don't use + // self.conten_type() because we want to accept unknown types. + let content_type = self.headers.get(header::CONTENT_TYPE)?; + let mime: Mime = content_type.to_str().ok()?.parse().ok()?; + Some(format!("data.{}", mime.subtype())) + }) + } + + /// Get the content type of this response, based on the `Content-Type` + /// header. Return `None` if the header is missing or an unknown type. + pub fn content_type(&self) -> Option { + ContentType::from_response(self).ok() } } @@ -309,103 +337,47 @@ impl PartialEq for Body { } } -/// Serialization/deserialization for [reqwest::Method] -mod serde_method { - use super::*; - use serde::{de, Deserializer, Serializer}; - - pub fn serialize( - method: &Method, - serializer: S, - ) -> Result - where - S: Serializer, - { - serializer.serialize_str(method.as_str()) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - <&str>::deserialize(deserializer)? - .parse() - .map_err(de::Error::custom) - } -} - -/// Serialization/deserialization for [reqwest::header::HeaderMap] -mod serde_header_map { - use super::*; - use reqwest::header::{HeaderName, HeaderValue}; - use serde::{de, Deserializer, Serializer}; - - pub fn serialize( - headers: &HeaderMap, - serializer: S, - ) -> Result - where - S: Serializer, - { - // HeaderValue -> str is fallible, so we'll serialize as bytes instead - >::serialize( - &headers - .into_iter() - .map(|(k, v)| (k.as_str(), v.as_bytes())) - .collect(), - serializer, - ) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - >>::deserialize(deserializer)? - .into_iter() - .map::, _>(|(k, v)| { - // Fallibly map each key and value to header types - Ok(( - k.try_into().map_err(de::Error::custom)?, - v.try_into().map_err(de::Error::custom)?, - )) - }) - .collect() - } -} - -/// Serialization/deserialization for [reqwest::StatusCode] -mod serde_status_code { - use super::*; - use serde::{de, Deserializer, Serializer}; - - pub fn serialize( - status_code: &StatusCode, - serializer: S, - ) -> Result - where - S: Serializer, - { - serializer.serialize_u16(status_code.as_u16()) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - StatusCode::from_u16(u16::deserialize(deserializer)?) - .map_err(de::Error::custom) - } -} - #[cfg(test)] mod tests { use super::*; use crate::test_util::*; use factori::create; use indexmap::indexmap; + use rstest::rstest; use serde_json::json; + #[rstest] + #[case::content_disposition( + create!(Response, headers: header_map(indexmap! { + "content-disposition" => "form-data;name=\"field\"; filename=\"fish.png\"", + "content-type" => "image/png", + })), + Some("fish.png") + )] + #[case::content_type_known( + create!(Response, headers: header_map(indexmap! { + "content-disposition" => "form-data", + "content-type" => "application/json", + })), + Some("data.json") + )] + #[case::content_type_unknown( + create!(Response, headers: header_map(indexmap! { + "content-disposition" => "form-data", + "content-type" => "image/jpeg", + })), + Some("data.jpeg") + )] + #[case::none( + create!(Response),None + )] + fn test_file_name( + #[case] response: Response, + #[case] expected: Option<&str>, + ) { + assert_eq!(response.file_name().as_deref(), expected); + } + #[test] fn test_to_curl() { let headers = indexmap! { diff --git a/src/template/prompt.rs b/src/template/prompt.rs index cd35b1cd..b1e688cd 100644 --- a/src/template/prompt.rs +++ b/src/template/prompt.rs @@ -31,18 +31,18 @@ pub struct Prompt { /// Should the value the user is typing be masked? E.g. password input pub sensitive: bool, /// How the prompter will pass the answer back - pub channel: PromptChannel, + pub channel: PromptChannel, } /// Channel used to return a prompt response. This is its own type so we can /// provide wrapping functionality while letting the user decompose the `Prompt` /// type. #[derive(Debug, From)] -pub struct PromptChannel(oneshot::Sender); +pub struct PromptChannel(oneshot::Sender); -impl PromptChannel { +impl PromptChannel { /// Return the value that the user gave - pub fn respond(self, response: String) { + pub fn respond(self, response: T) { // This error *shouldn't* ever happen, because the templating task // stays open until it gets a response let _ = self diff --git a/src/tui.rs b/src/tui.rs index b525470e..0d12fc27 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,7 +1,7 @@ pub mod context; pub mod input; pub mod message; -mod signal; +mod util; mod view; use crate::{ @@ -14,7 +14,7 @@ use crate::{ context::TuiContext, input::{Action, InputEngine}, message::{Message, MessageSender, RequestConfig}, - signal::signals, + util::{save_file, signals}, view::{ModalPriority, PreviewPrompter, RequestState, View}, }, util::Replaceable, @@ -189,6 +189,9 @@ impl Tui { self.copy_request_curl(request_config)?; } Message::CopyText(text) => self.view.copy_text(text), + Message::SaveFile { default_path, data } => { + self.spawn(save_file(self.messages_tx(), default_path, data)); + } Message::Error { error } => { self.view.open_modal(error, ModalPriority::High) @@ -253,9 +256,13 @@ impl Tui { self.load_request(profile_id.as_ref(), &recipe_id)?; } + Message::Notify(message) => self.view.notify(message), Message::PromptStart(prompt) => { self.view.open_modal(prompt, ModalPriority::Low); } + Message::ConfirmStart(confirm) => { + self.view.open_modal(confirm, ModalPriority::Low); + } Message::TemplatePreview { template, diff --git a/src/tui/message.rs b/src/tui/message.rs index 6c8c2e39..c98dd684 100644 --- a/src/tui/message.rs +++ b/src/tui/message.rs @@ -7,7 +7,7 @@ use crate::{ RecipeOptions, Request, RequestBuildError, RequestError, RequestRecord, }, template::{Prompt, Prompter, Template, TemplateChunk}, - tui::input::Action, + tui::{input::Action, view::Confirm}, util::ResultExt, }; use anyhow::Context; @@ -48,8 +48,8 @@ impl Prompter for MessageSender { /// A message triggers some *asynchronous* action. Most state modifications can /// be made synchronously by the input handler, but some require async handling -/// at the top level. The controller is responsible for both triggering and -/// handling messages. +/// at the top level. Messages can be triggered from anywhere (via the TUI +/// context), but are all handled by the top-level controller. #[derive(Debug)] pub enum Message { /// Trigger collection reload @@ -59,6 +59,10 @@ pub enum Message { /// Open the collection in the user's editor CollectionEdit, + /// Show a yes/no confirmation to the user. Use the included channel to + /// return the value. + ConfirmStart(Confirm), + /// Render request URL from a recipe, then copy rendered URL CopyRequestUrl(RequestConfig), /// Render request body from a recipe, then copy rendered text @@ -98,6 +102,8 @@ pub enum Message { action: Option, }, + /// Send an informational notification to the user + Notify(String), /// Show a prompt to the user, asking for some input. Use the included /// channel to return the value. PromptStart(Prompt), @@ -111,6 +117,15 @@ pub enum Message { recipe_id: RecipeId, }, + /// Save data to a file. Could be binary (e.g. image) or encoded text + SaveFile { + /// A suggestion for the file name. User will have the opportunity to + /// change this + default_path: Option, + /// Data to save + data: Vec, + }, + /// Render a template string, to be previewed in the UI. Ideally this could /// be launched directly by the component that needs it, but only the /// controller has the data needed to build the template context. The diff --git a/src/tui/signal.rs b/src/tui/signal.rs deleted file mode 100644 index eea63549..00000000 --- a/src/tui/signal.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Utilities for handling signals to the process - -use futures::{future, FutureExt}; -use tracing::info; - -/// Listen for any exit signals, and return `Ok(())` when any signal is -/// received. This can only fail during initialization. -#[cfg(unix)] -pub async fn signals() -> anyhow::Result<()> { - use anyhow::Context; - use itertools::Itertools; - use tokio::signal::unix::{signal, Signal, SignalKind}; - - let signals: Vec<(Signal, SignalKind)> = [ - SignalKind::interrupt(), - SignalKind::hangup(), - SignalKind::terminate(), - SignalKind::quit(), - ] - .into_iter() - .map::, _>(|kind| { - let signal = signal(kind).with_context(|| { - format!("Error initializing listener for signal `{kind:?}`") - })?; - Ok((signal, kind)) - }) - .try_collect()?; - let futures = signals - .into_iter() - .map(|(mut signal, kind)| async move { - signal.recv().await; - info!(?kind, "Received signal"); - }) - .map(FutureExt::boxed); - future::select_all(futures).await; - Ok(()) -} - -/// Listen for any exit signals, and return `Ok(())` when any signal is -/// received. This can only fail during initialization. -#[cfg(windows)] -pub async fn signals() -> anyhow::Result<()> { - use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; - - let (mut s1, mut s2, mut s3) = (ctrl_c()?, ctrl_break()?, ctrl_close()?); - let futures = vec![s1.recv().boxed(), s2.recv().boxed(), s3.recv().boxed()]; - future::select_all(futures).await; - info!("Received exit signal"); - Ok(()) -} diff --git a/src/tui/util.rs b/src/tui/util.rs new file mode 100644 index 00000000..8e0d32be --- /dev/null +++ b/src/tui/util.rs @@ -0,0 +1,164 @@ +//! Utilities for signal and message handling for the TUI. Most non-core +//! functionality is spun out into this module. + +use crate::{ + template::Prompt, + tui::{ + message::{Message, MessageSender}, + view::Confirm, + }, + util::ResultExt, +}; +use anyhow::Context; +use futures::{future, FutureExt}; +use std::io; +use tokio::{fs::OpenOptions, io::AsyncWriteExt, sync::oneshot}; +use tracing::{debug, info, warn}; + +/// Listen for any exit signals, and return `Ok(())` when any signal is +/// received. This can only fail during initialization. +#[cfg(unix)] +pub async fn signals() -> anyhow::Result<()> { + use itertools::Itertools; + use tokio::signal::unix::{signal, Signal, SignalKind}; + + let signals: Vec<(Signal, SignalKind)> = [ + SignalKind::interrupt(), + SignalKind::hangup(), + SignalKind::terminate(), + SignalKind::quit(), + ] + .into_iter() + .map::, _>(|kind| { + let signal = signal(kind).with_context(|| { + format!("Error initializing listener for signal `{kind:?}`") + })?; + Ok((signal, kind)) + }) + .try_collect()?; + let futures = signals + .into_iter() + .map(|(mut signal, kind)| async move { + signal.recv().await; + info!(?kind, "Received signal"); + }) + .map(FutureExt::boxed); + future::select_all(futures).await; + Ok(()) +} + +/// Listen for any exit signals, and return `Ok(())` when any signal is +/// received. This can only fail during initialization. +#[cfg(windows)] +pub async fn signals() -> anyhow::Result<()> { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let (mut s1, mut s2, mut s3) = (ctrl_c()?, ctrl_break()?, ctrl_close()?); + let futures = vec![s1.recv().boxed(), s2.recv().boxed(), s3.recv().boxed()]; + future::select_all(futures).await; + info!("Received exit signal"); + Ok(()) +} + +/// Save some data to disk. This will: +/// - Ask the user for a path +/// - Attempt to save a *new* file +/// - If the file already exists, ask for confirmation +/// - If confirmed, overwrite existing +pub async fn save_file( + messages_tx: MessageSender, + default_path: Option, + data: Vec, +) -> anyhow::Result<()> { + // If the user closed the prompt, just exit + let Some(path) = + prompt(&messages_tx, "Enter a path for the file", default_path).await + else { + return Ok(()); + }; + + // If the user input nothing, assume they just want to exit + if path.is_empty() { + return Ok(()); + } + + let result = { + // Attempt to open the file *if it doesn't exist already* + let result = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .await; + + match result { + Ok(file) => Ok(file), + // If the file already exists, ask for confirmation to overwrite + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + warn!(path, "File already exists, asking to overwrite"); + + // Hi, sorry, follow up question. Are you sure? + if confirm( + &messages_tx, + format!("`{path}` already exists, overwrite?"), + ) + .await + { + // REALLY attempt to open the file + OpenOptions::new() + .create(true) + .write(true) + .open(&path) + .await + } else { + return Ok(()); + } + } + Err(error) => Err(error), + } + }; + + debug!(?path, bytes = data.len(), "Writing to file"); + let mut file = result + .with_context(|| format!("Error opening file `{path}`")) + .traced()?; + file.write_all(&data) + .await + .with_context(|| format!("Error writing to file `{path}`")) + .traced()?; + + // It might be nice to show the full path here, but it's not trivial to get + // that. The stdlib has fs::canonicalize, but it does more than we need + // (specifically it resolves symlinks), which might be confusing + messages_tx.send(Message::Notify(format!("Saved to {path}"))); + Ok(()) +} + +/// Ask the user for some text input and wait for a response. Return `None` if +/// the prompt is closed with no input. +async fn prompt( + messages_tx: &MessageSender, + message: impl ToString, + default: Option, +) -> Option { + let (tx, rx) = oneshot::channel(); + messages_tx.send(Message::PromptStart(Prompt { + message: message.to_string(), + default, + sensitive: false, + channel: tx.into(), + })); + // Error indicates no response, we can throw that away + rx.await.ok() +} + +/// Ask the user a yes/no question and wait for a response +async fn confirm(messages_tx: &MessageSender, message: impl ToString) -> bool { + let (tx, rx) = oneshot::channel(); + let confirm = Confirm { + message: message.to_string(), + channel: tx.into(), + }; + messages_tx.send(Message::ConfirmStart(confirm)); + // Error means we got ghosted :( RUDE! + rx.await.unwrap_or_default() +} diff --git a/src/tui/view.rs b/src/tui/view.rs index 0387ebf3..e83f6a42 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -9,7 +9,7 @@ mod util; pub use common::modal::{IntoModal, ModalPriority}; pub use state::RequestState; pub use theme::Theme; -pub use util::PreviewPrompter; +pub use util::{Confirm, PreviewPrompter}; use crate::{ collection::{CollectionFile, ProfileId, RecipeId}, diff --git a/src/tui/view/common.rs b/src/tui/view/common.rs index 3a5c6c97..be10af79 100644 --- a/src/tui/view/common.rs +++ b/src/tui/view/common.rs @@ -2,6 +2,7 @@ //! generic, i.e. usable in more than a single narrow context. pub mod actions; +pub mod button; pub mod header_table; pub mod list; pub mod modal; diff --git a/src/tui/view/common/actions.rs b/src/tui/view/common/actions.rs index 1b8b810d..a1fd6f6e 100644 --- a/src/tui/view/common/actions.rs +++ b/src/tui/view/common/actions.rs @@ -32,8 +32,8 @@ impl Default for ActionsModal { // callback event. Jank but it works EventQueue::push(Event::CloseModal); let event = match action { - EnumChain::T(action) => Event::other(*action), - EnumChain::U(action) => Event::other(*action), + EnumChain::T(action) => Event::new_other(*action), + EnumChain::U(action) => Event::new_other(*action), }; EventQueue::push(event); }; diff --git a/src/tui/view/common/button.rs b/src/tui/view/common/button.rs new file mode 100644 index 00000000..6caecb58 --- /dev/null +++ b/src/tui/view/common/button.rs @@ -0,0 +1,102 @@ +//! Buttons and button accessories + +use crate::tui::{ + context::TuiContext, + input::Action, + message::MessageSender, + view::{ + draw::{Draw, Generate}, + event::{Event, EventHandler, EventQueue, Update}, + state::fixed_select::{FixedSelect, FixedSelectState}, + }, +}; +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + text::Span, + Frame, +}; + +/// An piece of text that the user can "press" with the submit action. It should +/// only be interactable if it is focused, but that's up to the caller to +/// enforce. +pub struct Button<'a> { + text: &'a str, + is_focused: bool, +} + +impl<'a> Generate for Button<'a> { + type Output<'this> = Span<'this> + where + Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + let theme = &TuiContext::get().theme; + Span { + content: self.text.into(), + style: if self.is_focused { + theme.text.highlight + } else { + Default::default() + }, + } + } +} + +/// A collection of buttons. User can cycle between buttons and hit enter to +/// activate one. When a button is activated, it will emit a dynamic event with +/// type `T`. +#[derive(derive_more::Debug, Default)] +pub struct ButtonGroup { + select: FixedSelectState, +} + +impl EventHandler for ButtonGroup { + fn update(&mut self, _: &MessageSender, event: Event) -> Update { + let Some(action) = event.action() else { + return Update::Propagate(event); + }; + match action { + Action::Left => self.select.previous(), + Action::Right => self.select.next(), + Action::Submit => { + // Propagate the selected item as a dynamic event + EventQueue::push(Event::new_other(*self.select.selected())); + } + _ => return Update::Propagate(event), + } + Update::Consumed + } + + // Do *not* treat the select state as a child, because the default select + // action bindings aren't intuitive for this component +} + +impl Draw for ButtonGroup { + fn draw(&self, frame: &mut Frame, _: (), area: Rect) { + let items = self.select.items(); + // The button width is based on the longest button + let width = items + .iter() + .map(|button| button.to_string().len()) + .max() + .unwrap_or(0) as u16; + let (areas, _) = + Layout::horizontal(items.iter().map(|_| Constraint::Length(width))) + .flex(Flex::SpaceAround) + .split_with_spacers(area); + + for (button, area) in items.iter().zip(areas.iter()) { + frame.render_widget( + Button { + text: &button.to_string(), + is_focused: self.select.is_selected(button), + } + .generate(), + *area, + ) + } + } +} diff --git a/src/tui/view/component.rs b/src/tui/view/component.rs index e380f307..18b5b034 100644 --- a/src/tui/view/component.rs +++ b/src/tui/view/component.rs @@ -1,5 +1,3 @@ -//! Specific single-use components - mod help; mod misc; mod primary; diff --git a/src/tui/view/component/misc.rs b/src/tui/view/component/misc.rs index 304a431d..e91b3011 100644 --- a/src/tui/view/component/misc.rs +++ b/src/tui/view/component/misc.rs @@ -3,23 +3,30 @@ use crate::{ template::{Prompt, PromptChannel}, - tui::view::{ - common::{ - modal::{IntoModal, Modal}, - text_box::TextBox, + tui::{ + message::MessageSender, + view::{ + common::{ + button::ButtonGroup, + modal::{IntoModal, Modal}, + text_box::TextBox, + }, + component::Component, + draw::{Draw, Generate}, + event::{Event, EventHandler, EventQueue, Update}, + state::Notification, + Confirm, }, - component::Component, - draw::{Draw, Generate}, - event::{Event, EventHandler, EventQueue}, - state::Notification, }, }; +use derive_more::Display; use ratatui::{ prelude::{Constraint, Rect}, widgets::{Paragraph, Wrap}, Frame, }; use std::{cell::Cell, fmt::Debug, rc::Rc}; +use strum::{EnumCount, EnumIter}; #[derive(Debug)] pub struct ErrorModal(anyhow::Error); @@ -59,7 +66,7 @@ pub struct PromptModal { /// Modal title, from the prompt message title: String, /// Channel used to submit entered value - channel: PromptChannel, + channel: PromptChannel, /// Flag set before closing to indicate if we should submit in our own /// `on_close`. This is set from the text box's `on_submit`. submit: Rc>, @@ -131,6 +138,90 @@ impl IntoModal for Prompt { } } +/// Inner state for the prompt modal +#[derive(Debug)] +pub struct ConfirmModal { + /// Modal title, from the prompt message + title: String, + /// Channel used to submit yes/no. This is an option so we can take the + /// value when a submission is given, and then close the modal. It should + /// only ever be taken once. + channel: Option>, + buttons: Component>, +} + +/// Buttons in the confirmation modal +#[derive( + Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, +)] +enum ConfirmButton { + No, + #[default] + Yes, +} + +impl ConfirmModal { + pub fn new(confirm: Confirm) -> Self { + Self { + title: confirm.message, + channel: Some(confirm.channel), + buttons: Default::default(), + } + } +} + +impl Modal for ConfirmModal { + fn title(&self) -> &str { + &self.title + } + + fn dimensions(&self) -> (Constraint, Constraint) { + ( + // Add some arbitrary padding + Constraint::Length((self.title.len() + 4) as u16), + Constraint::Length(1), + ) + } +} + +impl EventHandler for ConfirmModal { + fn update(&mut self, _: &MessageSender, event: Event) -> Update { + // When user selects a button, send the response and close + let Some(button) = event.other::() else { + return Update::Propagate(event); + }; + // Channel *should* always be available here, because after handling + // this event for the first time we close the modal. Hypothetically we + // could get two submissions in rapid succession though, so ignore + // subsequent ones. + if let Some(channel) = self.channel.take() { + channel.respond(*button == ConfirmButton::Yes); + } + + EventQueue::push(Event::CloseModal); + Update::Consumed + } + + fn children(&mut self) -> Vec> { + vec![self.buttons.as_child()] + } +} + +impl Draw for ConfirmModal { + fn draw(&self, frame: &mut Frame, _: (), area: Rect) { + self.buttons.draw(frame, (), area); + } +} + +impl IntoModal for Confirm { + type Target = ConfirmModal; + + fn into_modal(self) -> Self::Target { + ConfirmModal::new(self) + } +} + +/// Show most recent notification with timestamp #[derive(Debug)] pub struct NotificationText { notification: Notification, diff --git a/src/tui/view/component/recipe_pane.rs b/src/tui/view/component/recipe_pane.rs index 2a2bacee..a06a3ce2 100644 --- a/src/tui/view/component/recipe_pane.rs +++ b/src/tui/view/component/recipe_pane.rs @@ -552,8 +552,8 @@ mod tests { /// Test "Copy URL" action #[rstest] fn test_copy_url(mut component: RecipePane, mut messages: MessageQueue) { - let update = - component.update(messages.tx(), Event::other(MenuAction::CopyUrl)); + let update = component + .update(messages.tx(), Event::new_other(MenuAction::CopyUrl)); // unstable: https://github.com/rust-lang/rust/issues/82775 assert!(matches!(update, Update::Consumed)); @@ -574,8 +574,8 @@ mod tests { /// Test "Copy Body" action #[rstest] fn test_copy_body(mut component: RecipePane, mut messages: MessageQueue) { - let update = - component.update(messages.tx(), Event::other(MenuAction::CopyBody)); + let update = component + .update(messages.tx(), Event::new_other(MenuAction::CopyBody)); // unstable: https://github.com/rust-lang/rust/issues/82775 assert!(matches!(update, Update::Consumed)); @@ -599,8 +599,8 @@ mod tests { mut component: RecipePane, mut messages: MessageQueue, ) { - let update = - component.update(messages.tx(), Event::other(MenuAction::CopyCurl)); + let update = component + .update(messages.tx(), Event::new_other(MenuAction::CopyCurl)); // unstable: https://github.com/rust-lang/rust/issues/82775 assert!(matches!(update, Update::Consumed)); diff --git a/src/tui/view/component/record_body.rs b/src/tui/view/component/record_body.rs index 908643c2..b47f659b 100644 --- a/src/tui/view/component/record_body.rs +++ b/src/tui/view/component/record_body.rs @@ -70,7 +70,7 @@ impl Default for RecordBody { .with_validator(|text| JsonPath::parse(text).is_ok()) // Callback triggers an event, so we can modify our own state .with_on_submit(|text_box| { - EventQueue::push(Event::other(QuerySubmit( + EventQueue::push(Event::new_other(QuerySubmit( text_box.text().to_owned(), ))) }) diff --git a/src/tui/view/component/response_pane.rs b/src/tui/view/component/response_pane.rs index 59263c9c..baa7eddf 100644 --- a/src/tui/view/component/response_pane.rs +++ b/src/tui/view/component/response_pane.rs @@ -1,5 +1,5 @@ use crate::{ - http::{RequestId, RequestRecord}, + http::{RequestId, RequestRecord, Response}, tui::{ context::TuiContext, input::Action, @@ -27,6 +27,7 @@ use ratatui::{ Frame, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use strum::{EnumCount, EnumIter}; /// Display HTTP response state, which could be in progress, complete, or @@ -46,6 +47,8 @@ pub struct ResponsePaneProps<'a> { enum MenuAction { #[display("Copy Body")] CopyBody, + #[display("Save Body as File")] + SaveBody, } impl ToStringGenerate for MenuAction {} @@ -113,22 +116,23 @@ struct CompleteResponseContent { /// Persist the response body to track view state. Update whenever the /// loaded request changes #[debug(skip)] - body: StateCell>, -} - -impl Default for CompleteResponseContent { - fn default() -> Self { - Self { - tabs: Tabs::new(PersistentKey::ResponseTab).into(), - body: Default::default(), - } - } + state: StateCell, } struct CompleteResponseContentProps<'a> { record: &'a RequestRecord, } +/// Internal state +struct State { + /// Use Arc so we're not cloning large responses + response: Arc, + /// The presentable version of the response body, which may or may not + /// match the response body. We apply transformations such as filter, + /// prettification, or in the case of binary responses, a hex dump. + body: Component, +} + #[derive( Copy, Clone, @@ -145,31 +149,51 @@ enum Tab { Headers, } +impl CompleteResponseContent {} + impl EventHandler for CompleteResponseContent { fn update(&mut self, messages_tx: &MessageSender, event: Event) -> Update { - match event { - Event::Input { - action: Some(Action::OpenActions), - .. - } => EventQueue::open_modal_default::>(), - Event::Other(ref other) => { - // Check for an action menu event - match other.downcast_ref::() { - Some(MenuAction::CopyBody) => { - // We need to generate the copy text here because it can - // be formatted/queried - if let Some(body) = - self.body.get().and_then(|body| body.text()) - { - messages_tx.send(Message::CopyText(body)); - } + if let Some(Action::OpenActions) = event.action() { + EventQueue::open_modal_default::>(); + Update::Consumed + } else if let Some(action) = event.other::() { + match action { + MenuAction::CopyBody => { + // Use whatever text is visible to the user + if let Some(body) = + self.state.get().and_then(|state| state.body.text()) + { + messages_tx.send(Message::CopyText(body)); + } + } + MenuAction::SaveBody => { + // For text, use whatever is visible to the user. For + // binary, use the raw value + if let Some(state) = self.state.get() { + // If we've parsed the body, then save exactly what the + // user sees. Otherwise, save the raw bytes. This is + // going to clone the whole body, which could be big. + // If we need to optimize this, we would have to shove + // all querying to the main data storage, so the main + // loop can access it directly to be written. + let data = if state.response.body.parsed().is_some() { + state.body.text().unwrap_or_default().into_bytes() + } else { + state.response.body.bytes().to_vec() + }; + + // This will trigger a modal to ask the user for a path + messages_tx.send(Message::SaveFile { + default_path: state.response.file_name(), + data, + }); } - None => return Update::Propagate(event), } } - _ => return Update::Propagate(event), + Update::Consumed + } else { + Update::Propagate(event) } - Update::Consumed } fn children(&mut self) -> Vec> { @@ -177,8 +201,8 @@ impl EventHandler for CompleteResponseContent { let mut children = vec![]; match selected_tab { Tab::Body => { - if let Some(body) = self.body.get_mut() { - children.push(body.as_child()); + if let Some(state) = self.state.get_mut() { + children.push(state.body.as_child()); } } Tab::Headers => {} @@ -193,10 +217,16 @@ impl<'a> Draw> for CompleteResponseContent { fn draw( &self, frame: &mut Frame, - props: CompleteResponseContentProps<'a>, + props: CompleteResponseContentProps, area: Rect, ) { let response = &props.record.response; + // Set response state regardless of what tab we're on, so we always + // have access to it + let state = self.state.get_or_update(props.record.id, || State { + response: Arc::clone(&props.record.response), + body: Default::default(), + }); // Split the main area again to allow tabs let [header_area, tabs_area, content_area] = Layout::vertical([ @@ -227,9 +257,7 @@ impl<'a> Draw> for CompleteResponseContent { // Main content for the response match self.tabs.selected() { Tab::Body => { - let body = - self.body.get_or_update(props.record.id, Default::default); - body.draw( + state.body.draw( frame, RecordBodyProps { body: &response.body, @@ -248,3 +276,137 @@ impl<'a> Draw> for CompleteResponseContent { } } } + +impl Default for CompleteResponseContent { + fn default() -> Self { + Self { + tabs: Tabs::new(PersistentKey::ResponseTab).into(), + state: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::*; + use factori::create; + use indexmap::indexmap; + use ratatui::{backend::TestBackend, Terminal}; + use rstest::rstest; + + /// Test "Copy Body" menu action + #[rstest] + #[case::json_body( + create!( + Response, + headers: header_map(indexmap! {"content-type" => "application/json"}), + body: br#"{"hello":"world"}"#.to_vec().into() + ), + // Body gets prettified + r#"{ + "hello": "world" +}"# + )] + #[case::binary_body( + create!( + Response, + headers: header_map(indexmap! {"content-type" => "image/png"}), + body: b"\x01\x02\x03\xff".to_vec().into() + ), + "01 02 03 ff" + )] + #[tokio::test] + async fn test_copy_body( + mut messages: MessageQueue, + mut terminal: Terminal, + #[case] response: Response, + #[case] expected_body: &str, + ) { + // Draw once to initialize state + let mut component = CompleteResponseContent::default(); + let record = create!(RequestRecord, response: response.into()); + component.draw( + &mut terminal.get_frame(), + CompleteResponseContentProps { record: &record }, + Rect::default(), + ); + + let update = component + .update(messages.tx(), Event::new_other(MenuAction::CopyBody)); + // unstable: https://github.com/rust-lang/rust/issues/82775 + assert!(matches!(update, Update::Consumed)); + + let message = messages.pop(); + let Message::CopyText(body) = &message else { + panic!("Wrong message: {message:?}") + }; + assert_eq!(body, expected_body); + } + + /// Test "Save Body as File" menu action + #[rstest] + #[case::json_body( + create!( + Response, + headers: header_map(indexmap! {"content-type" => "application/json"}), + body: br#"{"hello":"world"}"#.to_vec().into() + ), + // Body gets prettified + br#"{ + "hello": "world" +}"#, + "data.json" + )] + #[case::binary_body( + create!( + Response, + headers: header_map(indexmap! {"content-type" => "image/png"}), + body: b"\x01\x02\x03".to_vec().into() + ), + b"\x01\x02\x03", + "data.png" + )] + #[case::content_disposition( + create!( + Response, + headers: header_map(indexmap! { + "content-type" => "image/png", + "content-disposition" => "attachment; filename=\"dogs.png\"", + }), + body: b"\x01\x02\x03".to_vec().into() + ), + b"\x01\x02\x03", + "dogs.png" + )] + #[tokio::test] + async fn test_save_file( + mut messages: MessageQueue, + mut terminal: Terminal, + #[case] response: Response, + #[case] expected_body: &[u8], + #[case] expected_path: &str, + ) { + let mut component = CompleteResponseContent::default(); + let record = create!(RequestRecord, response: response.into()); + + // Draw once to initialize state + component.draw( + &mut terminal.get_frame(), + CompleteResponseContentProps { record: &record }, + Rect::default(), + ); + + let update = component + .update(messages.tx(), Event::new_other(MenuAction::SaveBody)); + // unstable: https://github.com/rust-lang/rust/issues/82775 + assert!(matches!(update, Update::Consumed)); + + let message = messages.pop(); + let Message::SaveFile { data, default_path } = &message else { + panic!("Wrong message: {message:?}") + }; + assert_eq!(data, expected_body); + assert_eq!(default_path.as_deref(), Some(expected_path)); + } +} diff --git a/src/tui/view/event.rs b/src/tui/view/event.rs index c700d363..4930c222 100644 --- a/src/tui/view/event.rs +++ b/src/tui/view/event.rs @@ -140,7 +140,7 @@ pub enum Event { impl Event { /// Create a dynamic "other" variant - pub fn other(value: T) -> Event { + pub fn new_other(value: T) -> Event { Event::Other(Box::new(value)) } @@ -153,6 +153,14 @@ impl Event { _ => None, } } + + /// Get a dynamic "other" variant, if this event is one + pub fn other(&self) -> Option<&T> { + match self { + Self::Other(other) => other.downcast_ref(), + _ => None, + } + } } impl Event { diff --git a/src/tui/view/util.rs b/src/tui/view/util.rs index b37d7756..e69356ae 100644 --- a/src/tui/view/util.rs +++ b/src/tui/view/util.rs @@ -1,8 +1,18 @@ //! Helper structs and functions for building components -use crate::template::{Prompt, Prompter}; +use crate::template::{Prompt, PromptChannel, Prompter}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; +/// A data structure for representation a yes/no confirmation. This is similar +/// to [Prompt], but it only asks a yes/no question. +#[derive(Debug)] +pub struct Confirm { + /// Question to ask the user + pub message: String, + /// A channel to pass back the user's response + pub channel: PromptChannel, +} + /// A prompter that returns a static value; used for template previews, where /// user interaction isn't possible #[derive(Debug)]