From 9486819d76c23309e23785f894748d62a67f0296 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Thu, 28 Dec 2023 12:42:04 +0100 Subject: [PATCH] Toggle query/header rows in recipe --- CHANGELOG.md | 2 + src/cli/request.rs | 3 +- src/db.rs | 16 +- src/http.rs | 147 +++++++------ src/tui.rs | 8 +- src/tui/message.rs | 6 +- src/tui/view/common/text_window.rs | 5 +- src/tui/view/component/primary.rs | 1 + src/tui/view/component/profile_list.rs | 2 +- src/tui/view/component/recipe_list.rs | 4 +- src/tui/view/component/request.rs | 279 ++++++++++++++++++------- src/tui/view/state.rs | 16 +- src/tui/view/state/persistence.rs | 37 +++- src/tui/view/state/select.rs | 25 ++- 14 files changed, 375 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d00a47d..f907d92c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Move app-level configuration into a file ([#89](https://github.com/LucasPickering/slumber/issues/89)) - Right now the only supported field is `preview_templates` +- Toggle query parameters and headers in recipe pane ([#30](https://github.com/LucasPickering/slumber/issues/30)) + - You can easily enable/disable parameters and headers without having to modify the collection file now ### Changed diff --git a/src/cli/request.rs b/src/cli/request.rs index 7513833e..d2d64870 100644 --- a/src/cli/request.rs +++ b/src/cli/request.rs @@ -2,7 +2,7 @@ use crate::{ cli::Subcommand, collection::{CollectionFile, ProfileId, RecipeId}, db::Database, - http::{HttpEngine, RequestBuilder}, + http::{HttpEngine, RecipeOptions, RequestBuilder}, template::{Prompt, Prompter, TemplateContext}, util::ResultExt, GlobalArgs, @@ -75,6 +75,7 @@ impl Subcommand for RequestCommand { let overrides: IndexMap<_, _> = self.overrides.into_iter().collect(); let request = RequestBuilder::new( recipe, + RecipeOptions::default(), TemplateContext { profile, chains: collection.chains.clone(), diff --git a/src/db.rs b/src/db.rs index ab1a471d..da07df4f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -110,9 +110,9 @@ impl Database { ) .down("DROP TABLE requests"), M::up( - // Values will be serialized as msgpack + // keys+values will be serialized as msgpack "CREATE TABLE ui_state ( - key TEXT NOT NULL, + key BLOB NOT NULL, collection_id UUID NOT NULL, value BLOB NOT NULL, PRIMARY KEY (key, collection_id), @@ -355,7 +355,7 @@ impl CollectionDatabase { /// Get the value of a UI state field pub fn get_ui(&self, key: K) -> anyhow::Result> where - K: Display, + K: Debug + Serialize, V: Debug + DeserializeOwned, { let value = self @@ -366,7 +366,7 @@ impl CollectionDatabase { WHERE collection_id = :collection_id AND key = :key", named_params! { ":collection_id": self.collection_id, - ":key": key.to_string(), + ":key": Bytes(&key), }, |row| { let value: Bytes = row.get("value")?; @@ -376,17 +376,17 @@ impl CollectionDatabase { .optional() .context("Error fetching UI state from database") .traced()?; - debug!(%key, ?value, "Fetched UI state"); + debug!(?key, ?value, "Fetched UI state"); Ok(value) } /// Set the value of a UI state field pub fn set_ui(&self, key: K, value: V) -> anyhow::Result<()> where - K: Display, + K: Debug + Serialize, V: Debug + Serialize, { - debug!(%key, ?value, "Setting UI state"); + debug!(?key, ?value, "Setting UI state"); self.database .connection() .execute( @@ -396,7 +396,7 @@ impl CollectionDatabase { ON CONFLICT DO UPDATE SET value = excluded.value", named_params! { ":collection_id": self.collection_id, - ":key": key.to_string(), + ":key": Bytes(key), ":value": Bytes(value), }, ) diff --git a/src/http.rs b/src/http.rs index 687da74e..884ef84c 100644 --- a/src/http.rs +++ b/src/http.rs @@ -40,9 +40,7 @@ pub use parse::*; pub use record::*; use crate::{ - collection::Recipe, - db::CollectionDatabase, - template::{Template, TemplateContext}, + collection::Recipe, db::CollectionDatabase, template::TemplateContext, util::ResultExt, }; use anyhow::Context; @@ -53,6 +51,7 @@ use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, }; +use std::collections::HashSet; use tokio::try_join; use tracing::{debug, info, info_span}; @@ -220,22 +219,43 @@ pub struct RequestBuilder { id: RequestId, // We need this during the build recipe: Recipe, + options: RecipeOptions, template_context: TemplateContext, } +/// OPtions for modifying a recipe during a build. This is helpful for applying +/// temporary modifications made by the user. By providing this in a separate +/// struct, we prevent the need to clone, modify, and pass recipes everywhere. +/// Recipes could be very large so cloning may be expensive, and this options +/// layer makes the available modifications clear and restricted. +#[derive(Clone, Debug, Default)] +pub struct RecipeOptions { + /// Which headers should be excluded? A blacklist allows the default to be + /// "include all". + pub disabled_headers: HashSet, + /// Which query parameters should be excluded? A blacklist allows the + /// default to be "include all". + pub disabled_query_parameters: HashSet, +} + impl RequestBuilder { /// Instantiate new request builder for the given recipe. Use [Self::build] /// to build it. /// /// This needs an owned recipe and context so they can be moved into a /// subtask for the build. - pub fn new(recipe: Recipe, template_context: TemplateContext) -> Self { + pub fn new( + recipe: Recipe, + options: RecipeOptions, + template_context: TemplateContext, + ) -> Self { debug!(recipe_id = %recipe.id, "Building request from recipe"); let request_id = RequestId::new(); Self { id: request_id, recipe, + options, template_context, } } @@ -258,20 +278,20 @@ impl RequestBuilder { /// Outsourced build function, to make error conversion easier later async fn build_helper(self) -> anyhow::Result { - let recipe = self.recipe; + // let recipe = self.recipe; let template_context = &self.template_context; - let method = recipe.method.parse()?; + let method = self.recipe.method.parse()?; // Render everything in parallel let (url, headers, query, body) = try_join!( - Self::render_url(template_context, &recipe.url), - Self::render_headers(template_context, &recipe.headers), - Self::render_query(template_context, &recipe.query), - Self::render_body(template_context, recipe.body.as_ref()), + self.render_url(), + self.render_headers(), + self.render_query(), + self.render_body(), )?; info!( - recipe_id = %recipe.id, + recipe_id = %self.recipe.id, "Built request from recipe", ); @@ -281,7 +301,7 @@ impl RequestBuilder { .profile .as_ref() .map(|profile| profile.id.clone()), - recipe_id: recipe.id.clone(), + recipe_id: self.recipe.id, method, url, query, @@ -290,70 +310,79 @@ impl RequestBuilder { }) } - async fn render_url( - template_context: &TemplateContext, - url: &Template, - ) -> anyhow::Result { - url.render(template_context) + async fn render_url(&self) -> anyhow::Result { + self.recipe + .url + .render(&self.template_context) .await .context("Error rendering URL") } - async fn render_headers( - template_context: &TemplateContext, - headers: &IndexMap, - ) -> anyhow::Result { - let iter = headers.iter().map(|(header, value_template)| async move { - let value = value_template - .render(template_context) - .await - .context(format!("Error rendering header `{header}`"))?; - // Strip leading/trailing line breaks because they're going to - // trigger a validation error and are probably a mistake. This is - // a balance between convenience and explicitness - let value = value.trim_matches(|c| c == '\n' || c == '\r'); - // String -> header conversions are fallible, if headers - // are invalid - Ok::<(HeaderName, HeaderValue), anyhow::Error>(( - header - .try_into() - .context(format!("Error parsing header name `{header}`"))?, - value.try_into().context(format!( - "Error parsing value for header `{header}`" - ))?, - )) - }); + async fn render_headers(&self) -> anyhow::Result { + let template_context = &self.template_context; + let iter = self + .recipe + .headers + .iter() + // Filter out disabled headers + .filter(|(header, _)| { + !self.options.disabled_headers.contains(*header) + }) + .map(|(header, value_template)| async move { + let value = value_template + .render(template_context) + .await + .context(format!("Error rendering header `{header}`"))?; + // Strip leading/trailing line breaks because they're going to + // trigger a validation error and are probably a mistake. This + // is a balance between convenience and + // explicitness + let value = value.trim_matches(|c| c == '\n' || c == '\r'); + // String -> header conversions are fallible, if headers + // are invalid + Ok::<(HeaderName, HeaderValue), anyhow::Error>(( + header.try_into().context(format!( + "Error parsing header name `{header}`" + ))?, + value.try_into().context(format!( + "Error parsing value for header `{header}`" + ))?, + )) + }); Ok(future::try_join_all(iter) .await? .into_iter() .collect::()) } - async fn render_query( - template_context: &TemplateContext, - query: &IndexMap, - ) -> anyhow::Result> { - let iter = query.iter().map(|(k, v)| async move { - Ok::<_, anyhow::Error>(( - k.clone(), - v.render(template_context).await.context(format!( - "Error rendering query parameter `{k}`" - ))?, - )) - }); + async fn render_query(&self) -> anyhow::Result> { + let template_context = &self.template_context; + let iter = self + .recipe + .query + .iter() + // Filter out disabled params + .filter(|(param, _)| { + !self.options.disabled_query_parameters.contains(*param) + }) + .map(|(k, v)| async move { + Ok::<_, anyhow::Error>(( + k.clone(), + v.render(template_context).await.context(format!( + "Error rendering query parameter `{k}`" + ))?, + )) + }); Ok(future::try_join_all(iter) .await? .into_iter() .collect::>()) } - async fn render_body( - template_context: &TemplateContext, - body: Option<&Template>, - ) -> anyhow::Result> { - match body { + async fn render_body(&self) -> anyhow::Result> { + match &self.recipe.body { Some(body) => Ok(Some( - body.render(template_context) + body.render(&self.template_context) .await .context("Error rendering body")?, )), diff --git a/src/tui.rs b/src/tui.rs index 725ea55b..b03ebc95 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -7,7 +7,7 @@ use crate::{ collection::{Collection, CollectionFile, ProfileId, RecipeId}, config::Config, db::{CollectionDatabase, Database}, - http::{HttpEngine, RequestBuilder}, + http::{HttpEngine, RecipeOptions, RequestBuilder}, template::{Prompter, Template, TemplateChunk, TemplateContext}, tui::{ context::TuiContext, @@ -206,7 +206,8 @@ impl Tui { Message::HttpBeginRequest { recipe_id, profile_id, - } => self.send_request(profile_id, recipe_id)?, + options, + } => self.send_request(profile_id, recipe_id, options)?, Message::HttpBuildError { profile_id, recipe_id, @@ -313,6 +314,7 @@ impl Tui { &mut self, profile_id: Option, recipe_id: RecipeId, + options: RecipeOptions, ) -> anyhow::Result<()> { let recipe = self .collection_file @@ -327,7 +329,7 @@ impl Tui { let template_context = self .template_context(profile_id.as_ref(), self.messages_tx.clone())?; let http_engine = self.http_engine.clone(); - let builder = RequestBuilder::new(recipe, template_context); + let builder = RequestBuilder::new(recipe, options, template_context); let messages_tx = self.messages_tx.clone(); // Mark request state as building diff --git a/src/tui/message.rs b/src/tui/message.rs index 24b524e5..70b75d94 100644 --- a/src/tui/message.rs +++ b/src/tui/message.rs @@ -3,7 +3,10 @@ use crate::{ collection::{Collection, ProfileId, RecipeId}, - http::{RequestBuildError, RequestError, RequestId, RequestRecord}, + http::{ + RecipeOptions, RequestBuildError, RequestError, RequestId, + RequestRecord, + }, template::{Prompt, Prompter, Template, TemplateChunk}, util::ResultExt, }; @@ -61,6 +64,7 @@ pub enum Message { HttpBeginRequest { profile_id: Option, recipe_id: RecipeId, + options: RecipeOptions, }, /// Request failed to build HttpBuildError { diff --git a/src/tui/view/common/text_window.rs b/src/tui/view/common/text_window.rs index ec143334..11582183 100644 --- a/src/tui/view/common/text_window.rs +++ b/src/tui/view/common/text_window.rs @@ -170,7 +170,10 @@ struct TextWindowActionsModal { impl Default for TextWindowActionsModal { fn default() -> Self { - fn on_submit(context: &mut UpdateContext, action: &TextWindowAction) { + fn on_submit( + context: &mut UpdateContext, + action: &mut TextWindowAction, + ) { // Close the modal *first*, so the action event gets handled by our // parent rather than the modal. Jank but it works context.queue_event(Event::CloseModal); diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index fe667578..bc150ab6 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -168,6 +168,7 @@ impl EventHandler for PrimaryView { profile_id: self .selected_profile() .map(|profile| profile.id.clone()), + options: self.request_pane.recipe_options(), }); } Update::Consumed diff --git a/src/tui/view/component/profile_list.rs b/src/tui/view/component/profile_list.rs index 98f88715..f9d72bbb 100644 --- a/src/tui/view/component/profile_list.rs +++ b/src/tui/view/component/profile_list.rs @@ -26,7 +26,7 @@ pub struct ProfileListPaneProps { impl ProfileListPane { pub fn new(profiles: Vec) -> Self { // Loaded request depends on the profile, so refresh on change - fn on_select(context: &mut UpdateContext, _: &Profile) { + fn on_select(context: &mut UpdateContext, _: &mut Profile) { context.queue_event(Event::HttpLoadRequest); } diff --git a/src/tui/view/component/recipe_list.rs b/src/tui/view/component/recipe_list.rs index 2c6a9796..9af27be9 100644 --- a/src/tui/view/component/recipe_list.rs +++ b/src/tui/view/component/recipe_list.rs @@ -28,12 +28,12 @@ pub struct RecipeListPaneProps { impl RecipeListPane { pub fn new(recipes: Vec) -> Self { // When highlighting a new recipe, load it from the repo - fn on_select(context: &mut UpdateContext, _: &Recipe) { + fn on_select(context: &mut UpdateContext, _: &mut Recipe) { context.queue_event(Event::HttpLoadRequest); } // Trigger a request on submit - fn on_submit(context: &mut UpdateContext, _: &Recipe) { + fn on_submit(context: &mut UpdateContext, _: &mut Recipe) { // Parent has to be responsible for actually sending the request // because it also needs access to the profile list state context.queue_event(Event::HttpSendRequest); diff --git a/src/tui/view/component/request.rs b/src/tui/view/component/request.rs index 4b0eb120..51fe8306 100644 --- a/src/tui/view/component/request.rs +++ b/src/tui/view/component/request.rs @@ -1,15 +1,20 @@ use crate::{ collection::{ProfileId, Recipe, RecipeId}, + http::RecipeOptions, template::Template, tui::view::{ common::{ table::Table, tabs::Tabs, template_preview::TemplatePreview, - text_window::TextWindow, Pane, + text_window::TextWindow, Checkbox, Pane, }, component::primary::PrimaryPane, draw::{Draw, Generate}, - event::EventHandler, - state::{persistence::PersistentKey, StateCell}, + event::{EventHandler, UpdateContext}, + state::{ + persistence::{Persistable, Persistent, PersistentKey}, + select::{Dynamic, SelectState}, + StateCell, + }, util::layout, Component, }, @@ -18,10 +23,12 @@ use derive_more::Display; use itertools::Itertools; use ratatui::{ prelude::{Constraint, Direction, Rect}, - widgets::Paragraph, + text::Text, + widgets::{Paragraph, TableState}, Frame, }; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use strum::EnumIter; /// Display a request recipe @@ -36,7 +43,7 @@ pub struct RequestPane { impl Default for RequestPane { fn default() -> Self { Self { - tabs: Tabs::new(PersistentKey::RequestTab).into(), + tabs: Tabs::new(PersistentKey::RecipeTab).into(), recipe_state: Default::default(), } } @@ -58,8 +65,8 @@ struct RecipeStateKey { #[derive(Debug)] struct RecipeState { url: TemplatePreview, - query: Vec<(String, TemplatePreview)>, - headers: Vec<(String, TemplatePreview)>, + query: Component>>, + headers: Component>>, body: Option>>, } @@ -81,24 +88,60 @@ enum Tab { Headers, } +/// One row in the query/header table +#[derive(Debug)] +struct RowState { + key: String, + value: TemplatePreview, + enabled: Persistent, +} + +impl RequestPane { + /// Generate a [RecipeOptions] instance based on current UI state + pub fn recipe_options(&self) -> RecipeOptions { + if let Some(state) = self.recipe_state.get() { + /// Convert select state into the set of disabled keys + fn to_disabled_set( + select_state: &SelectState, + ) -> HashSet { + select_state + .items() + .iter() + .filter(|row| !*row.enabled) + .map(|row| row.key.clone()) + .collect() + } + + RecipeOptions { + disabled_headers: to_disabled_set(&state.headers), + disabled_query_parameters: to_disabled_set(&state.query), + } + } else { + // Shouldn't be possible, because state is initialized on first + // render + RecipeOptions::default() + } + } +} + impl EventHandler for RequestPane { fn children(&mut self) -> Vec> { let selected_tab = *self.tabs.selected(); let mut children = vec![self.tabs.as_child()]; - match selected_tab { - Tab::Body => { - // If the body is initialized and present, send events there too - if let Some(body) = self - .recipe_state - .get_mut() - .and_then(|state| state.body.as_mut()) - { - children.push(body.as_child()); + + // Send events to the tab pane as well + if let Some(state) = self.recipe_state.get_mut() { + match selected_tab { + Tab::Body => { + if let Some(body) = state.body.as_mut() { + children.push(body.as_child()); + } } + Tab::Query => children.push(state.query.as_child()), + Tab::Headers => children.push(state.headers.as_child()), } - Tab::Query => {} - Tab::Headers => {} } + children } } @@ -147,27 +190,7 @@ impl<'a> Draw> for RequestPane { selected_profile_id: props.selected_profile_id.cloned(), recipe_id: recipe.id.clone(), }, - || RecipeState { - url: TemplatePreview::new( - recipe.url.clone(), - props.selected_profile_id.cloned(), - ), - query: to_template_previews( - &recipe.query, - props.selected_profile_id, - ), - headers: to_template_previews( - &recipe.headers, - props.selected_profile_id, - ), - body: recipe.body.as_ref().map(|body| { - TextWindow::new(TemplatePreview::new( - body.clone(), - props.selected_profile_id.cloned(), - )) - .into() - }), - }, + || RecipeState::new(recipe, props.selected_profile_id), ); // First line: Method + URL @@ -187,56 +210,150 @@ impl<'a> Draw> for RequestPane { body.draw(frame, (), content_area); } } - Tab::Query => frame.render_widget( - Table { - rows: recipe_state - .query - .iter() - .map(|(param, value)| { - [param.as_str().into(), value.generate()] - }) - .collect_vec(), - header: Some(["Parameter", "Value"]), - alternate_row_style: true, - ..Default::default() - } - .generate(), + Tab::Query => frame.render_stateful_widget( + to_table(&recipe_state.query, ["Parameter", "Value", ""]) + .generate(), content_area, + &mut recipe_state.query.state_mut(), ), - Tab::Headers => frame.render_widget( - Table { - rows: recipe_state - .headers - .iter() - .map(|(param, value)| { - [param.as_str().into(), value.generate()] - }) - .collect_vec(), - header: Some(["Header", "Value"]), - alternate_row_style: true, - ..Default::default() - } - .generate(), + Tab::Headers => frame.render_stateful_widget( + to_table(&recipe_state.headers, ["Header", "Value", ""]) + .generate(), content_area, + &mut recipe_state.headers.state_mut(), ), } } } } -/// Convert a map of (string, template) from a recipe into (string, template -/// preview) to kick off the template preview for each value. The output should -/// be stored in state. -fn to_template_previews<'a>( - iter: impl IntoIterator, - profile_id: Option<&ProfileId>, -) -> Vec<(String, TemplatePreview)> { - iter.into_iter() - .map(|(k, v)| { - ( - k.clone(), - TemplatePreview::new(v.clone(), profile_id.cloned()), +impl RecipeState { + /// Initialize new recipe state. Should be called whenever the recipe or + /// profile changes + fn new(recipe: &Recipe, selected_profile_id: Option<&ProfileId>) -> Self { + let query_items = recipe + .query + .iter() + .map(|(param, value)| { + RowState::new( + param.clone(), + value.clone(), + selected_profile_id.cloned(), + PersistentKey::RecipeQuery { + recipe: recipe.id.clone(), + param: param.clone(), + }, + ) + }) + .collect(); + let header_items = recipe + .headers + .iter() + .map(|(header, value)| { + RowState::new( + header.clone(), + value.clone(), + selected_profile_id.cloned(), + PersistentKey::RecipeHeader { + recipe: recipe.id.clone(), + header: header.clone(), + }, + ) + }) + .collect(); + + Self { + url: TemplatePreview::new( + recipe.url.clone(), + selected_profile_id.cloned(), + ), + query: Persistent::new( + PersistentKey::RecipeSelectedQuery(recipe.id.clone()), + SelectState::new(query_items).on_submit(RowState::on_submit), + ) + .into(), + headers: Persistent::new( + PersistentKey::RecipeSelectedHeader(recipe.id.clone()), + SelectState::new(header_items).on_submit(RowState::on_submit), ) - }) - .collect() + .into(), + body: recipe.body.as_ref().map(|body| { + TextWindow::new(TemplatePreview::new( + body.clone(), + selected_profile_id.cloned(), + )) + .into() + }), + } + } +} + +impl RowState { + fn new( + key: String, + value: Template, + selected_profile_id: Option, + persistent_key: PersistentKey, + ) -> Self { + Self { + key, + value: TemplatePreview::new(value, selected_profile_id), + enabled: Persistent::new( + persistent_key, + // Value itself is the container, so just pass a default value + true, + ), + } + } + + /// Toggle row state on submit + fn on_submit(_: &mut UpdateContext, row: &mut Self) { + *row.enabled ^= true; + } +} + +/// Convert table select state into a renderable table +fn to_table<'a>( + state: &'a SelectState, + header: [&'a str; 3], +) -> Table<'a, 3, Vec<[Text<'a>; 3]>> { + Table { + rows: state + .items() + .iter() + .map(|item| { + [ + item.key.as_str().into(), + item.value.generate(), + Checkbox { + checked: *item.enabled, + } + .generate(), + ] + }) + .collect_vec(), + header: Some(header), + column_widths: &[ + Constraint::Percentage(50), + Constraint::Percentage(50), + Constraint::Min(3), + ], + alternate_row_style: true, + ..Default::default() + } +} + +/// This impl persists just which row is *selected* +impl Persistable for RowState { + type Persisted = String; + + fn get_persistent(&self) -> &Self::Persisted { + &self.key + } +} + +impl PartialEq for String { + fn eq(&self, other: &RowState) -> bool { + self == &other.key + } } diff --git a/src/tui/view/state.rs b/src/tui/view/state.rs index 191a62e3..7c6a5851 100644 --- a/src/tui/view/state.rs +++ b/src/tui/view/state.rs @@ -47,9 +47,19 @@ impl StateCell { Ref::map(self.state.borrow(), |state| &state.as_ref().unwrap().1) } - /// Get a mutable reference to the V. This will never panic because - /// `&mut self` guarantees exclusive access. Returns `None` iff the state - /// cell is uninitialized. + /// Get a reference to the state value. This can panic, if the state value + /// is already borrowed elsewhere. Returns `None` iff the state cell is + /// uninitialized. + pub fn get(&self) -> Option> { + Ref::filter_map(self.state.borrow(), |state| { + state.as_ref().map(|(_, v)| v) + }) + .ok() + } + + /// Get a mutable reference to the state value. This will never panic + /// because `&mut self` guarantees exclusive access. Returns `None` iff + /// the state cell is uninitialized. pub fn get_mut(&mut self) -> Option<&mut V> { self.state.get_mut().as_mut().map(|state| &mut state.1) } diff --git a/src/tui/view/state/persistence.rs b/src/tui/view/state/persistence.rs index fcdd8124..d97fecd8 100644 --- a/src/tui/view/state/persistence.rs +++ b/src/tui/view/state/persistence.rs @@ -1,13 +1,16 @@ //! Utilities for persisting UI state between sessions -use crate::tui::{ - context::TuiContext, - view::{ - component::Component, - event::{Event, EventHandler, Update, UpdateContext}, +use crate::{ + collection::RecipeId, + tui::{ + context::TuiContext, + view::{ + component::Component, + event::{Event, EventHandler, Update, UpdateContext}, + }, }, }; -use derive_more::{Deref, DerefMut, Display}; +use derive_more::{Deref, DerefMut}; use serde::{de::DeserializeOwned, Serialize}; use std::fmt::Debug; @@ -30,7 +33,7 @@ impl Persistent { if let Ok(Some(value)) = TuiContext::get() .database - .get_ui::<_, ::Persisted>(key) + .get_ui::<_, ::Persisted>(&key) { container.set(value); } @@ -56,19 +59,33 @@ where impl Drop for Persistent { fn drop(&mut self) { let _ = TuiContext::get().database.set_ui( - self.key, + &self.key, self.container.get().map(Persistable::get_persistent), ); } } -#[derive(Copy, Clone, Debug, Display)] +#[derive(Clone, Debug, Serialize)] pub enum PersistentKey { PrimaryPane, FullscreenMode, ProfileId, RecipeId, - RequestTab, + RecipeTab, + /// Selected query param, per recipe. Value is the query param name + RecipeSelectedQuery(RecipeId), + /// Toggle state for a single recipe+query param + RecipeQuery { + recipe: RecipeId, + param: String, + }, + /// Selected header, per recipe. Value is the header name + RecipeSelectedHeader(RecipeId), + /// Toggle state for a single recipe+header + RecipeHeader { + recipe: RecipeId, + header: String, + }, ResponseTab, } diff --git a/src/tui/view/state/select.rs b/src/tui/view/state/select.rs index 5c008f71..ef6fbf48 100644 --- a/src/tui/view/state/select.rs +++ b/src/tui/view/state/select.rs @@ -53,7 +53,7 @@ impl SelectStateKind for Dynamic {} pub struct Fixed; impl SelectStateKind for Fixed {} -type Callback = Box; +type Callback = Box; impl SelectState where @@ -62,7 +62,7 @@ where /// Set the callback to be called when the user highlights a new item pub fn on_select( mut self, - on_select: impl 'static + Fn(&mut UpdateContext, &Item), + on_select: impl 'static + Fn(&mut UpdateContext, &mut Item), ) -> Self { self.on_select = Some(Box::new(on_select)); self @@ -71,7 +71,7 @@ where /// Set the callback to be called when the user hits enter on an item pub fn on_submit( mut self, - on_submit: impl 'static + Fn(&mut UpdateContext, &Item), + on_submit: impl 'static + Fn(&mut UpdateContext, &mut Item), ) -> Self { self.on_submit = Some(Box::new(on_submit)); self @@ -129,11 +129,19 @@ where let state = self.state.get_mut(); let current = state.selected(); state.select(index); + let new = state.selected(); // If the selection changed, call the callback match &self.on_select { - Some(on_select) if current != state.selected() => { - on_select(context, self.selected_opt().unwrap()); + Some(on_select) if current != new => { + let selected = self + .state + .get_mut() + .selected() + .and_then(|index| self.items.get_mut(index)); + if let Some(selected) = selected { + on_select(context, selected); + } } _ => {} } @@ -280,7 +288,12 @@ where // If we have an on_submit, our parent wants us to handle // submit events so consume it even if nothing is selected if let Some(on_submit) = &self.on_submit { - if let Some(selected) = self.selected_opt() { + let selected = self + .state + .get_mut() + .selected() + .and_then(|index| self.items.get_mut(index)); + if let Some(selected) = selected { on_submit(context, selected); }