diff --git a/AGENTS.md b/AGENTS.md index 536394d..8130ac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,5 +5,6 @@ This repository is a Rust CLI/TUI application named `plinks`. Key context: - The crate version lives in `Cargo.toml`. - Project data is stored in a checked-in `project-links.toml` file. Its config schema version is `1` and code should continue to read/write that format unless explicitly changing the schema. -- The standard verification step is `cargo test`. +- After making changes, always run the full linting process with `./scripts/run-linters.sh`. +- The standard test step is `cargo test`. - Arch packaging artifacts are generated by `./scripts/build-arch-package.sh`, which reads the package version from `Cargo.toml`. diff --git a/Cargo.lock b/Cargo.lock index 7e5dee1..9795b38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -132,6 +138,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "clap" version = "4.6.0" @@ -198,7 +210,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -268,6 +280,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.15.0" @@ -296,6 +314,17 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -545,6 +574,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -617,6 +652,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -698,13 +745,14 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "plinks" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "assert_cmd", "clap", "crossterm", "open", + "portable-pty", "predicates", "ratatui", "serde", @@ -713,6 +761,27 @@ dependencies = [ "url", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -792,7 +861,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm", @@ -813,7 +882,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -851,7 +920,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -864,7 +933,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -947,6 +1016,33 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1065,6 +1161,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1242,7 +1358,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1367,6 +1483,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1425,7 +1550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 32a22a6..51e9370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "plinks" -version = "0.1.6" +version = "0.1.7" edition = "2024" description = "Project-local link manager with shell commands and a ratatui TUI" license = "MIT" @@ -17,5 +17,6 @@ url = "2.5" [dev-dependencies] assert_cmd = "2.0" +portable-pty = "0.9" predicates = "3.1" tempfile = "3.14" diff --git a/README.md b/README.md index 5fe8dcc..db6ef66 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ plinks plinks manage # or explicitly ``` +In the TUI, press `y` to copy the highlighted link to your system clipboard. +On Linux, `plinks` uses `wl-copy`, `xclip`, or `xsel` so the copied URL persists after `plinks` exits. At least one of those utilities must be installed for `y` to work; otherwise the yank action fails with an error. + Run `plinks --help` to see all commands. ## How `plinks` finds `project-links.toml` diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..3296ece --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,250 @@ +use std::env; +use std::io::{Read, Write}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; + +pub trait LinkClipboard { + fn copy_text(&self, text: &str) -> Result<()>; +} + +#[derive(Debug, Default)] +pub struct SystemClipboard; + +impl LinkClipboard for SystemClipboard { + fn copy_text(&self, text: &str) -> Result<()> { + let candidates = clipboard_command_candidates(); + let mut errors = Vec::new(); + + for candidate in candidates { + match run_clipboard_command(&candidate, text) { + Ok(()) => return Ok(()), + Err(err) => errors.push(format!("{}: {err}", candidate.program)), + } + } + + bail!( + "failed to copy to the system clipboard; tried {}", + errors.join("; ") + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClipboardCommand { + program: &'static str, + args: &'static [&'static str], +} + +const COMMAND_SETTLE_TIMEOUT: Duration = Duration::from_millis(200); + +fn run_clipboard_command(command: &ClipboardCommand, text: &str) -> Result<()> { + let mut child = Command::new(command.program) + .args(command.args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("failed to spawn {:?}", command_line(command)))?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(text.as_bytes()) + .with_context(|| format!("failed to write to {:?}", command_line(command)))?; + } else { + return Err(anyhow!( + "clipboard command {:?} has no stdin", + command.program + )); + } + + // Clipboard tools that read from stdin will block until they receive EOF. + // Drop the pipe before waiting so they can finish or daemonize. + drop(child.stdin.take()); + + wait_for_clipboard_command(child, command, COMMAND_SETTLE_TIMEOUT) +} + +fn wait_for_clipboard_command( + mut child: Child, + command: &ClipboardCommand, + timeout: Duration, +) -> Result<()> { + let deadline = Instant::now() + timeout; + + loop { + if let Some(status) = child + .try_wait() + .with_context(|| format!("failed to check {:?}", command_line(command)))? + { + return handle_clipboard_command_exit(child, command, status); + } + + if Instant::now() >= deadline { + // Long-lived clipboard tools usually keep running so the copied value + // remains available after this process exits. + thread::spawn(move || { + let _ = child.wait(); + }); + return Ok(()); + } + + thread::sleep(Duration::from_millis(10)); + } +} + +fn handle_clipboard_command_exit( + mut child: Child, + command: &ClipboardCommand, + status: ExitStatus, +) -> Result<()> { + if status.success() { + return Ok(()); + } + + let mut stderr = String::new(); + if let Some(mut child_stderr) = child.stderr.take() { + child_stderr + .read_to_string(&mut stderr) + .with_context(|| format!("failed to read stderr from {:?}", command_line(command)))?; + } + let stderr = stderr.trim().to_string(); + if stderr.is_empty() { + bail!("clipboard command exited with {}", status); + } + bail!("{stderr}"); +} + +#[cfg(target_os = "macos")] +fn clipboard_command_candidates() -> Vec { + vec![ClipboardCommand { + program: "pbcopy", + args: &[], + }] +} + +#[cfg(target_os = "windows")] +fn clipboard_command_candidates() -> Vec { + vec![ClipboardCommand { + program: "clip", + args: &[], + }] +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn clipboard_command_candidates() -> Vec { + let has_wayland = env::var_os("WAYLAND_DISPLAY").is_some(); + let has_x11 = env::var_os("DISPLAY").is_some(); + + linux_candidates_from_flags(has_wayland, has_x11) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn linux_candidates_from_flags(wayland: bool, x11: bool) -> Vec { + let mut candidates = Vec::new(); + + if wayland { + candidates.push(ClipboardCommand { + program: "wl-copy", + args: &[], + }); + } + + if x11 { + candidates.push(ClipboardCommand { + program: "xclip", + args: &["-selection", "clipboard", "-in"], + }); + candidates.push(ClipboardCommand { + program: "xsel", + args: &["--clipboard", "--input"], + }); + } + + if !wayland && !x11 { + candidates.push(ClipboardCommand { + program: "wl-copy", + args: &[], + }); + candidates.push(ClipboardCommand { + program: "xclip", + args: &["-selection", "clipboard", "-in"], + }); + candidates.push(ClipboardCommand { + program: "xsel", + args: &["--clipboard", "--input"], + }); + } + + candidates +} + +fn command_line(command: &ClipboardCommand) -> String { + if command.args.is_empty() { + command.program.to_string() + } else { + format!("{} {}", command.program, command.args.join(" ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn prefers_wayland_clipboard_when_available() { + let candidates = linux_candidates_from_flags(true, false); + assert_eq!( + candidates, + vec![ClipboardCommand { + program: "wl-copy", + args: &[], + }] + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn prefers_x11_clipboards_when_display_is_set() { + let candidates = linux_candidates_from_flags(false, true); + assert_eq!( + candidates, + vec![ + ClipboardCommand { + program: "xclip", + args: &["-selection", "clipboard", "-in"], + }, + ClipboardCommand { + program: "xsel", + args: &["--clipboard", "--input"], + } + ] + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn falls_back_to_common_linux_clipboard_tools_without_session_hints() { + let candidates = linux_candidates_from_flags(false, false); + assert_eq!( + candidates, + vec![ + ClipboardCommand { + program: "wl-copy", + args: &[], + }, + ClipboardCommand { + program: "xclip", + args: &["-selection", "clipboard", "-in"], + }, + ClipboardCommand { + program: "xsel", + args: &["--clipboard", "--input"], + } + ] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0b18aae..3d98fd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; +pub mod clipboard; pub mod config; pub mod lookup; pub mod open_link; @@ -11,12 +12,19 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use cli::{AddArgs, Cli, Command, ListArgs, OpenArgs, RemoveArgs}; +use clipboard::LinkClipboard; use config::{CandidateLink, Config, load_existing, write_config}; use lookup::{links_for_tag, resolve_alias}; use open_link::LinkOpener; use project_root::resolve_config_path; -pub fn run(cli: Cli, cwd: &Path, opener: &dyn LinkOpener, out: &mut dyn Write) -> Result<()> { +pub fn run( + cli: Cli, + cwd: &Path, + opener: &dyn LinkOpener, + clipboard: &dyn LinkClipboard, + out: &mut dyn Write, +) -> Result<()> { match cli.command.unwrap_or(Command::Manage) { Command::Open(args) => run_open(args, cwd, opener), Command::List(args) => run_list(args, cwd, out), @@ -24,7 +32,7 @@ pub fn run(cli: Cli, cwd: &Path, opener: &dyn LinkOpener, out: &mut dyn Write) - Command::Remove(args) => run_remove(args, cwd, out), Command::Manage => { let resolved = resolve_config_path(cwd)?; - tui::run(resolved, opener) + tui::run(resolved, opener, clipboard) } } } diff --git a/src/main.rs b/src/main.rs index 6c7e569..dd2a089 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::io; use clap::Parser; use plinks::cli::Cli; +use plinks::clipboard::SystemClipboard; use plinks::open_link::SystemOpener; fn main() { @@ -15,6 +16,7 @@ fn run() -> anyhow::Result<()> { let cli = Cli::parse(); let cwd = std::env::current_dir()?; let opener = SystemOpener; + let clipboard = SystemClipboard; let mut stdout = io::stdout(); - plinks::run(cli, &cwd, &opener, &mut stdout) + plinks::run(cli, &cwd, &opener, &clipboard, &mut stdout) } diff --git a/src/tui/events.rs b/src/tui/events.rs index db6f7a7..2bc0ef5 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -10,6 +10,7 @@ pub enum EventResult { Quit, Reload, OpenSelected, + YankSelected, SaveEditor(Box), ConfirmDelete, } @@ -61,6 +62,7 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) -> Result { } KeyCode::Char('r') => Ok(EventResult::Reload), KeyCode::Char('o') => Ok(EventResult::OpenSelected), + KeyCode::Char('y') => Ok(EventResult::YankSelected), _ => Ok(EventResult::None), } } @@ -258,4 +260,14 @@ mod tests { }; assert_eq!(editor.active_field, Field::Url); } + + #[test] + fn yanks_selected_link_in_normal_mode() { + let mut app = sample_app(); + + assert_eq!( + handle_key(&mut app, key(KeyCode::Char('y'))).unwrap(), + EventResult::YankSelected + ); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1bb7612..5e93fcf 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -16,6 +16,7 @@ use crossterm::terminal::{ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use crate::clipboard::LinkClipboard; use crate::config::{Config, load_existing, write_config}; use crate::open_link::LinkOpener; use crate::project_root::ResolvedConfigPath; @@ -23,7 +24,11 @@ use crate::project_root::ResolvedConfigPath; use self::events::{EventResult, handle_key}; use self::state::{App, Mode}; -pub fn run(resolved: ResolvedConfigPath, opener: &dyn LinkOpener) -> Result<()> { +pub fn run( + resolved: ResolvedConfigPath, + opener: &dyn LinkOpener, + clipboard: &dyn LinkClipboard, +) -> Result<()> { let loaded = load_or_default(&resolved)?; let mut app = App::new(loaded.config); let mut snapshot = loaded.raw; @@ -34,7 +39,14 @@ pub fn run(resolved: ResolvedConfigPath, opener: &dyn LinkOpener) -> Result<()> let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_loop(&mut terminal, &resolved, opener, &mut app, &mut snapshot); + let result = run_loop( + &mut terminal, + &resolved, + opener, + clipboard, + &mut app, + &mut snapshot, + ); disable_raw_mode().ok(); execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); @@ -47,6 +59,7 @@ fn run_loop( terminal: &mut Terminal>, resolved: &ResolvedConfigPath, opener: &dyn LinkOpener, + clipboard: &dyn LinkClipboard, app: &mut App, snapshot: &mut Option, ) -> Result<()> { @@ -67,16 +80,13 @@ fn run_loop( reload_from_disk(app, resolved, snapshot)?; } EventResult::OpenSelected => { - if let Some(primary) = app.selected_primary() { - if let Some(entry) = app.config.links.get(&primary) { - if let Err(err) = opener.open(&entry.url) { - app.set_error(err.to_string()); - } else { - app.set_info(format!("Opened '{primary}'")); - } - } - } else { - app.set_error("No link selected"); + if let Err(err) = open_selected(app, opener) { + app.set_error(err.to_string()); + } + } + EventResult::YankSelected => { + if let Err(err) = yank_selected(app, clipboard) { + app.set_error(err.to_string()); } } EventResult::SaveEditor(editor) => match editor.build_candidate() { @@ -112,6 +122,30 @@ fn run_loop( } } +fn open_selected(app: &mut App, opener: &dyn LinkOpener) -> Result<()> { + let primary = app.selected_primary().context("No link selected")?; + let entry = app + .config + .links + .get(&primary) + .with_context(|| format!("selected link '{primary}' no longer exists"))?; + opener.open(&entry.url)?; + app.set_info(format!("Opened '{primary}'")); + Ok(()) +} + +fn yank_selected(app: &mut App, clipboard: &dyn LinkClipboard) -> Result<()> { + let primary = app.selected_primary().context("No link selected")?; + let entry = app + .config + .links + .get(&primary) + .with_context(|| format!("selected link '{primary}' no longer exists"))?; + clipboard.copy_text(&entry.url)?; + app.set_info(format!("Copied URL for '{primary}'")); + Ok(()) +} + fn load_or_default(resolved: &ResolvedConfigPath) -> Result { match load_existing(&resolved.config_path)? { Some(document) => Ok(LoadedSnapshot { @@ -202,12 +236,51 @@ struct LoadedSnapshot { #[cfg(test)] mod tests { + use std::cell::RefCell; use std::fs; use tempfile::TempDir; use super::*; + use crate::clipboard::LinkClipboard; use crate::config::CandidateLink; + use crate::open_link::LinkOpener; + + #[derive(Default)] + struct RecordingClipboard { + copied: RefCell>, + } + + impl LinkClipboard for RecordingClipboard { + fn copy_text(&self, text: &str) -> Result<()> { + self.copied.borrow_mut().push(text.to_string()); + Ok(()) + } + } + + impl RecordingClipboard { + fn copied(&self) -> Vec { + self.copied.borrow().clone() + } + } + + #[derive(Default)] + struct RecordingOpener { + opened: RefCell>, + } + + impl LinkOpener for RecordingOpener { + fn open(&self, url: &str) -> Result<()> { + self.opened.borrow_mut().push(url.to_string()); + Ok(()) + } + } + + impl RecordingOpener { + fn opened(&self) -> Vec { + self.opened.borrow().clone() + } + } #[test] fn save_conflict_reloads_on_disk_state() { @@ -278,4 +351,62 @@ mod tests { snapshot.clone().unwrap() ); } + + #[test] + fn yank_selected_copies_selected_url() { + let mut config = Config::default(); + config + .save_link( + None, + CandidateLink::new( + "docs".into(), + "https://docs.rs".into(), + vec![], + vec![], + None, + ) + .unwrap(), + ) + .unwrap(); + + let mut app = App::new(config); + let clipboard = RecordingClipboard::default(); + + yank_selected(&mut app, &clipboard).unwrap(); + + assert_eq!(clipboard.copied(), vec!["https://docs.rs".to_string()]); + assert_eq!( + app.status.as_ref().map(|status| status.text.as_str()), + Some("Copied URL for 'docs'") + ); + } + + #[test] + fn open_selected_opens_selected_url() { + let mut config = Config::default(); + config + .save_link( + None, + CandidateLink::new( + "docs".into(), + "https://docs.rs".into(), + vec![], + vec![], + None, + ) + .unwrap(), + ) + .unwrap(); + + let mut app = App::new(config); + let opener = RecordingOpener::default(); + + open_selected(&mut app, &opener).unwrap(); + + assert_eq!(opener.opened(), vec!["https://docs.rs".to_string()]); + assert_eq!( + app.status.as_ref().map(|status| status.text.as_str()), + Some("Opened 'docs'") + ); + } } diff --git a/src/tui/view.rs b/src/tui/view.rs index b07750c..8a5498b 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -140,7 +140,9 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { .split(area); let keys = match app.mode { - Mode::Normal => "q quit j/k move / filter n new e edit d delete o open r reload", + Mode::Normal => { + "q quit j/k move / filter n new e edit d delete o open y yank r reload" + } Mode::Filter => "type to filter Enter/Esc finish", Mode::Editor(_) => "Tab fields Ctrl-s save Esc cancel", Mode::DeleteConfirm => "y confirm delete n cancel", diff --git a/tests/cli.rs b/tests/cli.rs index 459a500..9b42ab4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,6 +6,7 @@ use std::process::Command as ProcessCommand; use anyhow::Result; use clap::Parser; use plinks::cli::Cli; +use plinks::clipboard::LinkClipboard; use plinks::config::Config; use plinks::open_link::LinkOpener; use tempfile::TempDir; @@ -33,10 +34,20 @@ impl LinkOpener for RecordingOpener { } } +#[derive(Default)] +struct NoopClipboard; + +impl LinkClipboard for NoopClipboard { + fn copy_text(&self, _text: &str) -> Result<()> { + Ok(()) + } +} + fn run(args: &[&str], cwd: &Path, opener: &RecordingOpener) -> Result { let cli = Cli::parse_from(std::iter::once("plinks").chain(args.iter().copied())); + let clipboard = NoopClipboard; let mut stdout = Vec::new(); - plinks::run(cli, cwd, opener, &mut stdout)?; + plinks::run(cli, cwd, opener, &clipboard, &mut stdout)?; Ok(String::from_utf8(stdout).unwrap()) } diff --git a/tests/tui.rs b/tests/tui.rs new file mode 100644 index 0000000..0a107f7 --- /dev/null +++ b/tests/tui.rs @@ -0,0 +1,126 @@ +#![cfg(all(unix, not(target_os = "macos")))] + +use std::env; +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use tempfile::TempDir; + +#[test] +fn tui_yank_then_quit_exits_when_clipboard_tool_stays_alive() { + let temp = TempDir::new().unwrap(); + write_project_links(temp.path()); + let clipboard_file = temp.path().join("clipboard.txt"); + let fake_bin = temp.path().join("bin"); + write_fake_wl_copy(&fake_bin); + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_plinks")); + command.cwd(temp.path()); + command.env("PATH", test_path_with_fake_bin(&fake_bin)); + command.env("WAYLAND_DISPLAY", "plinks-test-wayland"); + command.env("PLINKS_TEST_CLIPBOARD_OUT", &clipboard_file); + command.env_remove("DISPLAY"); + + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().unwrap(); + let reader_thread = thread::spawn(move || { + let mut buffer = Vec::new(); + let _ = reader.read_to_end(&mut buffer); + buffer + }); + + let mut writer = pair.master.take_writer().unwrap(); + thread::sleep(Duration::from_millis(500)); + writer.write_all(b"y").unwrap(); + thread::sleep(Duration::from_millis(750)); + writer.write_all(b"q").unwrap(); + drop(writer); + + let status = wait_for_child_exit(&mut child, Duration::from_secs(2)); + if status.is_none() { + child.kill().unwrap(); + let output = reader_thread.join().unwrap(); + panic!( + "plinks did not exit after y then q.\nTUI output:\n{}", + String::from_utf8_lossy(&output) + ); + } + + assert!(status.unwrap().success()); + assert_eq!( + fs::read_to_string(&clipboard_file).unwrap(), + "https://docs.rs" + ); +} + +fn write_project_links(project_dir: &Path) { + fs::write( + project_dir.join("project-links.toml"), + r#"version = 1 + +[links] + +[links.docs] +url = "https://docs.rs" +"#, + ) + .unwrap(); +} + +fn write_fake_wl_copy(fake_bin: &Path) { + fs::create_dir_all(fake_bin).unwrap(); + let script = fake_bin.join("wl-copy"); + fs::write( + &script, + r#"#!/bin/sh +cat > "$PLINKS_TEST_CLIPBOARD_OUT" +sleep 5 +"#, + ) + .unwrap(); + + let mut permissions = fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions).unwrap(); +} + +fn test_path_with_fake_bin(fake_bin: &Path) -> std::ffi::OsString { + let mut paths = vec![fake_bin.to_path_buf()]; + if let Some(path) = env::var_os("PATH") { + paths.extend(env::split_paths(&path)); + } + env::join_paths(paths).unwrap() +} + +fn wait_for_child_exit( + child: &mut Box, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().unwrap() { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + thread::sleep(Duration::from_millis(20)); + } +}