From 798ca9f4f9958bf081c9f55949eacdc749aa091b Mon Sep 17 00:00:00 2001 From: Benoit de Chezelles Date: Fri, 19 May 2023 17:30:49 +0200 Subject: [PATCH] Re-enable clipboard with on-demand command escape hatch --- Cargo.toml | 1 - src/app.rs | 42 +++++++++++------------------------ src/clip.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ src/flatjson.rs | 1 - src/main.rs | 26 ++++++++++++++++++++++ src/options.rs | 5 +++++ 6 files changed, 102 insertions(+), 31 deletions(-) create mode 100644 src/clip.rs diff --git a/Cargo.toml b/Cargo.toml index c2a8760..1204f4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,4 +34,3 @@ indoc = "1.0" [features] default = ["clipboard"] clipboard = ["dep:clipboard"] - diff --git a/src/app.rs b/src/app.rs index 2d4b8ab..970f2e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,6 @@ -#[cfg(feature = "clipboard")] -use std::error::Error; use std::io; use std::io::Write; -#[cfg(feature = "clipboard")] -use clipboard::{ClipboardContext, ClipboardProvider}; use rustyline::error::ReadlineError; use rustyline::Editor; use termion::event::Key; @@ -12,17 +8,16 @@ use termion::event::MouseButton::{Left, WheelDown, WheelUp}; use termion::event::MouseEvent::Press; use termion::screen::{ToAlternateScreen, ToMainScreen}; +use crate::clip::{ClipProvider, ClipError}; use crate::flatjson; use crate::input::TuiEvent; use crate::input::TuiEvent::{KeyEvent, MouseEvent, WinChEvent}; -#[cfg(feature = "clipboard")] use crate::lineprinter::JS_IDENTIFIER; use crate::options::{DataFormat, Opt}; use crate::screenwriter::{MessageSeverity, ScreenWriter}; use crate::search::{JumpDirection, SearchDirection, SearchState}; use crate::types::TTYDimensions; use crate::viewer::{Action, JsonViewer}; -#[cfg(feature = "clipboard")] use crate::viewer::Mode; pub struct App { @@ -33,8 +28,7 @@ pub struct App { input_filename: String, search_state: SearchState, message: Option<(String, MessageSeverity)>, - #[cfg(feature = "clipboard")] - clipboard_context: Result>, + clip_provider: Result, } // State to determine how to process the next event input. @@ -49,13 +43,11 @@ pub struct App { #[derive(PartialEq)] enum InputState { Default, - #[cfg(feature = "clipboard")] PendingYCommand, PendingZCommand, } // Various things that can be copied -#[cfg(feature = "clipboard")] enum CopyTarget { PrettyPrintedValue, OneLineValue, @@ -83,6 +75,7 @@ impl App { data: String, data_format: DataFormat, input_filename: String, + clip_provider: Result, stdout: Box, ) -> Result { let flatjson = match Self::parse_input(data, data_format) { @@ -104,8 +97,7 @@ impl App { input_filename, search_state: SearchState::empty(), message: None, - #[cfg(feature = "clipboard")] - clipboard_context: ClipboardProvider::new(), + clip_provider, }) } @@ -161,7 +153,6 @@ impl App { } // Handle special input states: // y commands: - #[cfg(feature = "clipboard")] event if self.input_state == InputState::PendingYCommand => { let copy_target = match event { KeyEvent(Key::Char('y')) => Some(CopyTarget::PrettyPrintedValue), @@ -217,9 +208,8 @@ impl App { None } } - #[cfg(feature = "clipboard")] KeyEvent(Key::Char('y')) => { - match &self.clipboard_context { + match &self.clip_provider { Ok(_) => { self.input_state = InputState::PendingYCommand; self.input_buffer.clear(); @@ -230,7 +220,6 @@ impl App { self.set_error_message(msg); } } - None } KeyEvent(Key::Char('z')) => { @@ -633,16 +622,12 @@ impl App { let _ = write!(self.screen_writer.stdout, "{}", ToAlternateScreen); } - #[cfg(feature = "clipboard")] fn copy_content(&mut self, copy_target: CopyTarget) { - // Checked when the user first hits 'y'. - let clipboard = self.clipboard_context.as_mut().unwrap(); - let json = &self.viewer.flatjson.1; let focused_row_index = self.viewer.focused_row; let focused_row = &self.viewer.flatjson[focused_row_index]; - let (content_desc, content) = match copy_target { + let (desc, content) = match copy_target { CopyTarget::PrettyPrintedValue if focused_row.is_container() => ( "pretty-printed value", self.viewer @@ -674,7 +659,7 @@ impl App { } } ct @ (CopyTarget::DotPath | CopyTarget::BracketPath | CopyTarget::QueryPath) => { - let (content_desc, path_type) = match ct { + let (desc, path_type) = match ct { CopyTarget::DotPath => ("path", flatjson::PathType::Dot), CopyTarget::BracketPath => ("bracketed path", flatjson::PathType::Bracket), CopyTarget::QueryPath => ("query path", flatjson::PathType::Query), @@ -686,7 +671,7 @@ impl App { .flatjson .build_path_to_node(path_type, focused_row_index) { - Ok(path) => (content_desc, path), + Ok(path) => (desc, path), Err(err) => { self.set_error_message(err); return; @@ -695,13 +680,12 @@ impl App { } }; - if let Err(err) = clipboard.set_contents(content) { - self.set_error_message(format!( - "Unable to copy {} to clipboard: {}", - content_desc, err - )); + // Checked when the user first hits 'y'. + let clip = self.clip_provider.as_mut().unwrap(); + if let Err(err) = clip.copy(content) { + self.set_error_message(format!("Unable to copy {} to clipboard: {}", desc, err)); } else { - self.set_info_message(format!("Copied {} to clipboard", content_desc)); + self.set_info_message(format!("Copied {} to clipboard", desc)); } } } diff --git a/src/clip.rs b/src/clip.rs new file mode 100644 index 0000000..08c5b19 --- /dev/null +++ b/src/clip.rs @@ -0,0 +1,58 @@ +use std::fmt; +use std::io::{self, Write}; +use std::process::{Command, ExitStatus, Stdio}; + +#[cfg(feature = "clipboard")] +use clipboard::{ClipboardContext, ClipboardProvider}; + +pub enum ClipProvider { + CommandClipboard(String), + #[cfg(feature = "clipboard")] + SystemClipboard(ClipboardContext), +} + +#[derive(Debug)] +pub struct ClipError(pub String); + +impl fmt::Display for ClipError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ClipProvider { + pub fn copy(&mut self, content: String) -> Result<(), ClipError> { + match self { + Self::CommandClipboard(shell_command) => { + let status = send_content_to_shell_command(content, shell_command) + .map_err(|err| ClipError(err.to_string()))?; + match status.code() { + Some(code) if !status.success() => { + Err(ClipError(std::format!("Command failed with status code {}", code))) + }, + Some(_) => Ok(()), + None => Err(ClipError("Command terminated by signal".to_string())), + } + }, + + #[cfg(feature = "clipboard")] + Self::SystemClipboard(context) => { + context.set_contents(content) + .map_err(|err| ClipError(err.to_string())) + }, + } + } +} + +fn send_content_to_shell_command(content: String, shell_command: &str) -> io::Result { + let mut child = Command::new("sh") + .args(&["-c", shell_command]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let mut stdin = child.stdin.take().expect("Failed to grab stdin"); + stdin.write_all(content.as_bytes())?; + drop(stdin); + child.wait() +} diff --git a/src/flatjson.rs b/src/flatjson.rs index c234776..506ed0e 100644 --- a/src/flatjson.rs +++ b/src/flatjson.rs @@ -305,7 +305,6 @@ impl FlatJson { // there are some subtle enough differences, and the code isn't that // complicated, that I don't think it's worth it to try to have them // share an implementation. - #[cfg(feature = "clipboard")] pub fn pretty_printed_value(&self, value_index: Index) -> Result { if self[value_index].is_primitive() { return Ok(self.1[self[value_index].range.clone()].to_string()); diff --git a/src/main.rs b/src/main.rs index 7957542..cfbe0db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ use std::io; use std::io::Read; use std::path::PathBuf; +#[cfg(feature = "clipboard")] +use clipboard as sys_clipboard; + use clap::Parser; use termion::cursor::HideCursor; use termion::input::MouseTerminal; @@ -17,6 +20,7 @@ use termion::raw::IntoRawMode; use termion::screen::AlternateScreen; mod app; +mod clip; mod flatjson; mod highlighting; mod input; @@ -34,6 +38,7 @@ mod yamlparser; use app::App; use options::{DataFormat, Opt}; +use crate::clip::{ClipProvider, ClipError}; fn main() { let opt = Opt::parse(); @@ -63,11 +68,19 @@ fn main() { io::stdout().into_raw_mode().unwrap(), ))); + let clipboard_provider = match opt.clipboard_command { + Some(ref command) => { + Ok(ClipProvider::CommandClipboard(command.to_string())) + }, + None => default_clip_provider(), + }; + let mut app = match App::new( &opt, input_string, data_format, input_filename, + clipboard_provider, Box::new(stdout), ) { Ok(jl) => jl, @@ -80,6 +93,19 @@ fn main() { app.run(Box::new(input::get_input())); } +#[cfg(feature = "clipboard")] +fn default_clip_provider() -> Result { + match sys_clipboard::ClipboardProvider::new() { + Ok(clip) => Ok(ClipProvider::SystemClipboard(clip)), + Err(err) => Err(ClipError(err.to_string())), + } +} + +#[cfg(not(feature = "clipboard"))] +fn default_clip_provider() -> Result { + Err(ClipError("No clipboard support, use --clipboard-cmd to set one".to_string())) +} + fn print_pretty_printed_input(input: String, data_format: DataFormat) { // Don't try to pretty print YAML input; just pass it through. if data_format == DataFormat::Yaml { diff --git a/src/options.rs b/src/options.rs index fd3a1eb..808e4e2 100644 --- a/src/options.rs +++ b/src/options.rs @@ -38,6 +38,11 @@ pub struct Opt { #[clap(long = "scrolloff", default_value_t = 3)] pub scrolloff: u16, + /// Shell command to run for copy actions instead of the default clipboard. + /// The copy content will be sent into the command's stdin. + #[clap(long = "clipboard-cmd")] + pub clipboard_command: Option, + /// Parse input as JSON, regardless of file extension. #[clap(long = "json", group = "data-format", display_order = 1000)] pub json: bool,