diff --git a/Cargo.lock b/Cargo.lock index 77586b2..8cfdab7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -226,6 +244,7 @@ name = "blippy" version = "0.1.9" dependencies = [ "anyhow", + "arboard", "async-trait", "crossterm", "keyring", @@ -356,6 +375,15 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -538,6 +566,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -558,6 +596,12 @@ dependencies = [ "litrs", ] +[[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.17.0" @@ -607,6 +651,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.2" @@ -651,6 +701,18 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -740,6 +802,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getopts" version = "0.2.24" @@ -785,6 +857,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -793,7 +874,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -804,7 +885,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1273,6 +1354,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num" version = "0.4.3" @@ -1360,6 +1450,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1376,6 +1539,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "palette" version = "0.7.7" @@ -1444,6 +1617,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1539,6 +1723,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -2450,6 +2643,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2625,6 +2829,76 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -2804,12 +3078,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 1f21d87..e15ebf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/AksharP5/blippy" [dependencies] anyhow = "1.0.103" +arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } crossterm = "0.29" ratatui = { version = "0.30", default-features = false, features = ["crossterm"] } serde = { version = "1.0", features = ["derive"] } diff --git a/DEMO.md b/DEMO.md index cab2cc2..dc20af8 100644 --- a/DEMO.md +++ b/DEMO.md @@ -236,6 +236,7 @@ Issue closed with preset comment: | Edit assignees | `Shift+A` | | Close item | `dd` | | Open in browser | `o` | +| Copy URL | `y` | | Checkout PR | `v` | | Mark file viewed | `w` | | Collapse hunk | `c` | diff --git a/FEATURES.md b/FEATURES.md index 30ed699..d9dd525 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -18,6 +18,7 @@ See the [feature demo](DEMO.md) for a visual walkthrough of these capabilities i - Distinguishes merged pull requests from closed pull requests - Fast list navigation with keyboard-first controls - Issue and PR detail views with context-aware panes +- Copy repository, issue, and pull request URLs to the system clipboard ## Issue Creation in TUI diff --git a/KEYBINDS.md b/KEYBINDS.md index 7e48ea0..c8838b2 100644 --- a/KEYBINDS.md +++ b/KEYBINDS.md @@ -15,6 +15,7 @@ blippy is keyboard-first. Mouse/trackpad support exists, but it can be finicky a - `/`: Start repository search - `Enter`: Open selected repository +- `y`: Copy selected repository URL - `Ctrl+r`: Rescan repositories Search mode: @@ -41,6 +42,7 @@ Search mode: - `dd`: Close selected item via preset flow - `Shift+M`: Merge selected pull request - `o`: Open selected item in browser +- `y`: Copy selected item URL - `Shift+P`: Open linked PR/issue in TUI - `Shift+O`: Open linked PR/issue in browser - `v`: Checkout selected PR locally (`gh pr checkout`) @@ -64,6 +66,7 @@ Search mode: - `dd`: Close selected item via preset flow - `Shift+M`: Merge pull request - `o`: Open in browser +- `y`: Copy issue/PR URL - `Shift+P`: Open linked PR/issue in TUI - `Shift+O`: Open linked PR/issue in browser - `r`: Refresh issue/comments @@ -82,6 +85,7 @@ Search mode: - `dd`: Close selected item via preset flow - `Shift+M`: Merge pull request - `o`: Open in browser +- `y`: Copy issue/PR URL - `Shift+P`: Open linked PR/issue in TUI - `Shift+O`: Open linked PR/issue in browser - `r`: Refresh issue/comments @@ -96,6 +100,7 @@ Search mode: - `r`: Refresh PR data - `v`: Checkout PR locally - `Shift+M`: Merge pull request +- `y`: Copy pull request URL - `b` or `Esc`: Back (or return to split diff if expanded) ## Pull Request Review View (`Diff`) @@ -116,6 +121,7 @@ Search mode: - `r`: Refresh PR data - `v`: Checkout PR locally - `Shift+M`: Merge pull request +- `y`: Copy pull request URL - `b` or `Esc`: Return to split diff (if expanded) or back ## Label / Assignee Pickers @@ -191,6 +197,7 @@ All entries below can be overridden in `~/.config/blippy/keybinds.toml` (or unde | `move_up` | `k` | | `move_down` | `j` | | `open_browser` | `o` | +| `copy_url` | `y` | | `open_linked_pr_browser` | `shift+o` | | `open_linked_pr_tui` | `shift+p` | | `checkout_pr` | `v` | diff --git a/README.md b/README.md index 15ec1bc..e3e77f4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ cargo install --git https://github.com/AksharP5/blippy ## What You Can Do - Browse and manage issues and pull requests +- Copy repository, issue, and pull request URLs to the system clipboard - Create issues from the TUI with a confirmation step - Open linked issues/PRs in TUI or browser - Review PR diffs with inline comments and thread resolution diff --git a/keybinds.example.toml b/keybinds.example.toml index 1578c54..a5fdd6e 100644 --- a/keybinds.example.toml +++ b/keybinds.example.toml @@ -50,6 +50,7 @@ back_escape = "esc" move_up = "k" move_down = "j" open_browser = "o" +copy_url = "y" open_linked_pr_browser = "shift+o" open_linked_pr_tui = "shift+p" checkout_pr = "v" diff --git a/src/app.rs b/src/app.rs index 0b2087e..b7652e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,7 @@ pub enum AppAction { PickRemote, PickIssue, OpenInBrowser, + CopyUrl, CheckoutPullRequest, MergePullRequest, OpenLinkedPullRequestInBrowser, diff --git a/src/app/input.rs b/src/app/input.rs index 33cdd19..a0e3467 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -330,6 +330,19 @@ impl App { KeyCode::Char('k') | KeyCode::Up => self.move_selection_up(), KeyCode::Char('j') | KeyCode::Down => self.move_selection_down(), KeyCode::Enter => self.activate_selection(), + KeyCode::Char('y') + if matches!( + self.view, + View::RepoPicker + | View::RemoteChooser + | View::Issues + | View::IssueDetail + | View::IssueComments + | View::PullRequestFiles + ) => + { + self.interaction.action = Some(AppAction::CopyUrl); + } KeyCode::Char('o') if matches!( self.view, diff --git a/src/app/tests/part1.rs b/src/app/tests/part1.rs index a1c1137..ec5e1d5 100644 --- a/src/app/tests/part1.rs +++ b/src/app/tests/part1.rs @@ -1,5 +1,15 @@ use super::*; +#[test] +fn y_triggers_copy_url_action() { + let mut app = App::new(Config::default()); + app.set_view(View::Issues); + + app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!(app.take_action(), Some(AppAction::CopyUrl)); +} + #[test] fn dd_triggers_close_issue_action() { let mut app = App::new(Config::default()); diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..a078da0 --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,20 @@ +use anyhow::{Context, Result}; + +#[derive(Default)] +pub struct SystemClipboard { + inner: Option, +} + +impl SystemClipboard { + pub fn set_text(&mut self, text: &str) -> Result<()> { + if self.inner.is_none() { + self.inner = Some(arboard::Clipboard::new().context("clipboard unavailable")?); + } + + self.inner + .as_mut() + .context("clipboard unavailable")? + .set_text(text) + .context("failed to write to clipboard") + } +} diff --git a/src/keybinds.rs b/src/keybinds.rs index 0fb3ae2..c516f1b 100644 --- a/src/keybinds.rs +++ b/src/keybinds.rs @@ -185,6 +185,11 @@ pub const BINDING_SPECS: &[BindingSpec] = &[ default: "o", description: "Open issue/PR in browser", }, + BindingSpec { + action: "copy_url", + default: "y", + description: "Copy repository, issue, or PR URL", + }, BindingSpec { action: "open_linked_pr_browser", default: "shift+o", diff --git a/src/main.rs b/src/main.rs index e852fc8..9fccbe9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod auth; mod cli; +mod clipboard; mod config; mod discovery; mod git; @@ -44,6 +45,7 @@ use crate::app::{ }; use crate::auth::{SystemAuth, clear_auth_token, resolve_auth_token}; use crate::cli::{CliCommand, parse_args}; +use crate::clipboard::SystemClipboard; use crate::config::Config; use crate::discovery::{home_dir, quick_scan}; use crate::git::list_github_remotes_at; @@ -178,6 +180,7 @@ fn main() -> Result<()> { let config = Config::load()?; let conn = crate::store::open_db()?; let mut app = App::new(config); + let mut clipboard = SystemClipboard::default(); main_data::initialize_app(&mut app, &conn)?; let (event_tx, event_rx) = mpsc::channel(); @@ -190,6 +193,7 @@ fn main() -> Result<()> { run_app( terminal_guard.terminal_mut(), &mut app, + &mut clipboard, &conn, &token, event_rx, @@ -257,6 +261,7 @@ fn handle_sync() -> Result<()> { fn run_app( terminal: &mut Tui, app: &mut App, + clipboard: &mut SystemClipboard, conn: &rusqlite::Connection, token: &str, event_rx: Receiver, @@ -313,7 +318,7 @@ fn run_app( _ => {} } - main_actions::handle_actions(app, conn, token, event_tx.clone())?; + main_actions::handle_actions(app, clipboard, conn, token, event_tx.clone())?; drive_background_tasks( app, conn, diff --git a/src/main/tests.rs b/src/main/tests.rs index e77a20b..20aceb6 100644 --- a/src/main/tests.rs +++ b/src/main/tests.rs @@ -1,7 +1,7 @@ use super::main_actions::issue_url; use crate::app::{EditorMode, PendingIssueAction, View, WorkItemMode}; use crate::config::Config; -use crate::store::IssueRow; +use crate::store::{IssueRow, LocalRepoRow}; use std::sync::mpsc::channel; fn parse_csv_values(input: &str, strip_at: bool) -> Vec { @@ -88,6 +88,29 @@ fn issue_url_uses_issue_route_for_issues() { assert_eq!(url, "https://github.com/acme/blippy/issues/7"); } +#[test] +fn copy_selected_url_copies_repo_from_picker() { + let mut app = crate::app::App::new(Config::default()); + app.set_repos(vec![LocalRepoRow { + path: "/tmp/blippy".to_string(), + remote_name: "origin".to_string(), + owner: "acme".to_string(), + repo: "blippy".to_string(), + url: "git@github.com:acme/blippy.git".to_string(), + last_seen: None, + last_scanned: None, + }]); + let mut copied = String::new(); + + super::main_actions::copy_selected_url(&mut app, |url| { + copied = url.to_string(); + Ok(()) + }); + + assert_eq!(copied, "https://github.com/acme/blippy"); + assert_eq!(app.status(), "URL copied"); +} + #[test] fn linked_pull_request_action_opens_picker_when_multiple_cached() { let conn = rusqlite::Connection::open_in_memory().expect("conn"); @@ -133,7 +156,9 @@ fn create_issue_action_opens_create_issue_editor() { )); let (event_tx, _event_rx) = channel(); - super::main_actions::handle_actions(&mut app, &conn, "token", event_tx).expect("handled"); + let mut clipboard = crate::clipboard::SystemClipboard::default(); + super::main_actions::handle_actions(&mut app, &mut clipboard, &conn, "token", event_tx) + .expect("handled"); assert_eq!(app.view(), View::CommentEditor); assert_eq!(app.editor_mode(), EditorMode::CreateIssue); @@ -312,7 +337,9 @@ fn submit_created_issue_requires_non_empty_title() { )); let (event_tx, _event_rx) = channel(); - super::main_actions::handle_actions(&mut app, &conn, "token", event_tx).expect("handled"); + let mut clipboard = crate::clipboard::SystemClipboard::default(); + super::main_actions::handle_actions(&mut app, &mut clipboard, &conn, "token", event_tx) + .expect("handled"); assert_eq!(app.status(), "Issue title required"); assert_eq!(app.view(), View::CommentEditor); diff --git a/src/main_action_utils/issue_selection.rs b/src/main_action_utils/issue_selection.rs index 9fe1ac4..90ca838 100644 --- a/src/main_action_utils/issue_selection.rs +++ b/src/main_action_utils/issue_selection.rs @@ -144,3 +144,21 @@ pub(crate) fn issue_url(app: &App) -> Option { owner, repo, route, issue_number )) } + +pub(crate) fn selected_url(app: &App) -> Option { + if app.view() == View::RepoPicker { + let (owner, repo, _) = app.selected_repo_target()?; + return Some(repo_url(&owner, &repo)); + } + + if app.view() == View::RemoteChooser { + let remote = app.remotes().get(app.selected_remote())?; + return Some(repo_url(&remote.slug.owner, &remote.slug.repo)); + } + + issue_url(app).or_else(|| Some(repo_url(app.current_owner()?, app.current_repo()?))) +} + +fn repo_url(owner: &str, repo: &str) -> String { + format!("https://github.com/{}/{}", owner, repo) +} diff --git a/src/main_action_utils/mod.rs b/src/main_action_utils/mod.rs index 0bf385a..9ec5232 100644 --- a/src/main_action_utils/mod.rs +++ b/src/main_action_utils/mod.rs @@ -15,7 +15,7 @@ pub(super) use issue_actions::{ pub(super) use issue_selection::{ assignee_options_for_repo, ensure_can_edit_issue_metadata, ensure_can_merge_pull_request, issue_number, issue_url, label_options_for_repo, selected_issue_assignees, - selected_issue_for_action, selected_issue_labels, + selected_issue_for_action, selected_issue_labels, selected_url, }; pub(super) use pr_review_actions::{ delete_pull_request_review_comment, resolve_pull_request_review_comment, diff --git a/src/main_actions.rs b/src/main_actions.rs index 38f1b81..b21d68a 100644 --- a/src/main_actions.rs +++ b/src/main_actions.rs @@ -6,6 +6,7 @@ pub(super) use super::main_action_utils::issue_url; pub(super) fn handle_actions( app: &mut App, + clipboard: &mut crate::clipboard::SystemClipboard, conn: &rusqlite::Connection, token: &str, event_tx: Sender, @@ -89,6 +90,9 @@ pub(super) fn handle_actions( app.set_status("No issue selected".to_string()); } } + AppAction::CopyUrl => { + copy_selected_url(app, |url| clipboard.set_text(url)); + } AppAction::CheckoutPullRequest => { checkout_pull_request(app)?; } @@ -308,3 +312,20 @@ pub(super) fn handle_actions( } Ok(()) } + +pub(super) fn copy_selected_url(app: &mut App, set_text: impl FnOnce(&str) -> Result<()>) { + let url = match selected_url(app) { + Some(url) => url, + None => { + app.set_status("No URL available".to_string()); + return; + } + }; + + if let Err(error) = set_text(&url) { + app.set_status(format!("Copy failed: {}", error)); + return; + } + + app.set_transient_status("URL copied".to_string(), Duration::from_secs(2)); +} diff --git a/src/ui/ui_status_overlay.rs b/src/ui/ui_status_overlay.rs index 176864d..8c6a73c 100644 --- a/src/ui/ui_status_overlay.rs +++ b/src/ui/ui_status_overlay.rs @@ -201,6 +201,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { (move_keys, "Move repositories".to_string()), (bind(app, "repo_search"), "Search repositories".to_string()), (bind(app, "submit"), "Open selected repository".to_string()), + (bind(app, "copy_url"), "Copy repository URL".to_string()), (bind(app, "rescan_repos"), "Rescan repositories".to_string()), ( bind(app, "clear_and_repo_picker"), @@ -252,6 +253,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { ), ); } + rows.push((bind(app, "copy_url"), "Copy selected URL".to_string())); rows } View::IssueDetail => { @@ -263,6 +265,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { (bind(app, "open_comments"), "Open comments".to_string()), (back_keys, "Back".to_string()), (bind(app, "open_browser"), "Open in browser".to_string()), + (bind(app, "copy_url"), "Copy URL".to_string()), ]; if !is_pr { rows.insert(4, (bind(app, "create_issue"), "Create issue".to_string())); @@ -293,6 +296,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { (bind(app, "add_comment"), "Add comment".to_string()), (back_keys, "Back".to_string()), (bind(app, "open_browser"), "Open in browser".to_string()), + (bind(app, "copy_url"), "Copy URL".to_string()), ]; if !is_pr { rows.insert(4, (bind(app, "create_issue"), "Create issue".to_string())); @@ -324,6 +328,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { ), (back_keys, "Back".to_string()), (bind(app, "open_browser"), "Open in browser".to_string()), + (bind(app, "copy_url"), "Copy PR URL".to_string()), ]; } if app.pull_request_diff_expanded() { @@ -352,6 +357,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { bind(app, "merge_pull_request"), "Merge pull request".to_string(), ), + (bind(app, "copy_url"), "Copy PR URL".to_string()), ]; } vec![ @@ -376,6 +382,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { bind(app, "merge_pull_request"), "Merge pull request".to_string(), ), + (bind(app, "copy_url"), "Copy PR URL".to_string()), ] } View::LinkedPicker => vec![ @@ -432,6 +439,7 @@ fn help_rows(app: &App) -> Vec<(String, String)> { View::RemoteChooser => vec![ (move_keys, "Move remotes".to_string()), (bind(app, "submit"), "Select remote".to_string()), + (bind(app, "copy_url"), "Copy repository URL".to_string()), ( bind(app, "clear_and_repo_picker"), "Back to repos".to_string(),