Skip to content

Commit

Permalink
Add Copy URL action for recipe
Browse files Browse the repository at this point in the history
This involved refactoring the action menu a lot so it's no longer provided by the text window.
  • Loading branch information
LucasPickering committed Jan 2, 2024
1 parent 15db89e commit 145a693
Show file tree
Hide file tree
Showing 12 changed files with 388 additions and 223 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- 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
- Add Copy URL action, to get the full URL that a request will generate ([#93](https://github.com/LucasPickering/slumber/issues/93))

### Changed

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 33 additions & 32 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,39 @@ version = "0.11.0"
rust-version = "1.74.0"

[dependencies]
anyhow = {version = "^1.0.75", features = ["backtrace"]}
async-trait = "^0.1.73"
chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]}
clap = {version = "^4.4.2", features = ["derive"]}
cli-clipboard = "0.4.0"
crossterm = "^0.27.0"
derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from"]}
dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]}
dirs = "^5.0.1"
equivalent = "^1"
futures = "^0.3.28"
indexmap = {version = "^2.0.1", features = ["serde"]}
itertools = "^0.12.0"
nom = "7.1.3"
notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]}
ratatui = "^0.25.0"
reqwest = {version = "^0.11.20", default-features = false, features = ["rustls-tls"]}
rmp-serde = "^1.1.2"
rusqlite = {version = "^0.30.0", default-features = false, features = ["bundled", "chrono", "uuid"]}
rusqlite_migration = "^1.1.0"
serde = {version = "^1.0.188", features = ["derive"]}
serde_json = {version = "^1.0.107", default-features = false}
serde_json_path = "^0.6.3"
serde_yaml = {version = "^0.9.25", default-features = false}
signal-hook = "^0.3.17"
strum = {version = "^0.25.0", default-features = false, features = ["derive"]}
thiserror = "^1.0.48"
tokio = {version = "^1.32.0", default-features = false, features = ["full"]}
tracing = "^0.1.37"
tracing-subscriber = {version = "^0.3.17", default-features = false, features = ["env-filter", "fmt", "registry"]}
tui-textarea = "^0.4.0"
uuid = {version = "^1.4.1", default-features = false, features = ["serde", "v4"]}
anyhow = {version = "^1.0.75", features = ["backtrace"]}
async-trait = "^0.1.73"
chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]}
clap = {version = "^4.4.2", features = ["derive"]}
cli-clipboard = "0.4.0"
crossterm = "^0.27.0"
derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from"]}
dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]}
dirs = "^5.0.1"
equivalent = "^1"
futures = "^0.3.28"
indexmap = {version = "^2.0.1", features = ["serde"]}
itertools = "^0.12.0"
nom = "7.1.3"
notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]}
ratatui = "^0.25.0"
reqwest = {version = "^0.11.20", default-features = false, features = ["rustls-tls"]}
rmp-serde = "^1.1.2"
rusqlite = {version = "^0.30.0", default-features = false, features = ["bundled", "chrono", "uuid"]}
rusqlite_migration = "^1.1.0"
serde = {version = "^1.0.188", features = ["derive"]}
serde_json = {version = "^1.0.107", default-features = false}
serde_json_path = "^0.6.3"
serde_yaml = {version = "^0.9.25", default-features = false}
signal-hook = "^0.3.17"
strum = {version = "^0.25.0", default-features = false, features = ["derive"]}
thiserror = "^1.0.48"
tokio = {version = "^1.32.0", default-features = false, features = ["full"]}
tracing = "^0.1.37"
tracing-subscriber = {version = "^0.3.17", default-features = false, features = ["env-filter", "fmt", "registry"]}
tui-textarea = "^0.4.0"
url = "*" # Use the version from reqwest
uuid = {version = "^1.4.1", default-features = false, features = ["serde", "v4"]}

[dev-dependencies]
factori = "1.1.0"
Expand Down
1 change: 1 addition & 0 deletions src/tui/view/common.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Common reusable components for building the view. Children here should be
//! generic, i.e. usable in more than a single narrow context.
pub mod actions;
pub mod list;
pub mod modal;
pub mod table;
Expand Down
76 changes: 76 additions & 0 deletions src/tui/view/common/actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use crate::tui::view::{
common::{list::List, modal::Modal},
component::Component,
draw::{Draw, Generate},
event::{Event, EventHandler, UpdateContext},
state::select::{Fixed, FixedSelect, SelectState},
};
use ratatui::{
layout::{Constraint, Rect},
text::Span,
widgets::ListState,
Frame,
};

/// Modal to list and trigger arbitrary actions. The list of available actions
/// is defined by the generic parameter
#[derive(Debug)]
pub struct ActionsModal<T: FixedSelect> {
actions: Component<SelectState<Fixed, T, ListState>>,
}

impl<T: FixedSelect> Default for ActionsModal<T> {
fn default() -> Self {
let wrapper = move |context: &mut UpdateContext, action: &mut T| {
// Close the modal *first*, so the parent can handle the callback
// event. Jank but it works
context.queue_event(Event::CloseModal);
context.queue_event(Event::other(*action));
};

Self {
actions: SelectState::fixed().on_submit(wrapper).into(),
}
}
}

