Skip to content

Commit

Permalink
Add back prettification
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Dec 26, 2024
1 parent 9e95a7a commit c895c53
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 21 deletions.
21 changes: 21 additions & 0 deletions crates/core/src/http/content_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ impl ContentType {
}
}

/// Make a response body look pretty. If the input isn't valid for this
/// content type, return `None`
pub fn prettify(&self, body: &str) -> Option<String> {
match self {
ContentType::Json => {
// The easiest way to prettify is to parse and restringify.
// There's definitely faster ways that don't require building
// the whole data structure in memory, but not via serde
if let Ok(parsed) =
serde_json::from_str::<serde_json::Value>(body)
{
// serde_json shouldn't fail serializing its own Value type
serde_json::to_string_pretty(&parsed).ok()
} else {
// Not valid JSON
None
}
}
}
}

/// Stringify a single JSON value into this format
pub fn value_to_string(self, value: &serde_json::Value) -> String {
match self {
Expand Down
73 changes: 55 additions & 18 deletions crates/tui/src/view/component/queryable_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use slumber_core::{
http::{content_type::ContentType, ResponseBody, ResponseRecord},
util::MaybeStr,
};
use std::sync::Arc;
use std::{borrow::Cow, sync::Arc};
use tokio::task;

/// Display response body as text, with a query box to run commands on the body.
Expand Down Expand Up @@ -62,7 +62,7 @@ impl QueryableBody {
.placeholder(format!("{binding} to filter"))
.placeholder_focused("Enter command (ex: `jq .results`)")
.debounce();
let state = init_state(response.content_type(), &response.body);
let state = init_state(response.content_type(), &response.body, true);

Self {
emitter_id: EmitterId::new(),
Expand All @@ -75,18 +75,20 @@ impl QueryableBody {
}
}

/// If a query command has been applied, get the visible text. Otherwise,
/// If the original body text is _not_ what the user is looking at (because
/// of a query command or prettification), get the visible text. Otherwise,
/// return `None` to indicate the response's original body can be used.
/// Return an owned value because we have to join the text to a string.
/// Binary bodies will return `None` here. Return an owned value because we
/// have to join the text to a string.
pub fn modified_text(&self) -> Option<String> {
if self.query_command.is_none() {
None
} else {
if self.query_command.is_some() || self.state.pretty {
Some(self.state.text.to_string())
} else {
None
}
}

/// Get visible body text
/// Get whatever text the user sees
pub fn visible_text(&self) -> &Text {
&self.state.text
}
Expand All @@ -95,17 +97,22 @@ impl QueryableBody {
/// a task to run the command
fn update_query(&mut self) {
let command = self.query_text_box.data().text();
let response = &self.response;
if command.is_empty() {
// Reset to initial body
self.query_command = None;
self.state =
init_state(self.response.content_type(), &self.response.body);
} else {
self.state = init_state(
self.response.content_type(),
&self.response.body,
true, // Prettify
);
} else if self.query_command.as_deref() != Some(command) {
// If the command has changed, execute it
self.query_command = Some(command.to_owned());

// Spawn the command in the background because it could be slow.
// Clone is cheap because Bytes uses refcounting
let body = self.response.body.bytes().clone();
let body = response.body.bytes().clone();
let command = command.to_owned();
let emitter = self.detach();
task::spawn_local(async move {
Expand Down Expand Up @@ -143,6 +150,9 @@ impl EventHandler for QueryableBody {
// Assume the output has the same content type
self.response.content_type(),
&ResponseBody::new(stdout),
// Don't prettify - user has control over this output,
// so if it isn't pretty already that's on them
false,
);
}
// Trigger error state. We DON'T want to show a modal here
Expand Down Expand Up @@ -214,6 +224,9 @@ impl Emitter for QueryableBody {
struct TextState {
/// The full body, which we need to track for launching commands
text: Identified<Text<'static>>,
/// Was the text prettified? We track this so we know if we've modified the
/// original text
pretty: bool,
}

/// Emitted event to notify when a query subprocess has completed. Contains the
Expand All @@ -225,6 +238,7 @@ pub struct QueryComplete(anyhow::Result<Vec<u8>>);
fn init_state<T: AsRef<[u8]>>(
content_type: Option<ContentType>,
body: &ResponseBody<T>,
prettify: bool,
) -> TextState {
if TuiContext::get().config.http.is_large(body.size()) {
// For bodies over the "large" size, skip prettification and
Expand All @@ -238,21 +252,43 @@ fn init_state<T: AsRef<[u8]>>(
if let Some(text) = body.text() {
TextState {
text: str_to_text(text).into(),
pretty: false,
}
} else {
// Showing binary content is a bit of a novelty, there's not much
// value in it. For large bodies it's not worth the CPU cycles
let text: Text = "<binary>".into();
TextState { text: text.into() }
TextState {
text: text.into(),
pretty: false,
}
}
} else if let Some(text) = body.text() {
let text = highlight::highlight_if(content_type, str_to_text(text));
TextState { text: text.into() }
// Prettify for known content types. We _don't_ do this in a separate
// task because it's generally very fast. If this is slow enough that
// it affects the user, the "large" body size is probably too low
// 2024 edition: if-let chain
let (text, pretty): (Cow<str>, bool) = match content_type {
Some(content_type) if prettify => content_type
.prettify(text)
.map(|body| (Cow::Owned(body), true))
.unwrap_or((Cow::Borrowed(text), false)),
_ => (Cow::Borrowed(text), false),
};

let text = highlight::highlight_if(content_type, str_to_text(&text));
TextState {
text: text.into(),
pretty,
}
} else {
// Content is binary, show a textual representation of it
let text: Text =
format!("{:#}", MaybeStr(body.bytes().as_ref())).into();
TextState { text: text.into() }
TextState {
text: text.into(),
pretty: false,
}
}
}

Expand Down Expand Up @@ -287,8 +323,9 @@ mod tests {
fn response() -> Arc<ResponseRecord> {
ResponseRecord {
status: StatusCode::OK,
// Note: do NOT set the content-type header here. All it does is
// enable syntax highlighting, which makes buffer assertions hard
// Note: do NOT set the content-type header here. It enables syntax
// highlighting, which makes buffer assertions hard. JSON-specific
// behavior is tested in ResponseView
headers: Default::default(),
body: ResponseBody::new(TEXT.into()),
}
Expand Down
4 changes: 2 additions & 2 deletions crates/tui/src/view/component/recipe_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,13 +483,13 @@ mod tests {
assert_eq!(
select
.items()
.map(|item| item.id.as_str())
.map(|item| &item.id as &str)
.collect_vec()
.as_slice(),
&["recipe2", "recipe22"]
);
assert_eq!(
select.selected().map(|item| item.id.as_str()),
select.selected().map(|item| &item.id as &str),
Some("recipe2")
);

Expand Down
25 changes: 24 additions & 1 deletion crates/tui/src/view/component/response_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,13 @@ mod tests {
view::test_util::TestComponent,
};
use crossterm::event::KeyCode;
use indexmap::indexmap;
use rstest::rstest;
use slumber_core::{assert_matches, http::Exchange, test_util::Factory};
use slumber_core::{
assert_matches,
http::Exchange,
test_util::{header_map, Factory},
};

/// Test "Copy Body" menu action
#[rstest]
Expand All @@ -197,6 +202,14 @@ mod tests {
},
"{\"hello\":\"world\"}",
)]
#[case::json_body(
ResponseRecord {
headers: header_map(indexmap! {"content-type" => "application/json"}),
body: br#"{"hello":"world"}"#.to_vec().into(),
..ResponseRecord::factory(())
},
"{\n \"hello\": \"world\"\n}",
)]
#[case::binary_body(
ResponseRecord {
body: b"\x01\x02\x03\xff".to_vec().into(),
Expand Down Expand Up @@ -253,6 +266,16 @@ mod tests {
None,
None,
)]
#[case::json_body(
ResponseRecord {
headers: header_map(indexmap! {"content-type" => "application/json"}),
body: br#"{"hello":"world"}"#.to_vec().into(),
..ResponseRecord::factory(())
},
None,
// Body has been prettified, so we can't use the original
Some("{\n \"hello\": \"world\"\n}"),
)]
#[case::binary_body(
ResponseRecord {
body: b"\x01\x02\x03".to_vec().into(),
Expand Down

0 comments on commit c895c53

Please sign in to comment.