Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add keybind to open in pager #459

Merged
merged 1 commit into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions crates/config/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 91 additions & 24 deletions crates/core/src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
/// *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<RecipeBody>,
pub authentication: Option<Authentication>,
#[serde(default, with = "cereal::serde_query_parameters")]
pub query: Vec<(String, Template)>,
#[serde(default)]
pub headers: IndexMap<String, Template>,
}

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<Mime> {
self.headers
.get(header::CONTENT_TYPE.as_str())
.and_then(|template| template.display().parse::<Mime>().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"))]
Expand Down Expand Up @@ -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<String>,
/// *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<RecipeBody>,
pub authentication: Option<Authentication>,
#[serde(default, with = "cereal::serde_query_parameters")]
pub query: Vec<(String, Template)>,
#[serde(default)]
pub headers: IndexMap<String, Template>,
}

#[derive(
Clone,
Debug,
Expand Down Expand Up @@ -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<ContentType>,
#[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::<Mime>().ok());
assert_eq!(recipe.mime(), expected);
}
}
27 changes: 16 additions & 11 deletions crates/core/src/http/content_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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<Self> {
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<Self> {
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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
59 changes: 30 additions & 29 deletions crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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<RequestConfig> {
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.

Expand Down
10 changes: 5 additions & 5 deletions crates/tui/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand All @@ -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
Expand Down
Loading
Loading