From 0334c078f07f1174430a9b4fd673749f0dd5992a Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sat, 11 Jan 2025 11:43:17 -0500 Subject: [PATCH] Add keybind to open in pager Also move body-related recipe actions to only be in the Body tab. This should cut down on clutter a bit and it's consistent with the actions on the other tabs. --- CHANGELOG.md | 9 +- crates/config/src/input.rs | 2 + crates/core/src/collection/models.rs | 115 ++++++++++++---- crates/core/src/http/content_type.rs | 27 ++-- crates/tui/src/input.rs | 1 + crates/tui/src/lib.rs | 59 ++++----- crates/tui/src/message.rs | 10 +- crates/tui/src/view.rs | 8 +- crates/tui/src/view/component/primary.rs | 123 ++++-------------- crates/tui/src/view/component/recipe_list.rs | 62 ++++----- crates/tui/src/view/component/recipe_pane.rs | 90 ++++--------- .../src/view/component/recipe_pane/body.rs | 98 +++++++------- .../src/view/component/recipe_pane/recipe.rs | 16 +-- crates/tui/src/view/component/request_view.rs | 55 +++++--- .../tui/src/view/component/response_view.rs | 99 ++++++++------ crates/tui/src/view/component/root.rs | 8 +- docs/src/api/configuration/input_bindings.md | 1 + slumber.yml | 13 +- 18 files changed, 405 insertions(+), 391 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8db2f2..971141b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `slumber history get` prints a specific request/response - Add `--output` flag to `slumber request` to control where the response body is written to - Support MIME type mapping for `pager` config field, so you can set different pagers based on media type. [See docs](https://slumber.lucaspickering.me/book/api/configuration/mime.html) -- Add "Edit" and "Reset" actions to menus on the recipe pane - - These don't provide any new functionality, as the `e` and `z` keys are already bound to those actions, but it should make them more discoverable +- Several changes related to keybinds and action menus to make the two feel more cohesive + - Add "Edit" and "Reset" actions to menus on the recipe pane + - These don't provide any new functionality, as the `e` and `z` keys are already bound to those actions, but it should make them more discoverable + - Add keybind (`v` by defualt) to open a recipe/request/response body in your pager + - Previously this was available only through the actions menu + - "View Body" and "Copy Body" actions for a **recipe** are now only available within the Body tab of the Recipe pane + - Previously they were available anywhere in the Recipe List or Recipe panes. With the addition of other actions to the menu it was started to feel cluttered ### Changed diff --git a/crates/config/src/input.rs b/crates/config/src/input.rs index 4395041c..9fde2db2 100644 --- a/crates/config/src/input.rs +++ b/crates/config/src/input.rs @@ -146,6 +146,8 @@ pub enum Action { Edit, /// Reset temporary recipe override to its default value Reset, + /// Open content in the configured external pager + View, /// Browse request history History, /// Start a search/filter operation diff --git a/crates/core/src/collection/models.rs b/crates/core/src/collection/models.rs index 73a53a73..34b1e267 100644 --- a/crates/core/src/collection/models.rs +++ b/crates/core/src/collection/models.rs @@ -13,6 +13,7 @@ use anyhow::Context; use derive_more::{Deref, Display, From, FromStr}; use indexmap::IndexMap; use mime::Mime; +use reqwest::header; use serde::{Deserialize, Serialize}; use std::{fs::File, path::PathBuf, time::Duration}; use tracing::info; @@ -165,11 +166,56 @@ impl crate::test_util::Factory for Folder { } } +/// A definition of how to make a request. This is *not* called `Request` in +/// order to distinguish it from a single instance of an HTTP request. And it's +/// not called `RequestTemplate` because the word "template" has a specific +/// meaning related to string interpolation. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(any(test, feature = "test"), derive(PartialEq))] +#[serde(deny_unknown_fields)] +pub struct Recipe { + #[serde(skip)] // This will be auto-populated from the map key + pub id: RecipeId, + pub name: Option, + /// *Not* a template string because the usefulness doesn't justify the + /// complexity. This gives the user an immediate error if the method is + /// wrong which is helpful. + pub method: HttpMethod, + pub url: Template, + pub body: Option, + pub authentication: Option, + #[serde(default, with = "cereal::serde_query_parameters")] + pub query: Vec<(String, Template)>, + #[serde(default)] + pub headers: IndexMap, +} + impl Recipe { /// Get a presentable name for this recipe pub fn name(&self) -> &str { self.name.as_deref().unwrap_or(&self.id) } + + /// Guess the value that the `Content-Type` header will have for a generated + /// request. This will use the raw header if it's present and a valid MIME + /// type, otherwise it will fall back to the content type of the body, if + /// known (e.g. JSON). Otherwise, return `None`. If the header is a + /// dynamic template, we will *not* attempt to render it, so MIME parsing + /// will fail. + /// TODO update - forms don't count + pub fn mime(&self) -> Option { + self.headers + .get(header::CONTENT_TYPE.as_str()) + .and_then(|template| template.display().parse::().ok()) + .or_else(|| { + // Use the type of the body to determine MIME + if let Some(RecipeBody::Raw { content_type, .. }) = &self.body { + content_type.as_ref().map(ContentType::to_mime) + } else { + None + } + }) + } } #[cfg(any(test, feature = "test"))] @@ -199,30 +245,6 @@ impl crate::test_util::Factory<&str> for Recipe { } } -/// A definition of how to make a request. This is *not* called `Request` in -/// order to distinguish it from a single instance of an HTTP request. And it's -/// not called `RequestTemplate` because the word "template" has a specific -/// meaning related to string interpolation. -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(any(test, feature = "test"), derive(PartialEq))] -#[serde(deny_unknown_fields)] -pub struct Recipe { - #[serde(skip)] // This will be auto-populated from the map key - pub id: RecipeId, - pub name: Option, - /// *Not* a template string because the usefulness doesn't justify the - /// complexity. This gives the user an immediate error if the method is - /// wrong which is helpful. - pub method: HttpMethod, - pub url: Template, - pub body: Option, - pub authentication: Option, - #[serde(default, with = "cereal::serde_query_parameters")] - pub query: Vec<(String, Template)>, - #[serde(default)] - pub headers: IndexMap, -} - #[derive( Clone, Debug, @@ -582,3 +604,48 @@ impl crate::test_util::Factory for Collection { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::Factory; + use rstest::rstest; + + /// TODO + #[rstest] + #[case::none(None, None, None)] + #[case::header( + // Header takes precedence over body + Some("text/plain"), + Some(ContentType::Json), + Some("text/plain") + )] + #[case::body(None, Some(ContentType::Json), Some("application/json"))] + #[case::unknown_mime( + // Fall back to body type + Some("bogus"), + Some(ContentType::Json), + Some("application/json") + )] + fn test_recipe_mime( + #[case] header: Option<&str>, + #[case] content_type: Option, + #[case] expected: Option<&str>, + ) { + let mut headers = IndexMap::new(); + if let Some(header) = header { + headers.insert("content-type".into(), header.into()); + } + let body = RecipeBody::Raw { + body: "body!".into(), + content_type, + }; + let recipe = Recipe { + headers, + body: Some(body), + ..Recipe::factory(()) + }; + let expected = expected.and_then(|value| value.parse::().ok()); + assert_eq!(recipe.mime(), expected); + } +} diff --git a/crates/core/src/http/content_type.rs b/crates/core/src/http/content_type.rs index 03b74a4f..6f45f48b 100644 --- a/crates/core/src/http/content_type.rs +++ b/crates/core/src/http/content_type.rs @@ -35,21 +35,26 @@ impl ContentType { const EXTENSIONS: Mapping<'static, ContentType> = Mapping::new(&[(Self::Json, &["json"])]); - /// Parse the value of the content-type header and map it to a known content - /// type - fn from_mime(mime_type: &str) -> anyhow::Result { - let mime_type: Mime = mime_type + /// Parse a MIME string and map it to a known content type + fn parse_mime(mime_type: &str) -> anyhow::Result { + let mime: Mime = mime_type .parse() .with_context(|| format!("Invalid content type `{mime_type}`"))?; + Self::from_mime(&mime) + .ok_or_else(|| anyhow!("Unknown content type `{mime_type}`")) + } - let suffix = mime_type.suffix().map(|name| name.as_str()); - match (mime_type.type_(), mime_type.subtype(), suffix) { + /// Get a known content type from a pre-parsed MIME type. Return `None` if + /// the MIME type isn't supported. + pub fn from_mime(mime: &Mime) -> Option { + let suffix = mime.suffix().map(|name| name.as_str()); + match (mime.type_(), mime.subtype(), suffix) { // JSON has a lot of extended types that follow the pattern // "application/*+json", match those too (APPLICATION, JSON, _) | (APPLICATION, _, Some("json")) => { - Ok(Self::Json) + Some(Self::Json) } - _ => Err(anyhow!("Unknown content type `{mime_type}`")), + _ => None, } } @@ -79,7 +84,7 @@ impl ContentType { .ok_or_else(|| anyhow!("Response has no content-type header"))?; let header_value = std::str::from_utf8(header_value) .context("content-type header is not valid utf-8")?; - Self::from_mime(header_value) + Self::parse_mime(header_value) } /// Parse some content of this type. Return a dynamically dispatched content @@ -212,7 +217,7 @@ mod tests { #[case] mime_type: &str, #[case] expected: ContentType, ) { - assert_eq!(ContentType::from_mime(mime_type).unwrap(), expected); + assert_eq!(ContentType::parse_mime(mime_type).unwrap(), expected); } /// Test invalid/unknown MIME types @@ -225,7 +230,7 @@ mod tests { #[case] mime_type: &str, #[case] expected_error: &str, ) { - assert_err!(ContentType::from_mime(mime_type), expected_error); + assert_err!(ContentType::parse_mime(mime_type), expected_error); } #[test] diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 85a30d55..a0add842 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -178,6 +178,7 @@ impl Default for InputEngine { Action::Cancel => KeyCode::Esc.into(), Action::Edit => KeyCode::Char('e').into(), Action::Reset => KeyCode::Char('z').into(), + Action::View => KeyCode::Char('v').into(), Action::SelectProfileList => KeyCode::Char('p').into(), Action::SelectRecipeList => KeyCode::Char('l').into(), Action::SelectRecipe => KeyCode::Char('c').into(), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a229360c..9109bb2e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -246,14 +246,14 @@ impl Tui { self.run_command(command)?; } - Message::CopyRequestUrl(request_config) => { - self.copy_request_url(request_config)?; + Message::CopyRequestUrl => { + self.copy_request_url()?; } - Message::CopyRequestBody(request_config) => { - self.copy_request_body(request_config)?; + Message::CopyRequestBody => { + self.copy_request_body()?; } - Message::CopyRequestCurl(request_config) => { - self.copy_request_curl(request_config)?; + Message::CopyRequestCurl => { + self.copy_request_curl()?; } Message::CopyText(text) => self.view.copy_text(text), Message::SaveResponseBody { request_id, data } => { @@ -284,9 +284,7 @@ impl Tui { Message::Error { error } => self.view.open_modal(error), // Manage HTTP life cycle - Message::HttpBeginRequest(request_config) => { - self.send_request(request_config)? - } + Message::HttpBeginRequest => self.send_request()?, Message::HttpBuildError { error } => { let state = self.request_store.build_error(error); self.view.update_request(state); @@ -478,14 +476,12 @@ impl Tui { } /// Render URL for a request, then copy it to the clipboard - fn copy_request_url( - &self, - RequestConfig { + fn copy_request_url(&self) -> anyhow::Result<()> { + let RequestConfig { profile_id, recipe_id, options, - }: RequestConfig, - ) -> anyhow::Result<()> { + } = self.request_config()?; let seed = RequestSeed::new(recipe_id, options); let template_context = self.template_context(profile_id, false)?; let messages_tx = self.messages_tx(); @@ -502,14 +498,12 @@ impl Tui { } /// Render body for a request, then copy it to the clipboard - fn copy_request_body( - &self, - RequestConfig { + fn copy_request_body(&self) -> anyhow::Result<()> { + let RequestConfig { profile_id, recipe_id, options, - }: RequestConfig, - ) -> anyhow::Result<()> { + } = self.request_config()?; let seed = RequestSeed::new(recipe_id, options); let template_context = self.template_context(profile_id, false)?; let messages_tx = self.messages_tx(); @@ -530,14 +524,12 @@ impl Tui { } /// Render a request, then copy the equivalent curl command to the clipboard - fn copy_request_curl( - &self, - RequestConfig { + fn copy_request_curl(&self) -> anyhow::Result<()> { + let RequestConfig { profile_id, recipe_id, options, - }: RequestConfig, - ) -> anyhow::Result<()> { + } = self.request_config()?; let seed = RequestSeed::new(recipe_id, options); let template_context = self.template_context(profile_id, false)?; let messages_tx = self.messages_tx(); @@ -582,15 +574,24 @@ impl Tui { Ok(()) } + /// Get the current request config for the selected recipe. The config + /// defines how to build a request. If no recipe is selected, this returns + /// an error. This should only be called in contexts where we can safely + /// assume that a recipe is selected (e.g. triggered via an action on a + /// recipe), so an error indicates a bug. + fn request_config(&self) -> anyhow::Result { + self.view + .request_config() + .ok_or_else(|| anyhow!("No recipe selected")) + } + /// Launch an HTTP request in a separate task - fn send_request( - &mut self, - RequestConfig { + fn send_request(&mut self) -> anyhow::Result<()> { + let RequestConfig { profile_id, recipe_id, options, - }: RequestConfig, - ) -> anyhow::Result<()> { + } = self.request_config()?; // Launch the request in a separate task so it doesn't block. // These clones are all cheap. diff --git a/crates/tui/src/message.rs b/crates/tui/src/message.rs index 52dafd17..a842f959 100644 --- a/crates/tui/src/message.rs +++ b/crates/tui/src/message.rs @@ -71,11 +71,11 @@ pub enum Message { ConfirmStart(Confirm), /// Render request URL from a recipe, then copy rendered URL - CopyRequestUrl(RequestConfig), - /// Render request body from a recipe, then copy rendered text - CopyRequestBody(RequestConfig), + CopyRequestUrl, + /// Render request body from the selected recipe, then copy rendered text + CopyRequestBody, /// Render request, then generate an equivalent cURL command and copy it - CopyRequestCurl(RequestConfig), + CopyRequestCurl, /// Copy some text to the clipboard CopyText(String), @@ -98,7 +98,7 @@ pub enum Message { }, /// Launch an HTTP request from the given recipe/profile. - HttpBeginRequest(RequestConfig), + HttpBeginRequest, /// Request failed to build HttpBuildError { error: RequestBuildError }, /// We launched the HTTP request diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs index ab0e54b7..8c823920 100644 --- a/crates/tui/src/view.rs +++ b/crates/tui/src/view.rs @@ -18,7 +18,7 @@ pub use util::{Confirm, PreviewPrompter}; use crate::{ context::TuiContext, http::{RequestState, RequestStore}, - message::{Message, MessageSender}, + message::{Message, MessageSender, RequestConfig}, util::ResultReported, view::{ common::modal::Modal, @@ -103,6 +103,12 @@ impl View { self.root.data().selected_profile_id() } + /// Get a definition of the request that should be sent from the current + /// recipe settings + pub fn request_config(&self) -> Option { + self.root.data().request_config() + } + /// Select a particular request pub fn select_request( &mut self, diff --git a/crates/tui/src/view/component/primary.rs b/crates/tui/src/view/component/primary.rs index ae7332c8..3d831b18 100644 --- a/crates/tui/src/view/component/primary.rs +++ b/crates/tui/src/view/component/primary.rs @@ -2,7 +2,7 @@ use crate::{ http::RequestState, - message::Message, + message::{Message, RequestConfig}, util::ResultReported, view::{ common::{ @@ -14,9 +14,7 @@ use crate::{ help::HelpModal, profile_select::ProfilePane, recipe_list::{RecipeListPane, RecipeListPaneEvent}, - recipe_pane::{ - RecipeMenuAction, RecipePane, RecipePaneEvent, RecipePaneProps, - }, + recipe_pane::{RecipePane, RecipePaneEvent, RecipePaneProps}, }, context::UpdateContext, draw::{Draw, DrawMetadata}, @@ -25,10 +23,7 @@ use crate::{ fixed_select::FixedSelectState, select::{SelectStateEvent, SelectStateEventType}, }, - util::{ - persistence::{Persisted, PersistedLazy}, - view_text, - }, + util::persistence::{Persisted, PersistedLazy}, Component, ViewContext, }, }; @@ -131,6 +126,12 @@ impl PrimaryView { .into(); } + /// Get a definition of the request that should be sent from the current + /// recipe settings + pub fn request_config(&self) -> Option { + self.recipe_pane.data().request_config() + } + /// Is the given pane selected? fn is_selected(&self, primary_pane: PrimaryPane) -> bool { self.selected_pane.is_selected(&primary_pane) @@ -224,38 +225,9 @@ impl PrimaryView { .areas(area) } - /// Send a request for the currently selected recipe (if any) + /// Send a request for the currently selected recipe fn send_request(&self) { - if let Some(config) = self.recipe_pane.data().request_config() { - ViewContext::send_message(Message::HttpBeginRequest(config)); - } - } - - /// Handle menu actions for recipe list or detail panes. We handle this here - /// for code de-duplication, and because we have access to all the needed - /// context. - fn handle_recipe_menu_action(&self, action: RecipeMenuAction) { - // If no recipes are available, we can't do anything - let Some(config) = self.recipe_pane.data().request_config() else { - return; - }; - - match action { - RecipeMenuAction::CopyUrl => { - ViewContext::send_message(Message::CopyRequestUrl(config)) - } - RecipeMenuAction::CopyCurl => { - ViewContext::send_message(Message::CopyRequestCurl(config)) - } - RecipeMenuAction::CopyBody => { - ViewContext::send_message(Message::CopyRequestBody(config)) - } - RecipeMenuAction::ViewBody => { - let recipe_pane = self.recipe_pane.data(); - recipe_pane - .with_body_text(|text| view_text(text, recipe_pane.mime())) - } - } + ViewContext::send_message(Message::HttpBeginRequest); } } @@ -336,13 +308,6 @@ impl EventHandler for PrimaryView { } } }) - // Handle all recipe actions here, for deduplication - .emitted(self.recipe_list_pane.to_emitter(), |menu_action| { - self.handle_recipe_menu_action(menu_action) - }) - .emitted(self.recipe_pane.to_emitter(), |menu_action| { - self.handle_recipe_menu_action(menu_action) - }) } fn menu_actions(&self) -> Vec { @@ -525,6 +490,18 @@ mod tests { ); } + /// Test the request_config() getter + #[rstest] + fn test_request_config(mut harness: TestHarness, terminal: TestTerminal) { + let component = create_component(&mut harness, &terminal); + let expected_config = RequestConfig { + recipe_id: harness.collection.first_recipe_id().clone(), + profile_id: Some(harness.collection.first_profile_id().clone()), + options: BuildOptions::default(), + }; + assert_eq!(component.data().request_config(), Some(expected_config)); + } + /// Test "Edit Collection" action #[rstest] fn test_edit_collection(mut harness: TestHarness, terminal: TestTerminal) { @@ -546,11 +523,6 @@ mod tests { /// panes #[rstest] fn test_copy_url(mut harness: TestHarness, terminal: TestTerminal) { - let expected_config = RequestConfig { - recipe_id: harness.collection.first_recipe_id().clone(), - profile_id: Some(harness.collection.first_profile_id().clone()), - options: BuildOptions::default(), - }; let mut component = create_component(&mut harness, &terminal); component @@ -561,54 +533,13 @@ mod tests { .send_keys([KeyCode::Down, KeyCode::Enter]) .assert_empty(); - let request_config = assert_matches!( - harness.pop_message_now(), - Message::CopyRequestUrl(request_config) => request_config, - ); - assert_eq!(request_config, expected_config); - } - - /// Test "Copy Body" action, which is available via the Recipe List or - /// Recipe panes - #[rstest] - fn test_copy_body(mut harness: TestHarness, terminal: TestTerminal) { - let expected_config = RequestConfig { - recipe_id: harness.collection.first_recipe_id().clone(), - profile_id: Some(harness.collection.first_profile_id().clone()), - options: BuildOptions::default(), - }; - let mut component = create_component(&mut harness, &terminal); - - component - .int() - .send_key(KeyCode::Char('l')) // Select recipe list - .open_actions() - // Copy Body - .send_keys([ - KeyCode::Down, - KeyCode::Down, - KeyCode::Down, - KeyCode::Down, - KeyCode::Enter, - ]) - .assert_empty(); - - let request_config = assert_matches!( - harness.pop_message_now(), - Message::CopyRequestBody(request_config) => request_config, - ); - assert_eq!(request_config, expected_config); + assert_matches!(harness.pop_message_now(), Message::CopyRequestUrl); } /// Test "Copy as cURL" action, which is available via the Recipe List or /// Recipe panes #[rstest] fn test_copy_as_curl(mut harness: TestHarness, terminal: TestTerminal) { - let expected_config = RequestConfig { - recipe_id: harness.collection.first_recipe_id().clone(), - profile_id: Some(harness.collection.first_profile_id().clone()), - options: BuildOptions::default(), - }; let mut component = create_component(&mut harness, &terminal); component @@ -619,10 +550,6 @@ mod tests { .send_keys([KeyCode::Down, KeyCode::Down, KeyCode::Enter]) .assert_empty(); - let request_config = assert_matches!( - harness.pop_message_now(), - Message::CopyRequestCurl(request_config) => request_config, - ); - assert_eq!(request_config, expected_config); + assert_matches!(harness.pop_message_now(), Message::CopyRequestCurl); } } diff --git a/crates/tui/src/view/component/recipe_list.rs b/crates/tui/src/view/component/recipe_list.rs index 13ab2745..1722f3c2 100644 --- a/crates/tui/src/view/component/recipe_list.rs +++ b/crates/tui/src/view/component/recipe_list.rs @@ -1,5 +1,6 @@ use crate::{ context::TuiContext, + message::Message, view::{ common::{ actions::{IntoMenuAction, MenuAction}, @@ -7,7 +8,6 @@ use crate::{ text_box::{TextBox, TextBoxEvent, TextBoxProps}, Pane, }, - component::recipe_pane::RecipeMenuAction, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, @@ -29,7 +29,7 @@ use slumber_core::collection::{ HasId, RecipeId, RecipeLookupKey, RecipeNode, RecipeNodeType, RecipeTree, }; use std::collections::HashSet; -use strum::IntoEnumIterator; +use strum::{EnumIter, IntoEnumIterator}; /// List/tree of recipes and folders. This is mostly just a list, but with some /// extra logic to allow expanding/collapsing nodes. This could be made into a @@ -46,7 +46,7 @@ pub struct RecipeListPane { /// Emitter for the on-click event, to focus the pane click_emitter: Emitter, /// Emitter for menu actions, to be handled by our parent - actions_emitter: Emitter, + actions_emitter: Emitter, /// The visible list of items is tracked using normal list state, so we can /// easily re-use existing logic. We'll rebuild this any time a folder is /// expanded/collapsed (i.e whenever the list of items changes) @@ -191,10 +191,18 @@ impl EventHandler for RecipeListPane { self.filter_focused = false } }) + .emitted(self.actions_emitter, |menu_action| match menu_action { + RecipeListMenuAction::CopyUrl => { + ViewContext::send_message(Message::CopyRequestUrl) + } + RecipeListMenuAction::CopyCurl => { + ViewContext::send_message(Message::CopyRequestCurl) + } + }) } fn menu_actions(&self) -> Vec { - RecipeMenuAction::iter() + RecipeListMenuAction::iter() .map(MenuAction::with_data(self, self.actions_emitter)) .collect() } @@ -248,14 +256,27 @@ impl ToEmitter for RecipeListPane { } } -/// Notify parent when one of this pane's actions is selected -impl ToEmitter for RecipeListPane { - fn to_emitter(&self) -> Emitter { - self.actions_emitter - } +/// Persisted key for the ID of the selected recipe +#[derive(Debug, Serialize, PersistedKey)] +#[persisted(Option)] +struct SelectedRecipeKey; + +/// Emitted event type for the recipe list pane +#[derive(Debug)] +pub enum RecipeListPaneEvent { + Click, } -impl IntoMenuAction for RecipeMenuAction { +/// Items in the actions popup menu +#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter)] +enum RecipeListMenuAction { + #[display("Copy URL")] + CopyUrl, + #[display("Copy as cURL")] + CopyCurl, +} + +impl IntoMenuAction for RecipeListMenuAction { fn enabled(&self, data: &RecipeListPane) -> bool { let recipe = data .select @@ -264,31 +285,10 @@ impl IntoMenuAction for RecipeMenuAction { .filter(|node| node.is_recipe()); match self { Self::CopyUrl | Self::CopyCurl => recipe.is_some(), - // Check if the recipe has a body - Self::ViewBody | Self::CopyBody => recipe - .map(|recipe| { - ViewContext::collection() - .recipes - .get_recipe(&recipe.id) - .and_then(|recipe| recipe.body.as_ref()) - .is_some() - }) - .unwrap_or(false), } } } -/// Persisted key for the ID of the selected recipe -#[derive(Debug, Serialize, PersistedKey)] -#[persisted(Option)] -struct SelectedRecipeKey; - -/// Emitted event type for the recipe list pane -#[derive(Debug)] -pub enum RecipeListPaneEvent { - Click, -} - /// Simplified version of [RecipeNode], to be used in the display tree. This /// only stores whatever data is necessary to render the list #[derive(Debug)] diff --git a/crates/tui/src/view/component/recipe_pane.rs b/crates/tui/src/view/component/recipe_pane.rs index 125be78e..0c7a8404 100644 --- a/crates/tui/src/view/component/recipe_pane.rs +++ b/crates/tui/src/view/component/recipe_pane.rs @@ -8,7 +8,7 @@ pub use persistence::RecipeOverrideStore; use crate::{ context::TuiContext, - message::RequestConfig, + message::{Message, RequestConfig}, view::{ common::{ actions::{IntoMenuAction, MenuAction}, @@ -24,12 +24,10 @@ use crate::{ }; use derive_more::Display; use itertools::{Itertools, Position}; -use mime::Mime; use ratatui::{ text::{Line, Text}, Frame, }; -use reqwest::header; use slumber_config::Action; use slumber_core::{ collection::{Folder, HasId, ProfileId, RecipeId, RecipeNode}, @@ -45,7 +43,7 @@ pub struct RecipePane { /// Emitter for the on-click event, to focus the pane click_emitter: Emitter, /// Emitter for menu actions, to be handled by our parent - actions_emitter: Emitter, + actions_emitter: Emitter, /// All UI state derived from the recipe is stored together, and reset when /// the recipe or profile changes recipe_state: StateCell>>, @@ -73,58 +71,30 @@ impl RecipePane { options, }) } - - /// Get the value that the `Content-Type` header will have for a generated - /// request. This will use the preview of the header if present, otherwise - /// it will fall back to the content type of the body, if known (e.g. JSON). - /// Otherwise, return `None`. - pub fn mime(&self) -> Option { - let state = self.recipe_state.get()?; - let display = state.data().as_ref()?; - display - .header(header::CONTENT_TYPE) - .and_then(|value| value.parse::().ok()) - .or_else(|| { - // Use the type of the body to determine MIME - let recipe_id = - Ref::filter_map(self.recipe_state.get_key()?, |key| { - key.recipe_id.as_ref() - }) - .ok()?; - let collection = ViewContext::collection(); - let recipe = collection.recipes.get(&recipe_id)?.recipe()?; - recipe.body.as_ref()?.mime() - }) - } - - /// Execute a function with the recipe's body text, if available. Body text - /// is only available for recipes with non-form bodies. - pub fn with_body_text(&self, f: impl FnOnce(&Text)) { - let Some(state) = self.recipe_state.get() else { - return; - }; - let Some(display) = state.data().as_ref() else { - return; - }; - let Some(body_text) = display.body_text() else { - return; - }; - f(&body_text) - } } impl EventHandler for RecipePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().action(|action, propagate| match action { - Action::LeftClick => { - self.click_emitter.emit(RecipePaneEvent::Click) - } - _ => propagate.set(), - }) + event + .opt() + .action(|action, propagate| match action { + Action::LeftClick => { + self.click_emitter.emit(RecipePaneEvent::Click) + } + _ => propagate.set(), + }) + .emitted(self.actions_emitter, |menu_action| match menu_action { + RecipePaneMenuAction::CopyUrl => { + ViewContext::send_message(Message::CopyRequestUrl) + } + RecipePaneMenuAction::CopyCurl => { + ViewContext::send_message(Message::CopyRequestCurl) + } + }) } fn menu_actions(&self) -> Vec { - RecipeMenuAction::iter() + RecipePaneMenuAction::iter() .map(MenuAction::with_data(self, self.actions_emitter)) .collect() } @@ -210,13 +180,6 @@ impl ToEmitter for RecipePane { } } -/// Notify parent when one of this pane's actions is selected -impl ToEmitter for RecipePane { - fn to_emitter(&self) -> Emitter { - self.actions_emitter - } -} - /// Emitted event for the recipe pane component #[derive(Debug)] pub enum RecipePaneEvent { @@ -230,21 +193,16 @@ struct RecipeStateKey { recipe_id: Option, } -/// Items in the actions popup menu. This is also used by the recipe list -/// component, so the action is handled in the parent. +/// Items in the actions popup menu #[derive(Copy, Clone, Debug, Display, EnumIter)] -pub enum RecipeMenuAction { +enum RecipePaneMenuAction { #[display("Copy URL")] CopyUrl, #[display("Copy as cURL")] CopyCurl, - #[display("View Body")] - ViewBody, - #[display("Copy Body")] - CopyBody, } -impl IntoMenuAction for RecipeMenuAction { +impl IntoMenuAction for RecipePaneMenuAction { fn enabled(&self, data: &RecipePane) -> bool { let recipe = data.recipe_state.get().and_then(|state| { Ref::filter_map(state, |state| state.data().as_ref()).ok() @@ -252,10 +210,6 @@ impl IntoMenuAction for RecipeMenuAction { match self { // Enabled if we have any recipe Self::CopyUrl | Self::CopyCurl => recipe.is_some(), - // Enabled if we have a body - Self::ViewBody | Self::CopyBody => { - recipe.is_some_and(|recipe| recipe.has_body()) - } } } } diff --git a/crates/tui/src/view/component/recipe_pane/body.rs b/crates/tui/src/view/component/recipe_pane/body.rs index 87e033eb..519172e9 100644 --- a/crates/tui/src/view/component/recipe_pane/body.rs +++ b/crates/tui/src/view/component/recipe_pane/body.rs @@ -13,22 +13,22 @@ use crate::{ context::UpdateContext, draw::{Draw, DrawMetadata}, event::{Child, Emitter, Event, EventHandler, OptionEvent}, - state::Identified, + util::view_text, Component, ViewContext, }, }; use anyhow::Context; -use ratatui::{text::Text, Frame}; +use mime::Mime; +use ratatui::Frame; use serde::Serialize; use slumber_config::Action; use slumber_core::{ - collection::{RecipeBody, RecipeId}, + collection::{Recipe, RecipeBody, RecipeId}, http::content_type::ContentType, template::Template, }; use std::{ fs, - ops::Deref, path::{Path, PathBuf}, }; use strum::{EnumIter, IntoEnumIterator}; @@ -43,24 +43,26 @@ pub enum RecipeBodyDisplay { } impl RecipeBodyDisplay { - /// Build a component to display the body, based on the body type - pub fn new(body: &RecipeBody, recipe_id: RecipeId) -> Self { + /// Build a component to display the body, based on the body type. This + /// takes in the full recipe as well as the body so we can guarantee the + /// body is not `None`. + pub fn new(body: &RecipeBody, recipe: &Recipe) -> Self { match body { - RecipeBody::Raw { body, content_type } => Self::Raw( - RawBody::new(recipe_id, body.clone(), *content_type).into(), - ), + RecipeBody::Raw { body, .. } => { + Self::Raw(RawBody::new(body.clone(), recipe).into()) + } RecipeBody::FormUrlencoded(fields) | RecipeBody::FormMultipart(fields) => { let inner = RecipeFieldTable::new( "Field", - FormRowKey(recipe_id.clone()), + FormRowKey(recipe.id.clone()), fields.iter().enumerate().map(|(i, (field, value))| { ( field.clone(), value.clone(), - RecipeOverrideKey::form_field(recipe_id.clone(), i), + RecipeOverrideKey::form_field(recipe.id.clone(), i), FormRowToggleKey { - recipe_id: recipe_id.clone(), + recipe_id: recipe.id.clone(), field: field.clone(), }, ) @@ -71,18 +73,6 @@ impl RecipeBodyDisplay { } } - /// Get body text. Return `None` for form bodies - pub fn text( - &self, - ) -> Option>>> { - match self { - RecipeBodyDisplay::Raw(body) => { - Some(body.data().body.preview().text()) - } - RecipeBodyDisplay::Form(_) => None, - } - } - /// If the user has applied a temporary edit to the body, get the override /// value. Return `None` to use the recipe's stock body. pub fn override_value(&self) -> Option { @@ -136,27 +126,32 @@ pub struct RawBody { /// Emitter for menu actions actions_emitter: Emitter, body: RecipeTemplate, + mime: Option, text_window: Component, } impl RawBody { - fn new( - recipe_id: RecipeId, - template: Template, - content_type: Option, - ) -> Self { + fn new(template: Template, recipe: &Recipe) -> Self { + let mime = recipe.mime(); + let content_type = mime.as_ref().and_then(ContentType::from_mime); Self { override_emitter: Default::default(), actions_emitter: Default::default(), body: RecipeTemplate::new( - RecipeOverrideKey::body(recipe_id), + RecipeOverrideKey::body(recipe.id.clone()), template, content_type, ), + mime, text_window: Component::default(), } } + /// Open rendered body in the pager + fn view_body(&self) { + view_text(&self.body.preview().text(), self.mime.clone()); + } + /// Send a message to open the body in an external editor. We have to write /// the body to a temp file so the editor subprocess can access it. We'll /// read it back later. @@ -221,6 +216,7 @@ impl EventHandler for RawBody { event .opt() .action(|action, propagate| match action { + Action::View => self.view_body(), Action::Edit => self.open_editor(), Action::Reset => self.body.reset_override(), _ => propagate.set(), @@ -229,6 +225,10 @@ impl EventHandler for RawBody { self.load_override(&path) }) .emitted(self.actions_emitter, |menu_action| match menu_action { + RawBodyMenuAction::View => self.view_body(), + RawBodyMenuAction::Copy => { + ViewContext::send_message(Message::CopyRequestBody) + } RawBodyMenuAction::Edit => self.open_editor(), RawBodyMenuAction::Reset => self.body.reset_override(), }) @@ -281,6 +281,10 @@ pub struct FormRowToggleKey { /// Action menu items for a raw body #[derive(Copy, Clone, Debug, derive_more::Display, EnumIter)] enum RawBodyMenuAction { + #[display("View Body")] + View, + #[display("Copy Body")] + Copy, #[display("Edit Body")] Edit, #[display("Reset Body")] @@ -290,13 +294,15 @@ enum RawBodyMenuAction { impl IntoMenuAction for RawBodyMenuAction { fn enabled(&self, data: &RawBody) -> bool { match self { - Self::Edit => true, + Self::View | Self::Copy | Self::Edit => true, Self::Reset => data.body.is_overridden(), } } fn shortcut(&self, _: &RawBody) -> Option { match self { + Self::View => Some(Action::View), + Self::Copy => None, Self::Edit => Some(Action::Edit), Self::Reset => Some(Action::Reset), } @@ -334,15 +340,17 @@ mod tests { mut harness: TestHarness, #[with(10, 1)] terminal: TestTerminal, ) { - let body: RecipeBody = RecipeBody::Raw { - body: "hello!".into(), - content_type: Some(ContentType::Json), + let recipe = Recipe { + body: Some(RecipeBody::Raw { + body: "hello!".into(), + content_type: Some(ContentType::Json), + }), + ..Recipe::factory(()) }; - let recipe_id = RecipeId::factory(()); let mut component = TestComponent::new( &harness, &terminal, - RecipeBodyDisplay::new(&body, recipe_id.clone()), + RecipeBodyDisplay::new(recipe.body.as_ref().unwrap(), &recipe), ); // Check initial state @@ -381,7 +389,7 @@ mod tests { // Persistence store should be updated let persisted = RecipeOverrideStore::load_persisted( - &RecipeOverrideKey::body(recipe_id), + &RecipeOverrideKey::body(recipe.id.clone()), ); assert_eq!( persisted, @@ -399,20 +407,22 @@ mod tests { harness: TestHarness, #[with(10, 1)] terminal: TestTerminal, ) { - let recipe_id = RecipeId::factory(()); + let recipe = Recipe { + body: Some(RecipeBody::Raw { + body: "".into(), + content_type: Some(ContentType::Json), + }), + ..Recipe::factory(()) + }; RecipeOverrideStore::store_persisted( - &RecipeOverrideKey::body(recipe_id.clone()), + &RecipeOverrideKey::body(recipe.id.clone()), &RecipeOverrideValue::Override("hello!".into()), ); - let body: RecipeBody = RecipeBody::Raw { - body: "".into(), - content_type: Some(ContentType::Json), - }; let component = TestComponent::new( &harness, &terminal, - RecipeBodyDisplay::new(&body, recipe_id), + RecipeBodyDisplay::new(recipe.body.as_ref().unwrap(), &recipe), ); assert_eq!( diff --git a/crates/tui/src/view/component/recipe_pane/recipe.rs b/crates/tui/src/view/component/recipe_pane/recipe.rs index 0fbc9e83..d478304d 100644 --- a/crates/tui/src/view/component/recipe_pane/recipe.rs +++ b/crates/tui/src/view/component/recipe_pane/recipe.rs @@ -8,22 +8,18 @@ use crate::view::{ }, draw::{Draw, DrawMetadata}, event::{Child, EventHandler}, - state::Identified, util::persistence::PersistedLazy, Component, }; use derive_more::Display; use persisted::SingletonKey; -use ratatui::{ - layout::Layout, prelude::Constraint, text::Text, widgets::Paragraph, Frame, -}; +use ratatui::{layout::Layout, prelude::Constraint, widgets::Paragraph, Frame}; use reqwest::header::HeaderName; use serde::{Deserialize, Serialize}; use slumber_core::{ collection::{Recipe, RecipeId}, http::{BuildOptions, HttpMethod}, }; -use std::ops::Deref; use strum::{EnumCount, EnumIter}; /// Display a recipe. Not a recipe *node*, this is for genuine bonafide recipe. @@ -85,7 +81,7 @@ impl RecipeDisplay { body: recipe .body .as_ref() - .map(|body| RecipeBodyDisplay::new(body, recipe.id.clone())) + .map(|body| RecipeBodyDisplay::new(body, recipe)) .into(), // Map authentication type authentication: recipe @@ -149,14 +145,6 @@ impl RecipeDisplay { pub fn has_body(&self) -> bool { self.body.data().is_some() } - - /// Get visible body text - pub fn body_text( - &self, - ) -> Option>>> { - let body = self.body.data().as_ref()?; - body.text() - } } impl EventHandler for RecipeDisplay { diff --git a/crates/tui/src/view/component/request_view.rs b/crates/tui/src/view/component/request_view.rs index f797c62f..1ca08f09 100644 --- a/crates/tui/src/view/component/request_view.rs +++ b/crates/tui/src/view/component/request_view.rs @@ -17,6 +17,7 @@ use crate::{ }; use derive_more::Display; use ratatui::{layout::Layout, prelude::Constraint, text::Text, Frame}; +use slumber_config::Action; use slumber_core::{ http::{content_type::ContentType, RequestRecord}, util::{format_byte_size, MaybeStr}, @@ -47,31 +48,40 @@ impl RequestView { body_text_window: Default::default(), } } + + fn view_body(&self) { + if let Some(body) = &self.body { + view_text(body, self.request.mime()); + } + } } impl EventHandler for RequestView { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.actions_emitter, |menu_action| { - match menu_action { - RequestMenuAction::CopyUrl => ViewContext::send_message( - Message::CopyText(self.request.url.to_string()), - ), - RequestMenuAction::CopyBody => { - // Copy exactly what the user sees. Currently requests - // don't support formatting/querying but that could change - if let Some(body) = &self.body { - ViewContext::send_message(Message::CopyText( - body.to_string(), - )); - } - } - RequestMenuAction::ViewBody => { - if let Some(body) = &self.body { - view_text(body, self.request.mime()); + event + .opt() + .action(|action, propagate| match action { + Action::View => self.view_body(), + _ => propagate.set(), + }) + .emitted(self.actions_emitter, |menu_action| { + match menu_action { + RequestMenuAction::CopyUrl => ViewContext::send_message( + Message::CopyText(self.request.url.to_string()), + ), + RequestMenuAction::CopyBody => { + // Copy exactly what the user sees. Currently requests + // don't support formatting/querying but that could + // change + if let Some(body) = &self.body { + ViewContext::send_message(Message::CopyText( + body.to_string(), + )); + } } + RequestMenuAction::ViewBody => self.view_body(), } - } - }) + }) } fn menu_actions(&self) -> Vec { @@ -143,6 +153,13 @@ impl IntoMenuAction for RequestMenuAction { Self::CopyBody | Self::ViewBody => data.body.is_some(), } } + + fn shortcut(&self, _: &RequestView) -> Option { + match self { + Self::CopyUrl | Self::CopyBody => None, + Self::ViewBody => Some(Action::View), + } + } } /// Calculate body text, including syntax highlighting. We have to clone the diff --git a/crates/tui/src/view/component/response_view.rs b/crates/tui/src/view/component/response_view.rs index f00e5f40..04e385e5 100644 --- a/crates/tui/src/view/component/response_view.rs +++ b/crates/tui/src/view/component/response_view.rs @@ -20,6 +20,7 @@ use derive_more::Display; use persisted::PersistedKey; use ratatui::Frame; use serde::Serialize; +use slumber_config::Action; use slumber_core::{collection::RecipeId, http::ResponseRecord}; use std::sync::Arc; use strum::{EnumIter, IntoEnumIterator}; @@ -53,55 +54,41 @@ impl ResponseBodyView { body, } } -} -/// Items in the actions popup menu for the Body -#[derive(Copy, Clone, Debug, Display, EnumIter)] -#[allow(clippy::enum_variant_names)] -enum ResponseBodyMenuAction { - #[display("View Body")] - ViewBody, - #[display("Copy Body")] - CopyBody, - #[display("Save Body as File")] - SaveBody, + fn view_body(&self) { + view_text(self.body.data().visible_text(), self.response.mime()); + } } -impl IntoMenuAction for ResponseBodyMenuAction {} - -/// Persisted key for response body JSONPath query text box -#[derive(Debug, Serialize, PersistedKey)] -#[persisted(String)] -struct ResponseQueryKey(RecipeId); - impl EventHandler for ResponseBodyView { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.actions_emitter, |menu_action| { - match menu_action { - ResponseBodyMenuAction::ViewBody => { - view_text( - self.body.data().visible_text(), - self.response.mime(), - ); - } - ResponseBodyMenuAction::CopyBody => { - // Use whatever text is visible to the user. This differs - // from saving the body, because we can't copy binary - // content, so if the file is binary we'll copy the hexcode - // text - ViewContext::send_message(Message::CopyText( - self.body.data().visible_text().to_string(), - )); - } - ResponseBodyMenuAction::SaveBody => { - // This will trigger a modal to ask the user for a path - ViewContext::send_message(Message::SaveResponseBody { - request_id: self.response.id, - data: self.body.data().modified_text(), - }); + event + .opt() + .action(|action, propagate| match action { + Action::View => self.view_body(), + _ => propagate.set(), + }) + .emitted(self.actions_emitter, |menu_action| { + match menu_action { + ResponseBodyMenuAction::ViewBody => self.view_body(), + ResponseBodyMenuAction::CopyBody => { + // Use whatever text is visible to the user. This + // differs from saving the body, because we can't copy + // binary content, so if the file is binary we'll copy + // the hexcode text + ViewContext::send_message(Message::CopyText( + self.body.data().visible_text().to_string(), + )); + } + ResponseBodyMenuAction::SaveBody => { + // This will trigger a modal to ask the user for a path + ViewContext::send_message(Message::SaveResponseBody { + request_id: self.response.id, + data: self.body.data().modified_text(), + }); + } } - } - }) + }) } fn menu_actions(&self) -> Vec { @@ -121,6 +108,32 @@ impl Draw for ResponseBodyView { } } +/// Items in the actions popup menu for the Body +#[derive(Copy, Clone, Debug, Display, EnumIter)] +#[allow(clippy::enum_variant_names)] +enum ResponseBodyMenuAction { + #[display("View Body")] + ViewBody, + #[display("Copy Body")] + CopyBody, + #[display("Save Body as File")] + SaveBody, +} + +impl IntoMenuAction for ResponseBodyMenuAction { + fn shortcut(&self, _: &ResponseBodyView) -> Option { + match self { + Self::ViewBody => Some(Action::View), + Self::CopyBody | Self::SaveBody => None, + } + } +} + +/// Persisted key for response body JSONPath query text box +#[derive(Debug, Serialize, PersistedKey)] +#[persisted(String)] +struct ResponseQueryKey(RecipeId); + #[derive(Debug)] pub struct ResponseHeadersView { response: Arc, diff --git a/crates/tui/src/view/component/root.rs b/crates/tui/src/view/component/root.rs index 91f58f5b..fec4a54b 100644 --- a/crates/tui/src/view/component/root.rs +++ b/crates/tui/src/view/component/root.rs @@ -1,6 +1,6 @@ use crate::{ http::{RequestState, RequestStateSummary, RequestStore}, - message::Message, + message::{Message, RequestConfig}, util::ResultReported, view::{ common::{ @@ -68,6 +68,12 @@ impl Root { self.primary_view.data().selected_profile_id() } + /// Get a definition of the request that should be sent from the current + /// recipe settings + pub fn request_config(&self) -> Option { + self.primary_view.data().request_config() + } + /// What request should be shown in the request/response pane right now? fn selected_request_id(&self) -> Option { self.selected_request_id.0 diff --git a/docs/src/api/configuration/input_bindings.md b/docs/src/api/configuration/input_bindings.md index 4d6f62b6..149e6815 100644 --- a/docs/src/api/configuration/input_bindings.md +++ b/docs/src/api/configuration/input_bindings.md @@ -56,6 +56,7 @@ input_bindings: | `cancel` | `esc` | Cancel current dialog or request | | `edit` | `e` | Apply a temporary override to a recipe value | | `reset` | `r` | Reset temporary recipe override to its default | +| `view` | `v` | Open the selected content (e.g. body) in your pager | | `history` | `h` | Open request history for a recipe | | `search` | `/` | Open/select search for current pane | | `export` | `:` | Enter command for exporting response data | diff --git a/slumber.yml b/slumber.yml index 0493a7b6..fc4d633e 100644 --- a/slumber.yml +++ b/slumber.yml @@ -64,7 +64,6 @@ chains: authentication: !bearer "{{chains.auth_token}}" headers: Accept: application/json - Content-Type: application/json requests: login: !request @@ -136,6 +135,18 @@ requests: url: "{{host}}/anything" body: "{{chains.big_file}}" + raw_json: !request + name: Raw JSON + method: POST + url: "{{host}}/anything" + headers: + Content-Type: application/json + body: > + { + "location": "boston", + "size": "HUGE" + } + delay: !request <<: *base name: Delay