diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2837f1f564f6ef188595371c0301b7fd7bcf6019..c4a9addc0b14a1ca3a25351e23ea810af66392b5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11687,13 +11687,26 @@ impl Editor { rows.end.previous_row().0, buffer.line_len(rows.end.previous_row()), ); - let text = buffer - .text_for_range(start..end) - .chain(Some("\n")) - .collect::(); + + let mut text = buffer.text_for_range(start..end).collect::(); + let insert_location = if upwards { - Point::new(rows.end.0, 0) + // When duplicating upward, we need to insert before the current line. + // If we're on the last line and it doesn't end with a newline, + // we need to add a newline before the duplicated content. + let needs_leading_newline = rows.end.0 >= buffer.max_point().row + && buffer.max_point().column > 0 + && !text.ends_with('\n'); + + if needs_leading_newline { + text.insert(0, '\n'); + end + } else { + text.push('\n'); + Point::new(rows.end.0, 0) + } } else { + text.push('\n'); start }; edits.push((insert_location..insert_location, text)); @@ -12503,9 +12516,18 @@ impl Editor { let mut start = selection.start; let mut end = selection.end; let is_entire_line = selection.is_empty() || self.selections.line_mode(); + let mut add_trailing_newline = false; if is_entire_line { start = Point::new(start.row, 0); - end = cmp::min(max_point, Point::new(end.row + 1, 0)); + let next_line_start = Point::new(end.row + 1, 0); + if next_line_start <= max_point { + end = next_line_start; + } else { + // We're on the last line without a trailing newline. + // Copy to the end of the line and add a newline afterwards. + end = Point::new(end.row, buffer.line_len(MultiBufferRow(end.row))); + add_trailing_newline = true; + } } let mut trimmed_selections = Vec::new(); @@ -12556,6 +12578,10 @@ impl Editor { text.push_str(chunk); len += chunk.len(); } + if add_trailing_newline { + text.push('\n'); + len += 1; + } clipboard_selections.push(ClipboardSelection { len, is_entire_line, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 875d62b23e26cc85fb1d112bbd9042688d0394b9..4dab8ae4742ba9bf14df3393f60ddec752eaf47e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -26478,3 +26478,64 @@ fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { .map(Rgba::from) .collect() } + +#[gpui::test] +fn test_duplicate_line_up_on_last_line_without_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("line1\nline2", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + + assert_eq!( + editor.display_text(cx), + "line1\nline2\nline2", + "Duplicating last line upward should create duplicate above, not on same line" + ); + + assert_eq!( + editor.selections.display_ranges(cx), + vec![DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)], + "Selection should remain on the original line" + ); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_copy_line_without_trailing_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + + assert_eq!( + clipboard_text, + Some("line2\n".to_string()), + "Copying a line without trailing newline should include a newline" + ); + + cx.set_state("line1\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("line1\nline2\nˇ"); +}