Skip to content
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
23 changes: 1 addition & 22 deletions src/session.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::adapters;
use crate::config::AppConfig;
Expand Down Expand Up @@ -558,7 +556,7 @@ fn cmd_session_share(
};

if copy_url {
copy_to_clipboard(&url)?;
crate::utils::copy_to_clipboard(&url)?;
}
if open {
crate::utils::open_url_in_default_browser(&url)?;
Expand Down Expand Up @@ -923,25 +921,6 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
Ok(())
}

fn copy_to_clipboard(text: &str) -> Result<()> {
let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
("pbcopy", &[])
} else if cfg!(target_os = "windows") {
("clip.exe", &[])
} else {
("xclip", &["-selection", "clipboard"])
};
let mut child = Command::new(program).args(args).stdin(Stdio::piped()).spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(text.as_bytes())?;
}
let status = child.wait()?;
if !status.success() {
anyhow::bail!("{program} exited with status {status}");
}
Ok(())
}

fn read_tldr_file(path: &Path) -> Option<String> {
match fs::read_to_string(path) {
Ok(content) => {
Expand Down
28 changes: 6 additions & 22 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
#[cfg(test)]
use std::process::Command;
use std::sync::mpsc;
use std::time::{Duration, Instant};

Expand Down Expand Up @@ -2507,26 +2507,10 @@ impl App {
}

fn copy_to_clipboard(&mut self, text: &str) {
let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
("pbcopy", &[])
} else if cfg!(target_os = "windows") {
("clip.exe", &[])
} else {
("xclip", &["-selection", "clipboard"])
};

match Command::new(cmd).args(args).stdin(Stdio::piped()).spawn() {
Ok(mut child) => {
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
self.status_message = Some("Copied to clipboard".to_string());
}
Err(_) => {
self.status_message = Some(format!("Failed to copy ({cmd} not found)"));
}
}
self.status_message = Some(match crate::utils::copy_to_clipboard(text) {
Ok(()) => "Copied to clipboard".to_string(),
Err(error) => error.to_string(),
});
}

fn start_export(&mut self) {
Expand Down
112 changes: 111 additions & 1 deletion src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::process::Command;
use std::io::Write as _;
use std::process::{Command, Stdio};

pub(crate) fn open_url_in_default_browser(url: &str) -> anyhow::Result<()> {
let (program, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
Expand All @@ -15,6 +16,60 @@ pub(crate) fn open_url_in_default_browser(url: &str) -> anyhow::Result<()> {
Ok(())
}

pub(crate) fn clipboard_candidates() -> &'static [(&'static str, &'static [&'static str])] {
if cfg!(target_os = "macos") {
&[("pbcopy", &[])]
} else if cfg!(target_os = "windows") {
&[("clip.exe", &[])]
} else {
&[
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("xsel", &["--clipboard", "--input"]),
]
}
}

fn try_clipboard_candidates(candidates: &[(&str, &[&str])], text: &str) -> anyhow::Result<()> {
let mut last_error = None;
for (program, args) in candidates {
let mut child = match Command::new(program).args(*args).stdin(Stdio::piped()).spawn() {
Ok(child) => child,
Err(error) => {
last_error = Some(error.into());
continue;
}
};
let write_error = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(text.as_bytes()).err().map(anyhow::Error::from),
None => Some(anyhow::anyhow!("{program} stdin unavailable")),
};
let status = child.wait();
if let Some(error) = write_error {
last_error = Some(anyhow::anyhow!("{program} stdin write failed: {error}"));
continue;
}
match status {
Ok(status) if status.success() => return Ok(()),
Ok(status) => {
last_error = Some(anyhow::anyhow!("{program} exited with status {status}"));
}
Err(error) => {
last_error = Some(anyhow::anyhow!("{program} wait failed: {error}"));
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no clipboard utility found")))
}

pub(crate) fn copy_to_clipboard(text: &str) -> anyhow::Result<()> {
try_clipboard_candidates(clipboard_candidates(), text).map_err(|error| {
anyhow::anyhow!(
"Failed to copy to clipboard (install wl-clipboard, xclip, or xsel): {error}"
)
})
}

pub(crate) fn format_age(started_at: i64) -> String {
let now = chrono::Utc::now().timestamp_millis();
let diff_hours = (now - started_at) / (1000 * 3600);
Expand Down Expand Up @@ -113,6 +168,61 @@ fn is_noise_first_message(content: &str) -> bool {
mod tests {
use super::*;

#[test]
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn clipboard_candidates_fall_back_wl_copy_then_xclip_then_xsel_on_linux() {
let candidates = clipboard_candidates();

assert_eq!(
candidates,
&[
("wl-copy", &[][..]),
("xclip", &["-selection", "clipboard"][..]),
("xsel", &["--clipboard", "--input"][..]),
]
);
}

#[test]
#[cfg(unix)]
fn clipboard_candidates_fall_through_after_spawn_failure() {
let candidates: &[(&str, &[&str])] = &[
("recall-test-nonexistent-clipboard-tool-xyz", &[]),
("sh", &["-c", "cat >/dev/null; exit 0"]),
];

assert!(try_clipboard_candidates(candidates, "hello").is_ok());
}

#[test]
#[cfg(unix)]
fn clipboard_candidates_fall_through_after_nonzero_exit() {
let candidates: &[(&str, &[&str])] =
&[("sh", &["-c", "cat >/dev/null; exit 1"]), ("sh", &["-c", "cat >/dev/null; exit 0"])];

assert!(try_clipboard_candidates(candidates, "hello").is_ok());
}

#[test]
#[cfg(unix)]
fn clipboard_candidates_reject_partial_stdin_write() {
let candidates: &[(&str, &[&str])] = &[
("sh", &["-c", "dd bs=1 count=1 of=/dev/null 2>/dev/null"]),
("sh", &["-c", "cat >/dev/null; exit 1"]),
];
let text = "x".repeat(1024 * 1024);

assert!(try_clipboard_candidates(candidates, &text).is_err());
}

#[test]
#[cfg(unix)]
fn clipboard_candidates_error_when_all_fail() {
let candidates: &[(&str, &[&str])] = &[("sh", &["-c", "cat >/dev/null; exit 1"])];

assert!(try_clipboard_candidates(candidates, "hello").is_err());
}

#[test]
fn title_empty_input_returns_untitled() {
assert_eq!(title_from_user_messages(&[]), "Untitled");
Expand Down