impl<T> Modal for ActionsModal<T>
where
T: FixedSelect,
ActionsModal<T>: Draw,
{
fn title(&self) -> &str {
"Actions"
}

fn dimensions(&self) -> (Constraint, Constraint) {
(
Constraint::Length(30),
Constraint::Length(T::iter().count() as u16),
)
}
}

impl<T: FixedSelect> EventHandler for ActionsModal<T> {
fn children(&mut self) -> Vec<Component<&mut dyn EventHandler>> {
vec![self.actions.as_child()]
}
}

impl<T> Draw for ActionsModal<T>
where
T: 'static + FixedSelect,
for<'a> &'a T: Generate<Output<'a> = Span<'a>>,
{
fn draw(&self, frame: &mut Frame, _: (), area: Rect) {
let list = List {
block: None,
list: &self.actions,
};
frame.render_stateful_widget(
list.generate(),
area,
&mut self.actions.state_mut(),
);
}
}
102 changes: 56 additions & 46 deletions src/tui/view/common/template_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use ratatui::{
widgets::{Paragraph, Widget},
};
use std::{
fmt::{self, Display, Formatter},
mem,
sync::{Arc, OnceLock},
};
Expand Down Expand Up @@ -58,6 +57,39 @@ impl TemplatePreview {
Self::Disabled { template }
}
}

/// Convert rendered template to plain text for the purposes of copypasta.
/// If the render isn't ready, return the raw template. If any chunk failed,
/// it will be left empty.
pub fn to_copy_text(&self) -> String {
// If the preview render is ready, show it. Otherwise fall back to raw
match self {
TemplatePreview::Disabled { template } => template.to_string(),
TemplatePreview::Enabled { template, chunks } => match chunks.get()
{
// The goal here is to minimize "wonky" output, so loading and
// errors are replaced with minimal placeholders
Some(chunks) => {
let mut s = String::new();
for chunk in chunks {
let content = match chunk {
TemplateChunk::Raw(span) => {
template.substring(*span)
}
TemplateChunk::Rendered { value, .. } => {
value.as_str()
}
TemplateChunk::Error(_) => "",
};
s.push_str(content);
}
s
}
// Preview still rendering
None => template.to_string(),
},
}
}
}

impl Generate for &TemplatePreview {
Expand Down Expand Up @@ -90,28 +122,6 @@ impl Widget for &TemplatePreview {
}
}

/// Convert to raw text. Useful for copypasta
impl Display for TemplatePreview {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
TemplatePreview::Disabled { template } => write!(f, "{template}"),
// If the preview render is ready, show it. Otherwise fall back
// to the raw
TemplatePreview::Enabled { template, chunks } => match chunks.get()
{
Some(chunks) => {
for chunk in chunks {
write!(f, "{}", get_chunk_text(template, chunk))?;
}
Ok(())
}
// Preview still rendering
None => write!(f, "{template}"),
},
}
}
}

/// A helper for stitching rendered template chunks into ratatui `Text`. This
/// requires some effort because ratatui *loves* line breaks, so we have to
/// very manually construct the text to make sure the structure reflects the
Expand Down Expand Up @@ -139,7 +149,7 @@ impl<'a> TextStitcher<'a> {
// manually split the lines
let mut stitcher = Self::default();
for chunk in chunks {
let chunk_text = get_chunk_text(template, chunk);
let chunk_text = Self::get_chunk_text(template, chunk);
let style = match &chunk {
TemplateChunk::Raw(_) => Style::default(),
TemplateChunk::Rendered { .. } => theme.template_preview_text,
Expand Down Expand Up @@ -168,6 +178,28 @@ impl<'a> TextStitcher<'a> {
}
}

/// Get the renderable text for a chunk of a template
fn get_chunk_text(
template: &'a Template,
chunk: &'a TemplateChunk,
) -> &'a str {
match chunk {
TemplateChunk::Raw(span) => template.substring(*span),
TemplateChunk::Rendered { value, sensitive } => {
if *sensitive {
// Hide sensitive values. Ratatui has a Masked type, but
// it complicates the string ownership a lot and also
// exposes the length of the sensitive text
"<sensitive>"
} else {
value.as_str()
}
}
// There's no good way to render the entire error inline
TemplateChunk::Error(_) => "Error",
}
}

fn add_span(&mut self, text: &'a str, style: Style) {
if !text.is_empty() {
self.next_line.push(Span::styled(text, style));
Expand All @@ -189,28 +221,6 @@ impl<'a> TextStitcher<'a> {
}
}

/// Get the plain text for a chunk of a template
fn get_chunk_text<'a>(
template: &'a Template,
chunk: &'a TemplateChunk,
) -> &'a str {
match chunk {
TemplateChunk::Raw(span) => template.substring(*span),
TemplateChunk::Rendered { value, sensitive } => {
if *sensitive {
// Hide sensitive values. Ratatui has a Masked type, but
// it complicates the string ownership a lot and also
// exposes the length of the sensitive text
"<sensitive>"
} else {
value.as_str()
}
}
// There's no good way to render the entire error inline
TemplateChunk::Error(_) => "Error",
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading

0 comments on commit 145a693

Please sign in to comment.