diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index c5a08911..2221f3f3 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -121,6 +121,7 @@ Shown below the file tree when local comments or visible remote PR threads exist | `A` | Edit comment at cursor with text cursor at end (vim mode only) | | `e` | Open focused file in `$EDITOR` | | `y` | Copy review to clipboard | +| `Y` | Copy the comment at cursor to clipboard | ## Visual mode diff --git a/src/app/comments.rs b/src/app/comments.rs index 7e3ec1f0..62e862be 100644 --- a/src/app/comments.rs +++ b/src/app/comments.rs @@ -419,6 +419,41 @@ impl App { } } + /// Content of the comment at the current cursor position, if any. + /// Resolves through the same lookup `dd` and `i` use, so `Y` yanks + /// exactly the comment the cursor is sitting on. + pub fn comment_content_at_cursor(&self) -> Option { + match self.find_comment_at_cursor()? { + CommentLocation::Review { index } => self + .session + .review_comments + .get(index) + .map(|c| c.content.clone()), + CommentLocation::File { path, index } => self + .session + .files + .get(&path) + .and_then(|review| review.file_comments.get(index)) + .map(|c| c.content.clone()), + CommentLocation::Line { + path, + line, + side, + index, + } => self + .session + .files + .get(&path) + .and_then(|review| review.line_comments.get(&line)) + .and_then(|comments| comments.get(index)) + // Same side guard as the delete path: the annotation index is + // absolute into the stored Vec, so confirm we landed on the + // side the cursor is actually showing. + .filter(|c| c.side.unwrap_or(LineSide::New) == side) + .map(|c| c.content.clone()), + } + } + /// Delete the comment at the current cursor position, if any /// Returns true if a comment was deleted pub fn delete_comment_at_cursor(&mut self) -> bool { diff --git a/src/app/tests/submit_flow_tests.rs b/src/app/tests/submit_flow_tests.rs index f9c3e803..3c125ebd 100644 --- a/src/app/tests/submit_flow_tests.rs +++ b/src/app/tests/submit_flow_tests.rs @@ -1135,3 +1135,58 @@ fn should_detect_locked_comment_under_cursor_for_dd_path() { app.diff_state.cursor_line = idx; assert!(app.cursor_on_locked_comment()); } + +#[test] +fn should_yank_only_the_comment_under_the_cursor() { + // given two line comments in the same file, `Y` on the second one + // resolves to that comment's content and not the first. + let mut app = make_pr_app_with_single_modified_file("src/lib.rs"); + let mut first = line_comment(LineSide::New, Some(10), None); + first.content = "first comment".to_string(); + add_line_comment(&mut app, "src/lib.rs", 10, first); + let mut second = line_comment(LineSide::New, Some(11), None); + second.content = "second comment".to_string(); + add_line_comment(&mut app, "src/lib.rs", 11, second); + app.rebuild_annotations(); + + // A comment box spans several annotation rows, so anchor on the line the + // comment belongs to rather than counting rows. + let row_for_line = |app: &App, target: u32| { + app.line_annotations + .iter() + .position(|a| matches!(a, AnnotatedLine::LineComment { line, .. } if *line == target)) + .expect("expected an annotation for the comment") + }; + + app.diff_state.cursor_line = row_for_line(&app, 11); + assert_eq!( + app.comment_content_at_cursor(), + Some("second comment".to_string()) + ); + + app.diff_state.cursor_line = row_for_line(&app, 10); + assert_eq!( + app.comment_content_at_cursor(), + Some("first comment".to_string()) + ); +} + +#[test] +fn should_yank_nothing_when_cursor_is_not_on_a_comment() { + let mut app = make_pr_app_with_single_modified_file("src/lib.rs"); + add_line_comment( + &mut app, + "src/lib.rs", + 11, + line_comment(LineSide::New, Some(11), None), + ); + app.rebuild_annotations(); + + let idx = app + .line_annotations + .iter() + .position(|a| matches!(a, AnnotatedLine::DiffLine { .. })) + .expect("expected a diff line annotation"); + app.diff_state.cursor_line = idx; + assert_eq!(app.comment_content_at_cursor(), None); +} diff --git a/src/handler.rs b/src/handler.rs index 955d0acc..9eeec888 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -9,7 +9,7 @@ use crate::forge::remote_comments::PrCommentsVisibility; use crate::forge::submit::SubmitEvent; use crate::input::Action; use crate::model::{ClearScope, LineSide}; -use crate::output::{export_to_clipboard, generate_export_content}; +use crate::output::{copy_text_to_clipboard, export_to_clipboard, generate_export_content}; use crate::text_edit::{ delete_char_before, delete_word_before, next_char_boundary, prev_char_boundary, }; @@ -391,6 +391,26 @@ fn handle_export(app: &mut App) { } } +/// Copy just the comment under the cursor (`Y`). Unlike `y`, the rest of the +/// review stays out of the clipboard, so a single comment can go straight into +/// a chat message or an agent prompt. +fn handle_copy_comment_at_cursor(app: &mut App) { + let Some(content) = app.comment_content_at_cursor() else { + if app.cursor_on_remote_thread() { + let forge = app.forge_display_name(); + app.set_message(format!("Y copies local comments; this one is on {forge}")); + } else { + app.set_message("No comment at cursor"); + } + return; + }; + match copy_text_to_clipboard(&content) { + Ok(true) => app.set_message("Comment copied to clipboard (via terminal)"), + Ok(false) => app.set_message("Comment copied to clipboard"), + Err(e) => app.set_warning(format!("{e}")), + } +} + /// Export and quit (used by ZZ keybinding). /// When --stdout is set, stores export content and quits. /// Otherwise, exports to clipboard and quits. @@ -1602,6 +1622,7 @@ fn handle_shared_normal_action(app: &mut App, action: Action) { // `A` (vim only) edits with the text cursor at end-of-line. Action::EditCommentAtEnd if app.comment_vim_enabled => edit_comment_at_cursor(app, true), Action::ExportToClipboard => handle_export(app), + Action::CopyCommentAtCursor => handle_copy_comment_at_cursor(app), Action::SearchNext => { app.search_next_in_diff(); } diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index a17be9b6..036bd9af 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -55,6 +55,8 @@ pub enum Action { // Session Quit, ExportToClipboard, + /// Copy just the comment under the cursor (`Y`), not the whole review. + CopyCommentAtCursor, // Mode changes EnterCommandMode, @@ -220,6 +222,7 @@ fn map_normal_mode(key: KeyEvent, leader_key: char) -> Action { (KeyCode::Char('d'), KeyModifiers::NONE) => Action::PendingDCommand, (KeyCode::Char('v') | KeyCode::Char('V'), _) => Action::EnterVisualMode, (KeyCode::Char('y'), KeyModifiers::NONE) => Action::ExportToClipboard, + (KeyCode::Char('Y'), _) => Action::CopyCommentAtCursor, (KeyCode::Char('e'), KeyModifiers::NONE) => Action::EditFile, (KeyCode::Char('n'), KeyModifiers::NONE) => Action::SearchNext, (KeyCode::Char('N'), _) => Action::SearchPrev, @@ -617,6 +620,18 @@ mod tests { ); } + #[test] + fn should_map_lowercase_and_uppercase_y_to_separate_yank_actions() { + assert_eq!( + map_normal_mode(key(KeyCode::Char('y')), DEFAULT_LEADER_KEY), + Action::ExportToClipboard + ); + assert_eq!( + map_normal_mode(key_shift('Y'), DEFAULT_LEADER_KEY), + Action::CopyCommentAtCursor + ); + } + #[test] fn should_map_uppercase_g_to_go_to_bottom_in_normal_mode() { let action = map_normal_mode(key_shift('G'), DEFAULT_LEADER_KEY); diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 3669e8ed..8bcaff2b 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -468,6 +468,13 @@ pub fn render_help(frame: &mut Frame, app: &mut App) { ), Span::raw("Yank: mouse selection if any, else review to clipboard"), ]), + Line::from(vec![ + Span::styled( + " Y ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("Copy comment at cursor to clipboard"), + ]), Line::from(vec![ Span::styled( " e ",