From 04824fd2070caa03fa2a61f264d71ed13748bac9 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sun, 3 Nov 2024 10:32:43 -0500 Subject: [PATCH] Open bodies in external viewer Closes #404 --- crates/config/src/lib.rs | 3 + crates/tui/src/lib.rs | 41 +++++++---- crates/tui/src/message.rs | 2 + crates/tui/src/util.rs | 43 ++++++++++- crates/tui/src/view/component/primary.rs | 29 +++++--- .../tui/src/view/component/queryable_body.rs | 11 ++- crates/tui/src/view/component/recipe_pane.rs | 23 +++++- .../src/view/component/recipe_pane/body.rs | 30 +++++--- .../src/view/component/recipe_pane/recipe.rs | 12 +++- crates/tui/src/view/component/request_view.rs | 14 +++- .../tui/src/view/component/response_view.rs | 29 +++++--- crates/tui/src/view/util.rs | 39 +++++++++- docs/src/api/configuration/editor.md | 22 +++++- docs/src/api/configuration/index.md | 19 ++--- docs/src/api/configuration/input_bindings.md | 72 +++++++++---------- 15 files changed, 284 insertions(+), 105 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 15287b5f..bbf558c0 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -42,6 +42,8 @@ pub struct Config { /// Command to use for in-app editing. If provided, overrides /// `VISUAL`/`EDITOR` environment variables pub editor: Option, + /// Command to use to browse response bodies + pub viewer: Option, #[serde(flatten)] pub http: HttpEngineConfig, /// Should templates be rendered inline in the UI, or should we show the @@ -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(), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 03566d86..299d92f6 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -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}, }; @@ -46,7 +46,8 @@ use std::{ future::Future, io::{self, Stdout}, ops::Deref, - path::{Path, PathBuf}, + path::PathBuf, + process::Command, sync::Arc, time::Duration, }; @@ -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) @@ -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) => { @@ -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), @@ -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 @@ -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(); @@ -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 diff --git a/crates/tui/src/message.rs b/crates/tui/src/message.rs index b2a7f49f..988e5fdb 100644 --- a/crates/tui/src/message.rs +++ b/crates/tui/src/message.rs @@ -86,6 +86,8 @@ pub enum Message { #[debug(skip)] on_complete: Callback, }, + /// 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 }, diff --git a/crates/tui/src/util.rs b/crates/tui/src/util.rs index 463b6e56..549c05a0 100644 --- a/crates/tui/src/util.rs +++ b/crates/tui/src/util.rs @@ -13,7 +13,7 @@ use slumber_core::{ util::{doc_link, paths::expand_home, ResultTraced}, }; use std::{ - io, + env, io, ops::Deref, path::{Path, PathBuf}, process::Command, @@ -21,6 +21,7 @@ use std::{ }; use tokio::{fs::OpenOptions, io::AsyncWriteExt, sync::oneshot}; use tracing::{debug, error, info, warn}; +use uuid::Uuid; /// Extension trait for [Result] pub trait ResultReported: Sized { @@ -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 @@ -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 { EditorBuilder::new() // Config field takes priority over environment variables @@ -194,6 +208,29 @@ pub fn get_editor_command(file: &Path) -> anyhow::Result { }) } +/// 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 { + // 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( diff --git a/crates/tui/src/view/component/primary.rs b/crates/tui/src/view/component/primary.rs index f1dc0cc9..48879902 100644 --- a/crates/tui/src/view/component/primary.rs +++ b/crates/tui/src/view/component/primary.rs @@ -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, }, }; @@ -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) + } + } } } diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index 2474f9ab..3d503b69 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -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 @@ -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> { self.state .get() - .map(|state| state.text.to_string()) - .unwrap_or_default() + .map(|state| Ref::map(state, |state| &*state.text)) } } diff --git a/crates/tui/src/view/component/recipe_pane.rs b/crates/tui/src/view/component/recipe_pane.rs index 8c713e13..23f05300 100644 --- a/crates/tui/src/view/component/recipe_pane.rs +++ b/crates/tui/src/view/component/recipe_pane.rs @@ -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 { @@ -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 { @@ -200,7 +217,7 @@ impl RecipeMenuAction { if has_body { &[] } else { - &[Self::CopyBody] + &[Self::CopyBody, Self::ViewBody] } } else { &[Self::CopyUrl, Self::CopyBody, Self::CopyCurl] diff --git a/crates/tui/src/view/component/recipe_pane/body.rs b/crates/tui/src/view/component/recipe_pane/body.rs index b53b150b..d25ae94c 100644 --- a/crates/tui/src/view/component/recipe_pane/body.rs +++ b/crates/tui/src/view/component/recipe_pane/body.rs @@ -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::{ @@ -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 @@ -67,6 +67,18 @@ impl RecipeBodyDisplay { } } + /// Get body text. Return `None` for form bodies + pub fn text( + &self, + ) -> Option>>> { + 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 { @@ -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()) @@ -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::