diff --git a/Cargo.lock b/Cargo.lock index dff4502909952..5d6aa0d2881bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11484,6 +11484,7 @@ version = "0.1.0" dependencies = [ "anyhow", "atty", + "base64 0.22.1", "chrono", "console", "crossterm 0.27.0", @@ -11501,6 +11502,7 @@ dependencies = [ "turbopath", "turborepo-ci", "turborepo-vt100", + "which", "winapi", ] diff --git a/crates/turborepo-ui/Cargo.toml b/crates/turborepo-ui/Cargo.toml index 14b7ce8804ead..fe9d70d14b8f4 100644 --- a/crates/turborepo-ui/Cargo.toml +++ b/crates/turborepo-ui/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] atty = { workspace = true } +base64 = "0.22" chrono = { workspace = true } console = { workspace = true } crossterm = "0.27.0" @@ -30,4 +31,5 @@ tui-term = { workspace = true } turbopath = { workspace = true } turborepo-ci = { workspace = true } turborepo-vt100 = { workspace = true } +which = { workspace = true } winapi = "0.3.9" diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index dce9898641b2b..1cafb9940e5f3 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -312,6 +312,17 @@ impl App { Ok(()) } + + pub fn copy_selection(&self) { + let task = self + .tasks + .get(&self.active_task()) + .expect("active task should exist"); + let Some(text) = task.copy_selection() else { + return; + }; + super::copy_to_clipboard(&text); + } } impl App { @@ -518,6 +529,9 @@ fn update( Event::Mouse(m) => { app.handle_mouse(m)?; } + Event::CopySelection => { + app.copy_selection(); + } } Ok(None) } diff --git a/crates/turborepo-ui/src/tui/clipboard.rs b/crates/turborepo-ui/src/tui/clipboard.rs new file mode 100644 index 0000000000000..d9c5caa769c8f --- /dev/null +++ b/crates/turborepo-ui/src/tui/clipboard.rs @@ -0,0 +1,113 @@ +// Inspired by https://github.com/pvolok/mprocs/blob/master/src/clipboard.rs +use std::process::Stdio; + +use base64::Engine; +use which::which; + +pub fn copy_to_clipboard(s: &str) { + match copy_impl(s, &PROVIDER) { + Ok(()) => (), + Err(err) => tracing::debug!("Unable to copy: {}", err.to_string()), + } +} + +#[allow(dead_code)] +enum Provider { + OSC52, + Exec(&'static str, Vec<&'static str>), + #[cfg(windows)] + Win, + NoOp, +} + +#[cfg(windows)] +fn detect_copy_provider() -> Provider { + Provider::Win +} + +#[cfg(target_os = "macos")] +fn detect_copy_provider() -> Provider { + if let Some(provider) = check_prog("pbcopy", &[]) { + return provider; + } + Provider::OSC52 +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn detect_copy_provider() -> Provider { + // Wayland + if std::env::var("WAYLAND_DISPLAY").is_ok() { + if let Some(provider) = check_prog("wl-copy", &["--type", "text/plain"]) { + return provider; + } + } + // X11 + if std::env::var("DISPLAY").is_ok() { + if let Some(provider) = check_prog("xclip", &["-i", "-selection", "clipboard"]) { + return provider; + } + if let Some(provider) = check_prog("xsel", &["-i", "-b"]) { + return provider; + } + } + // Termux + if let Some(provider) = check_prog("termux-clipboard-set", &[]) { + return provider; + } + // Tmux + if std::env::var("TMUX").is_ok() { + if let Some(provider) = check_prog("tmux", &["load-buffer", "-"]) { + return provider; + } + } + + Provider::OSC52 +} + +#[allow(dead_code)] +fn check_prog(cmd: &'static str, args: &[&'static str]) -> Option { + if which(cmd).is_ok() { + Some(Provider::Exec(cmd, args.to_vec())) + } else { + None + } +} + +fn copy_impl(s: &str, provider: &Provider) -> std::io::Result<()> { + match provider { + Provider::OSC52 => { + let mut stdout = std::io::stdout().lock(); + use std::io::Write; + write!( + &mut stdout, + "\x1b]52;;{}\x07", + base64::engine::general_purpose::STANDARD.encode(s) + )?; + } + + Provider::Exec(prog, args) => { + let mut child = std::process::Command::new(prog) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + std::io::Write::write_all(&mut child.stdin.as_ref().unwrap(), s.as_bytes())?; + child.wait()?; + } + + #[cfg(windows)] + Provider::Win => { + clipboard_win::set_clipboard_string(s).map_err(|e| anyhow::Error::msg(e.to_string()))? + } + + Provider::NoOp => (), + }; + + Ok(()) +} + +lazy_static::lazy_static! { + static ref PROVIDER: Provider = detect_copy_provider(); +} diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index 3e08a6d3d91f0..e5477441ae4eb 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -35,6 +35,7 @@ pub enum Event { tasks: Vec, }, Mouse(crossterm::event::MouseEvent), + CopySelection, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index e4dcfab163e71..212b295787577 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -1,6 +1,7 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use tracing::debug; use super::{app::LayoutSections, event::Event, Error}; @@ -19,7 +20,9 @@ pub fn input(options: InputOptions) -> Result, Error> { // poll with 0 duration will only return true if event::read won't need to wait // for input if crossterm::event::poll(Duration::from_millis(0))? { - match crossterm::event::read()? { + let event = crossterm::event::read()?; + debug!("Received event: {event:?}"); + match event { crossterm::event::Event::Key(k) => Ok(translate_key_event(&focus, k)), crossterm::event::Event::Mouse(m) => match m.kind { crossterm::event::MouseEventKind::ScrollDown => Ok(Some(Event::ScrollDown)), @@ -28,6 +31,9 @@ pub fn input(options: InputOptions) -> Result, Error> { | crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { Ok(Some(Event::Mouse(m))) } + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => { + Ok(Some(Event::CopySelection)) + } _ => Ok(None), }, _ => Ok(None), @@ -50,6 +56,9 @@ fn translate_key_event(interact: &LayoutSections, key_event: KeyEvent) -> Option KeyCode::Char('c') if key_event.modifiers == crossterm::event::KeyModifiers::CONTROL => { ctrl_c() } + KeyCode::Char('c') if key_event.modifiers == crossterm::event::KeyModifiers::SUPER => { + return Some(Event::CopySelection); + } // Interactive branches KeyCode::Char('z') if matches!(interact, LayoutSections::Pane) diff --git a/crates/turborepo-ui/src/tui/mod.rs b/crates/turborepo-ui/src/tui/mod.rs index 5bcb487873a69..93a016ecc04e0 100644 --- a/crates/turborepo-ui/src/tui/mod.rs +++ b/crates/turborepo-ui/src/tui/mod.rs @@ -1,4 +1,5 @@ mod app; +mod clipboard; pub mod event; mod handle; mod input; @@ -9,6 +10,7 @@ mod task; mod term_output; pub use app::{run_app, terminal_big_enough}; +use clipboard::copy_to_clipboard; use event::{Event, TaskResult}; pub use handle::{AppReceiver, AppSender, TuiTask}; use input::{input, InputOptions}; diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 16c65b805f86f..3e33c576f5bd8 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -138,4 +138,8 @@ impl TerminalOutput { } Ok(()) } + + pub fn copy_selection(&self) -> Option { + self.parser.screen().selected_text() + } }