Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open bodies in external viewer #414

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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