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
313 changes: 311 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions KEYBINDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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`)
Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions keybinds.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub enum AppAction {
PickRemote,
PickIssue,
OpenInBrowser,
CopyUrl,
CheckoutPullRequest,
MergePullRequest,
OpenLinkedPullRequestInBrowser,
Expand Down
13 changes: 13 additions & 0 deletions src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/app/tests/part1.rs
Original file line number Diff line number Diff line change
@@ -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());
Expand Down
20 changes: 20 additions & 0 deletions src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use anyhow::{Context, Result};

#[derive(Default)]
pub struct SystemClipboard {
inner: Option<arboard::Clipboard>,
}

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")
}
}
5 changes: 5 additions & 0 deletions src/keybinds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod app;
mod auth;
mod cli;
mod clipboard;
mod config;
mod discovery;
mod git;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -190,6 +193,7 @@ fn main() -> Result<()> {
run_app(
terminal_guard.terminal_mut(),
&mut app,
&mut clipboard,
&conn,
&token,
event_rx,
Expand Down Expand Up @@ -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<AppEvent>,
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 30 additions & 3 deletions src/main/tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/main_action_utils/issue_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,21 @@ pub(crate) fn issue_url(app: &App) -> Option<String> {
owner, repo, route, issue_number
))
}

pub(crate) fn selected_url(app: &App) -> Option<String> {
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)
}
2 changes: 1 addition & 1 deletion src/main_action_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/main_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEvent>,
Expand Down Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -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));
}
Loading