Skip to content

Commit

Permalink
Open bodies in external viewer
Browse files Browse the repository at this point in the history
Closes #404
  • Loading branch information
LucasPickering committed Nov 3, 2024
1 parent d34ba59 commit f8aa781
Show file tree
Hide file tree
Showing 15 changed files with 284 additions and 105 deletions.
3 changes: 3 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub struct Config {
/// Command to use for in-app editing. If provided, overrides
/// `VISUAL`/`EDITOR` environment variables
pub editor: Option<String>,
/// Command to use to browse response bodies
pub viewer: Option<String>,
#[serde(flatten)]
pub http: HttpEngineConfig,
/// Should templates be rendered inline in the UI, or should we show the
Expand Down Expand Up @@ -111,6 +113,7 @@ impl Default for Config {
fn default() -> Self {
Self {
editor: None,
viewer: None,
http: HttpEngineConfig::default(),
preview_templates: true,
input_bindings: Default::default(),
Expand Down
41 changes: 27 additions & 14 deletions crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use crate::{
http::{BackgroundResponseParser, RequestState, RequestStore},
message::{Message, MessageSender, RequestConfig},
util::{
clear_event_buffer, get_editor_command, save_file, signals,
ResultReported,
clear_event_buffer, delete_temp_file, get_editor_command,
get_viewer_command, save_file, signals, ResultReported,
},
view::{PreviewPrompter, UpdateContext, View},
};
Expand All @@ -46,7 +46,8 @@ use std::{
future::Future,
io::{self, Stdout},
ops::Deref,
path::{Path, PathBuf},
path::PathBuf,
process::Command,
sync::Arc,
time::Duration,
};
Expand All @@ -55,7 +56,7 @@ use tokio::{
sync::mpsc::{self, UnboundedReceiver},
time,
};
use tracing::{debug, error, info, trace};
use tracing::{debug, error, info, info_span, trace};

/// Main controller struct for the TUI. The app uses a React-ish architecture
/// for the view, with a wrapping controller (this struct)
Expand Down Expand Up @@ -236,7 +237,8 @@ impl Tui {
}
Message::CollectionEdit => {
let path = self.collection_file.path().to_owned();
self.edit_file(&path)?
let command = get_editor_command(&path)?;
self.run_command(command)?;
}

Message::CopyRequestUrl(request_config) => {
Expand All @@ -261,8 +263,17 @@ impl Tui {
}

Message::EditFile { path, on_complete } => {
self.edit_file(&path)?;
let command = get_editor_command(&path)?;
self.run_command(command)?;
on_complete(path);
// The callback may queue an event to read the file, so we can't
// delete it yet. Caller is responsible for cleaning up
}
Message::ViewFile { path } => {
let command = get_viewer_command(&path)?;
self.run_command(command)?;
// We don't need to read the contents back so we can clean up
delete_temp_file(&path);
}

Message::Error { error } => self.view.open_modal(error),
Expand Down Expand Up @@ -423,13 +434,11 @@ impl Tui {
Ok(())
}

/// Open a file in the user's configured editor. **This will block the main
/// thread**, because we assume we're opening a terminal editor and
/// therefore should yield the terminal to the editor.
fn edit_file(&mut self, path: &Path) -> anyhow::Result<()> {
let mut command = get_editor_command(path)?;
let error_context =
format!("Error spawning editor with command `{command:?}`");
/// Run a **blocking** subprocess that will take over the terminal. Used
/// for opening an external editor or viewer.
fn run_command(&mut self, mut command: Command) -> anyhow::Result<()> {
let span = info_span!("Running command", ?command).entered();
let error_context = format!("Error spawning command `{command:?}`");

// Block while the editor runs. Useful for terminal editors since
// they'll take over the whole screen. Potentially annoying for GUI
Expand All @@ -438,7 +447,10 @@ impl Tui {
// subprocess took over the terminal and cut it loose if not, or add a
// config field for it.
self.terminal.draw(|frame| {
frame.render_widget("Waiting for editor to close...", frame.area());
frame.render_widget(
"Waiting for subprocess to close...",
frame.area(),
);
})?;

let mut stdout = io::stdout();
Expand All @@ -451,6 +463,7 @@ impl Tui {
// other events were queued behind the event to open the editor).
clear_event_buffer();
crossterm::execute!(stdout, EnterAlternateScreen)?;
drop(span);

// Redraw immediately. The main loop will probably be in the tick
// timeout when we go back to it, so that adds a 250ms delay to
Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ pub enum Message {
#[debug(skip)]
on_complete: Callback<PathBuf>,
},
/// Open a file to be viewed in the user's external viewer
ViewFile { path: PathBuf },

/// An error occurred in some async process and should be shown to the user
Error { error: anyhow::Error },
Expand Down
43 changes: 40 additions & 3 deletions crates/tui/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ use slumber_core::{
util::{doc_link, paths::expand_home, ResultTraced},
};
use std::{
io,
env, io,
ops::Deref,
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use tokio::{fs::OpenOptions, io::AsyncWriteExt, sync::oneshot};
use tracing::{debug, error, info, warn};
use uuid::Uuid;

/// Extension trait for [Result]
pub trait ResultReported<T, E>: Sized {
Expand Down Expand Up @@ -100,6 +101,19 @@ pub async fn signals() -> anyhow::Result<()> {
Ok(())
}

/// Get a path to a random temp file
pub fn temp_file() -> PathBuf {
env::temp_dir().join(format!("slumber-{}", Uuid::new_v4()))
}

/// Delete a file. If it fails, trace and move on because it's not important
/// enough to bother the user
pub fn delete_temp_file(path: &Path) {
let _ = std::fs::remove_file(path)
.with_context(|| format!("Error deleting file {path:?}"))
.traced();
}

/// Save some data to disk. This will:
/// - Ask the user for a path
/// - Attempt to save a *new* file
Expand Down Expand Up @@ -176,8 +190,8 @@ pub async fn save_file(
Ok(())
}

/// Get a command to open the given file in the user's configured editor. Return
/// an error if the user has no editor configured
/// Get a command to open the given file in the user's configured editor.
/// Default editor is `vim`. Return an error if the command couldn't be built.
pub fn get_editor_command(file: &Path) -> anyhow::Result<Command> {
EditorBuilder::new()
// Config field takes priority over environment variables
Expand All @@ -194,6 +208,29 @@ pub fn get_editor_command(file: &Path) -> anyhow::Result<Command> {
})
}

/// Get a command to open the given file in the user's configured file viewer.
/// Default is `less` on Unix, `more` on Windows. Return an error if the command
/// couldn't be built.
pub fn get_viewer_command(file: &Path) -> anyhow::Result<Command> {
// Use a built-in viewer
let default = if cfg!(windows) { "more" } else { "less" };

// Unlike the editor, there is no standard env var to store the viewer, so
// we rely solely on the configuration field.
EditorBuilder::new()
// Config field takes priority over environment variables
.source(TuiContext::get().config.viewer.as_deref())
.source(Some(default))
.path(file)
.build()
.with_context(|| {
format!(
"Error opening viewer; see {}",
doc_link("api/configuration/editor"),
)
})
}

/// Ask the user for some text input and wait for a response. Return `None` if
/// the prompt is closed with no input.
async fn prompt(
Expand Down
29 changes: 21 additions & 8 deletions crates/tui/src/view/component/primary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ use crate::{
draw::{Draw, DrawMetadata, ToStringGenerate},
event::{Child, Event, EventHandler, Update},
state::fixed_select::FixedSelectState,
util::persistence::{Persisted, PersistedLazy},
util::{
persistence::{Persisted, PersistedLazy},
view_text,
},
Component, ViewContext,
},
};
Expand Down Expand Up @@ -262,13 +265,23 @@ impl PrimaryView {
return;
};

let message = match action {
RecipeMenuAction::EditCollection => Message::CollectionEdit,
RecipeMenuAction::CopyUrl => Message::CopyRequestUrl(config),
RecipeMenuAction::CopyBody => Message::CopyRequestBody(config),
RecipeMenuAction::CopyCurl => Message::CopyRequestCurl(config),
};
ViewContext::send_message(message);
match action {
RecipeMenuAction::EditCollection => {
ViewContext::send_message(Message::CollectionEdit)
}
RecipeMenuAction::CopyUrl => {
ViewContext::send_message(Message::CopyRequestUrl(config))
}
RecipeMenuAction::CopyCurl => {
ViewContext::send_message(Message::CopyRequestCurl(config))
}
RecipeMenuAction::CopyBody => {
ViewContext::send_message(Message::CopyRequestBody(config))
}
RecipeMenuAction::ViewBody => {
self.recipe_pane.data().with_body_text(view_text)
}
}
}
}

Expand Down
11 changes: 4 additions & 7 deletions crates/tui/src/view/component/queryable_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use slumber_core::{
http::{content_type::ContentType, query::Query, ResponseBody},
util::{MaybeStr, ResultTraced},
};
use std::cell::Cell;
use std::cell::{Cell, Ref};

/// Display response body as text, with a query box to filter it if the body has
/// been parsed. The query state can be persisted by persisting this entire
Expand Down Expand Up @@ -128,14 +128,11 @@ impl QueryableBody {
}
}

/// Get the exact text that the user sees
pub fn visible_text(&self) -> String {
// State should always be initialized by the time this is called, but
// if it isn't then the user effectively sees nothing
/// Get visible body text
pub fn visible_text(&self) -> Option<Ref<'_, Text>> {
self.state
.get()
.map(|state| state.text.to_string())
.unwrap_or_default()
.map(|state| Ref::map(state, |state| &*state.text))
}
}

Expand Down
23 changes: 20 additions & 3 deletions crates/tui/src/view/component/recipe_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ impl RecipePane {
options,
})
}

/// Execute a function with the recipe's body text, if available. Body text
/// is only available for recipes with non-form bodies.
pub fn with_body_text(&self, f: impl FnOnce(&Text)) {
let Some(state) = self.recipe_state.get() else {
return;
};
let Some(display) = state.data().as_ref() else {
return;
};
let Some(body_text) = display.body_text() else {
return;
};
f(&body_text)
}
}

impl EventHandler for RecipePane {
Expand Down Expand Up @@ -185,10 +200,12 @@ pub enum RecipeMenuAction {
EditCollection,
#[display("Copy URL")]
CopyUrl,
#[display("Copy Body")]
CopyBody,
#[display("Copy as cURL")]
CopyCurl,
#[display("View Body")]
ViewBody,
#[display("Copy Body")]
CopyBody,
}

impl RecipeMenuAction {
Expand All @@ -200,7 +217,7 @@ impl RecipeMenuAction {
if has_body {
&[]
} else {
&[Self::CopyBody]
&[Self::CopyBody, Self::ViewBody]
}
} else {
&[Self::CopyUrl, Self::CopyBody, Self::CopyCurl]
Expand Down
30 changes: 19 additions & 11 deletions crates/tui/src/view/component/recipe_pane/body.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
context::TuiContext,
message::Message,
util::ResultReported,
util::{delete_temp_file, temp_file, ResultReported},
view::{
common::text_window::{ScrollbarMargins, TextWindow, TextWindowProps},
component::recipe_pane::{
Expand All @@ -11,25 +11,25 @@ use crate::{
context::UpdateContext,
draw::{Draw, DrawMetadata},
event::{Child, Event, EventHandler, Update},
state::Identified,
Component, ViewContext,
},
};
use anyhow::Context;
use ratatui::{style::Styled, Frame};
use ratatui::{style::Styled, text::Text, Frame};
use serde::Serialize;
use slumber_config::Action;
use slumber_core::{
collection::{RecipeBody, RecipeId},
http::content_type::ContentType,
template::Template,
util::ResultTraced,
};
use std::{
env, fs,
fs,
ops::Deref,
path::{Path, PathBuf},
};
use tracing::debug;
use uuid::Uuid;

/// Render recipe body. The variant is based on the incoming body type, and
/// determines the representation
Expand Down Expand Up @@ -67,6 +67,18 @@ impl RecipeBodyDisplay {
}
}

/// Get body text. Return `None` for form bodies
pub fn text(
&self,
) -> Option<impl '_ + Deref<Target = Identified<Text<'static>>>> {
match self {
RecipeBodyDisplay::Raw(body) => {
Some(body.data().body.preview().text())
}
RecipeBodyDisplay::Form(_) => None,
}
}

/// If the user has applied a temporary edit to the body, get the override
/// value. Return `None` to use the recipe's stock body.
pub fn override_value(&self) -> Option<RecipeBody> {
Expand Down Expand Up @@ -139,7 +151,7 @@ impl RawBody {
/// the body to a temp file so the editor subprocess can access it. We'll
/// read it back later.
fn open_editor(&mut self) {
let path = env::temp_dir().join(format!("slumber-{}", Uuid::new_v4()));
let path = temp_file();
debug!(?path, "Writing body to file for editing");
let Some(_) =
fs::write(&path, self.body.template().display().as_bytes())
Expand Down Expand Up @@ -180,11 +192,7 @@ impl RawBody {
};

// Clean up after ourselves
let _ = fs::remove_file(path)
.with_context(|| {
format!("Error writing body to file {path:?} for editing")
})
.traced();
delete_temp_file(path);

let Some(template) = body
.parse::<Template>()
Expand Down
Loading

0 comments on commit f8aa781

Please sign in to comment.