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
1 change: 1 addition & 0 deletions docs/KEYBINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions src/app/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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 {
Expand Down
55 changes: 55 additions & 0 deletions src/app/tests/submit_flow_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
23 changes: 22 additions & 1 deletion src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
15 changes: 15 additions & 0 deletions src/input/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/ui/help_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down
Loading