From 5b25b03a320fff13bf35c8a4294ecb34ed235616 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Tue, 31 Dec 2024 17:32:50 -0500 Subject: [PATCH] Add commands.query_default config field --- crates/config/src/lib.rs | 3 + crates/tui/src/test_util.rs | 5 +- crates/tui/src/view/common/text_box.rs | 10 +- .../tui/src/view/component/queryable_body.rs | 141 +++++++++++++++--- .../tui/src/view/component/response_view.rs | 6 +- docs/src/api/configuration/index.md | 1 + 6 files changed, 139 insertions(+), 27 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a05a7f78..e8717a03 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -135,6 +135,8 @@ pub struct CommandsConfig { /// Wrapping shell to parse and execute commands /// If empty, commands will be parsed with shell-words and run natievly pub shell: Vec, + /// Default query command for responses + pub query_default: Option, } impl Default for CommandsConfig { @@ -150,6 +152,7 @@ impl Default for CommandsConfig { Self { shell: default_shell.iter().map(|s| s.to_string()).collect(), + query_default: None, } } } diff --git a/crates/tui/src/test_util.rs b/crates/tui/src/test_util.rs index 2e57cf1b..46661ba6 100644 --- a/crates/tui/src/test_util.rs +++ b/crates/tui/src/test_util.rs @@ -143,10 +143,11 @@ impl TestTerminal { /// Run a future in a local set, so it can use [tokio::task::spawn_local]. This /// will wait until all spawned tasks are done. -pub async fn run_local(future: impl Future) { +pub async fn run_local(future: impl Future) -> T { let local = LocalSet::new(); - local.run_until(future).await; // Let the future spawn tasks + let output = local.run_until(future).await; // Let the future spawn tasks local.await; // Wait until all tasks are done + output } /// Assert that the event queue matches the given list of patterns. Each event diff --git a/crates/tui/src/view/common/text_box.rs b/crates/tui/src/view/common/text_box.rs index 12954746..cb9202f4 100644 --- a/crates/tui/src/view/common/text_box.rs +++ b/crates/tui/src/view/common/text_box.rs @@ -47,6 +47,7 @@ type Validator = Box bool>; impl TextBox { /// Set initialize value for the text box pub fn default_value(mut self, default: String) -> Self { + // Don't call set_text here, because we don't want to emit an event self.state.text = default; self.state.end(); self @@ -101,10 +102,15 @@ impl TextBox { self.state.text } - /// Set text, and move the cursor to the end. This will **not** emit events + /// Set text, and move the cursor to the end. If the text changed, emit a + /// change event. pub fn set_text(&mut self, text: String) { + let changed = text != self.state.text; self.state.text = text; self.state.end(); + if changed { + self.change(); + } } /// Check if the current input text is valid. Always returns true if there @@ -166,6 +172,7 @@ impl TextBox { /// Emit a change event. Should be called whenever text _content_ is changed fn change(&mut self) { + println!("change"); let is_valid = self.is_valid(); if let Some(debounce) = &self.on_change_debounce { if self.is_valid() { @@ -402,7 +409,6 @@ impl PersistedContainer for TextBox { fn restore_persisted(&mut self, value: Self::Value) { self.set_text(value); - self.submit(); } } diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index c325c637..eeef752f 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -41,6 +41,10 @@ pub struct QueryableBody { /// Are we currently typing in the query box? query_focused: bool, + /// Default query to use when none is present. We have to store this so we + /// can apply it when an empty query is loaded from persistence. Generally + /// this will come from the config but it's parameterized for testing + query_default: Option, /// Shell command used to transform the content body query_command: Option, /// Track status of the current query command @@ -58,27 +62,36 @@ impl QueryableBody { /// Create a new body, optionally loading the query text from the /// persistence DB. This is optional because not all callers use the query /// box, or want to persist the value. - pub fn new(response: Arc) -> Self { + pub fn new( + response: Arc, + default_query: Option, + ) -> Self { let input_engine = &TuiContext::get().input_engine; let binding = input_engine.binding_display(Action::Search); - let text_box = TextBox::default() + let query_text_box = TextBox::default() .placeholder(format!("{binding} to filter")) .placeholder_focused("Enter command (ex: `jq .results`)") .debounce(); + let text_state = TextState::new(response.content_type(), &response.body, true); - Self { + let mut slf = Self { emitter_id: EmitterId::new(), response, query_focused: false, + query_default: default_query, query_command: None, query_state: QueryState::None, - query_text_box: text_box.into(), + query_text_box: query_text_box.into(), text_window: Default::default(), text_state, - } + }; + // Do *not* use the default_value method here, because we want to + // trigger a change event so the query is applied + slf.apply_default_query(); + slf } /// If the original body text is _not_ what the user is looking at (because @@ -99,10 +112,17 @@ impl QueryableBody { &self.text_state.text } + /// Set query to whatever the user passed in as the default + fn apply_default_query(&mut self) { + if let Some(query) = self.query_default.clone() { + self.query_text_box.data_mut().set_text(query); + } + } + /// Update query command based on the current text in the box, and start /// a task to run the command fn update_query(&mut self) { - let command = self.query_text_box.data().text(); + let command = self.query_text_box.data().text().trim(); let response = &self.response; // If a command is already running, abort it @@ -248,7 +268,17 @@ impl PersistedContainer for QueryableBody { } fn restore_persisted(&mut self, value: Self::Value) { - self.query_text_box.data_mut().restore_persisted(value) + let text_box = self.query_text_box.data_mut(); + text_box.restore_persisted(value); + + // It's pretty common to clear the whole text box without thinking about + // it. In that case, we want to restore the default the next time we + // reload from persistence (probably either app restart or next response + // for this recipe). It's possible the user really wants an empty box + // and this is annoying, but I think it'll be more good than bad. + if text_box.text().is_empty() { + self.apply_default_query(); + } } } @@ -388,6 +418,11 @@ mod tests { const TEXT: &[u8] = b"{\"greeting\":\"hello\"}"; + /// Persisted key for testing + #[derive(Debug, Serialize, PersistedKey)] + #[persisted(String)] + struct Key; + /// Style text to match the text window gutter fn gutter(text: &str) -> Span { let styles = &TuiContext::get().styles; @@ -418,7 +453,7 @@ mod tests { let mut component = TestComponent::new( &harness, &terminal, - QueryableBody::new(response), + QueryableBody::new(response, None), ); // Assert initial state/view @@ -483,29 +518,91 @@ mod tests { #[tokio::test] async fn test_persistence( harness: TestHarness, - #[with(30, 4)] terminal: TestTerminal, + terminal: TestTerminal, response: Arc, ) { - #[derive(Debug, Serialize, PersistedKey)] - #[persisted(String)] - struct Key; - // Add initial query to the DB DatabasePersistedStore::store_persisted(&Key, &"head -n 1".to_owned()); - let mut component = TestComponent::new( - &harness, - &terminal, - PersistedLazy::new(Key, QueryableBody::new(response)), + // Loading from persistence triggers a debounce event, which needs a + // local set + let mut component = run_local(async { + TestComponent::new( + &harness, + &terminal, + PersistedLazy::new( + Key, + // Default value should get tossed out + QueryableBody::new(response, Some("initial".into())), + ), + ) + }) + .await; + + // After the debounce, there's a change event that spawns the command + run_local(async { component.drain_draw().assert_empty() }).await; + + assert_eq!( + component.data().query_command.as_deref(), + Some("head -n 1") ); + } - // We already have another test to check that querying works via typing - // in the box, so we just need to make sure state is initialized - // correctly here. Command execution requires a localset - run_local(async { - component.drain_draw().assert_empty(); + /// Test that the user's configured query default is applied on a fresh load + #[rstest] + #[tokio::test] + async fn test_query_default_initial( + harness: TestHarness, + terminal: TestTerminal, + response: Arc, + ) { + // Setting initial value triggers a debounce event + let mut component = run_local(async { + TestComponent::new( + &harness, + &terminal, + QueryableBody::new(response, Some("head -n 1".into())), + ) }) .await; + + // After the debounce, there's a change event that spawns the command + run_local(async { component.drain_draw().assert_empty() }).await; + + assert_eq!( + component.data().query_command.as_deref(), + Some("head -n 1") + ); + } + + /// Test that the user's configured query default is applied when there's a + /// persisted value, but it's an empty string + #[rstest] + #[tokio::test] + async fn test_query_default_persisted( + harness: TestHarness, + terminal: TestTerminal, + response: Arc, + ) { + DatabasePersistedStore::store_persisted(&Key, &"".to_owned()); + + // Setting initial value triggers a debounce event + let mut component = run_local(async { + TestComponent::new( + &harness, + &terminal, + PersistedLazy::new( + Key, + // Default should override the persisted value + QueryableBody::new(response, Some("head -n 1".into())), + ), + ) + }) + .await; + + // After the debounce, there's a change event that spawns the command + run_local(async { component.drain_draw().assert_empty() }).await; + assert_eq!( component.data().query_command.as_deref(), Some("head -n 1") diff --git a/crates/tui/src/view/component/response_view.rs b/crates/tui/src/view/component/response_view.rs index 6b451480..6fb26158 100644 --- a/crates/tui/src/view/component/response_view.rs +++ b/crates/tui/src/view/component/response_view.rs @@ -1,6 +1,7 @@ //! Display for HTTP responses use crate::{ + context::TuiContext, message::Message, view::{ common::{ @@ -144,7 +145,10 @@ impl<'a> Draw> for ResponseBodyView { request_id: props.request_id, body: PersistedLazy::new( ResponseQueryPersistedKey(props.recipe_id.clone()), - QueryableBody::new(Arc::clone(props.response)), + QueryableBody::new( + Arc::clone(props.response), + TuiContext::get().config.commands.query_default.clone(), + ), ) .into(), }); diff --git a/docs/src/api/configuration/index.md b/docs/src/api/configuration/index.md index a1d1b75c..7d567b4f 100644 --- a/docs/src/api/configuration/index.md +++ b/docs/src/api/configuration/index.md @@ -33,6 +33,7 @@ SLUMBER_CONFIG_PATH=~/dotfiles/slumber.yml slumber | Field | Type | Description | Default | | -------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------- | | `commands.shell` | `string[]` | Shell used to execute commands within the TUI. [More info](#commands) | `[sh, -c]` (Unix), `[cmd, /S, /C]` (Windows) | +| `commands.query_default` | `string` | Default query command for all responses | `""` | | `debug` | `boolean` | Enable developer information | `false` | | `editor` | `string` | Command to use when opening files for in-app editing. [More info](./editor.md) | `VISUAL`/`EDITOR` env vars, or `vim` | | `ignore_certificate_hosts` | `string[]` | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) | `[]` |