Skip to content

Commit

Permalink
Add commands.query_default config field
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Dec 31, 2024
1 parent e5e5d99 commit 5b25b03
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 27 deletions.
3 changes: 3 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Default query command for responses
pub query_default: Option<String>,
}

impl Default for CommandsConfig {
Expand All @@ -150,6 +152,7 @@ impl Default for CommandsConfig {

Self {
shell: default_shell.iter().map(|s| s.to_string()).collect(),
query_default: None,
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions crates/tui/src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output = ()>) {
pub async fn run_local<T>(future: impl Future<Output = T>) -> 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
Expand Down
10 changes: 8 additions & 2 deletions crates/tui/src/view/common/text_box.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Validator = Box<dyn Fn(&str) -> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -402,7 +409,6 @@ impl PersistedContainer for TextBox {

fn restore_persisted(&mut self, value: Self::Value) {
self.set_text(value);
self.submit();
}
}

Expand Down
141 changes: 119 additions & 22 deletions crates/tui/src/view/component/queryable_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Shell command used to transform the content body
query_command: Option<String>,
/// Track status of the current query command
Expand All @@ -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<ResponseRecord>) -> Self {
pub fn new(
response: Arc<ResponseRecord>,
default_query: Option<String>,
) -> 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
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -418,7 +453,7 @@ mod tests {
let mut component = TestComponent::new(
&harness,
&terminal,
QueryableBody::new(response),
QueryableBody::new(response, None),
);

// Assert initial state/view
Expand Down Expand Up @@ -483,29 +518,91 @@ mod tests {
#[tokio::test]
async fn test_persistence(
harness: TestHarness,
#[with(30, 4)] terminal: TestTerminal,
terminal: TestTerminal,
response: Arc<ResponseRecord>,
) {
#[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<ResponseRecord>,
) {
// 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<ResponseRecord>,
) {
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")
Expand Down
6 changes: 5 additions & 1 deletion crates/tui/src/view/component/response_view.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Display for HTTP responses
use crate::{
context::TuiContext,
message::Message,
view::{
common::{
Expand Down Expand Up @@ -144,7 +145,10 @@ impl<'a> Draw<ResponseBodyViewProps<'a>> 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(),
});
Expand Down
1 change: 1 addition & 0 deletions docs/src/api/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | `[]` |
Expand Down

0 comments on commit 5b25b03

Please sign in to comment.