From 014bf4c332d58ae9e23166d658d594272588daad Mon Sep 17 00:00:00 2001 From: Austin Cummings Date: Tue, 17 Feb 2026 06:45:36 -0700 Subject: [PATCH] editor: Fix newline below when selection is at the end of an multibuffer excerpt (#49132) Switches `newline_below` from adding a newline to the start of the next line in a multibuffer, to appending a newline at the end of the selections current lines. This keeps the insertion within the excerpt of the selection rather than adding a newline to the excerpt below. Closes #47965 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed `editor::NewlineBelow` adding a newline to the next multibuffer excerpt when selection is at the end of an excerpt --- crates/editor/src/editor.rs | 44 +++-- crates/editor/src/editor_tests.rs | 259 ++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 14 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 03d19d8b8de674b3b120e28adfe0831baeede833..132425d2139e11d72b6ac70f6435abdad87e7c2d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -114,11 +114,11 @@ use git::blame::{GitBlame, GlobalBlameRenderer}; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, - DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, - Focusable, FontId, FontStyle, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage, - Render, ScrollHandle, SharedString, SharedUri, Size, Stateful, Styled, Subscription, Task, - TextRun, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, + DispatchPhase, Edges, Entity, EntityId, EntityInputHandler, EventEmitter, FocusHandle, + FocusOutEvent, Focusable, FontId, FontStyle, FontWeight, Global, HighlightStyle, Hsla, + KeyContext, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, + Pixels, PressureStage, Render, ScrollHandle, SharedString, SharedUri, Size, Stateful, Styled, + Subscription, Task, TextRun, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, }; @@ -5357,10 +5357,7 @@ impl Editor { pub fn newline_below(&mut self, _: &NewlineBelow, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - - let mut edits = Vec::new(); + let mut buffer_edits: HashMap, Vec)> = HashMap::default(); let mut rows = Vec::new(); let mut rows_inserted = 0; @@ -5368,18 +5365,37 @@ impl Editor { let cursor = selection.head(); let row = cursor.row; - let point = Point::new(row + 1, 0); - let start_of_line = snapshot.clip_point(point, Bias::Left); + let point = Point::new(row, 0); + let Some((buffer_handle, buffer_point, _)) = + self.buffer.read(cx).point_to_buffer_point(point, cx) + else { + continue; + }; - let newline = "\n".to_string(); - edits.push((start_of_line..start_of_line, newline)); + buffer_edits + .entry(buffer_handle.entity_id()) + .or_insert_with(|| (buffer_handle, Vec::new())) + .1 + .push(buffer_point); rows_inserted += 1; rows.push(row + rows_inserted); } self.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); + for (_, (buffer_handle, points)) in &buffer_edits { + buffer_handle.update(cx, |buffer, cx| { + let edits: Vec<_> = points + .iter() + .map(|point| { + let target = Point::new(point.row + 1, 0); + let start_of_line = buffer.point_to_offset(target).min(buffer.len()); + (start_of_line..start_of_line, "\n") + }) + .collect(); + buffer.edit(edits, None, cx); + }); + } editor.change_selections(Default::default(), window, cx, |s| { let mut index = 0; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 654770350785c7a0a88c12d8a6b13fee7d77063d..6788f899d5ce0ffa933a927a2ffbc18163e75935 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3385,6 +3385,265 @@ async fn test_newline_below(cx: &mut TestAppContext) { "}); } +#[gpui::test] +fn test_newline_below_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + assert_eq!( + editor.text(cx), + indoc! {" + aaa + bbb + ccc + ddd + eee + fff"} + ); + + // Cursor on the last line of the first excerpt. + // The newline should be inserted within the first excerpt (buffer_1), + // not in the second excerpt (buffer_2). + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + cˇcc + ddd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ˇ + ddd + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_middle_of_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursor in the middle of the first excerpt. + select_ranges( + &mut editor, + indoc! {" + aˇaa + bbb + ccc + ddd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + ˇ + bbb + ccc + ddd + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\n\nbbb\nccc"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_last_line_of_last_excerpt(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursor on the last line of the last excerpt. + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + ccc + ddd + eee + fˇff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ddd + eee + fff + ˇ"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\neee\nfff\n"); + }); + + editor + }); +} + +#[gpui::test] +fn test_newline_below_multibuffer_multiple_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb\nccc", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ddd\neee\nfff", cx)); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 3))], + cx, + ); + multibuffer + }); + + cx.add_window(|window, cx| { + let mut editor = build_editor(multibuffer, window, cx); + + // Cursors on the last line of the first excerpt and the first line + // of the second excerpt. Each newline should go into its respective buffer. + select_ranges( + &mut editor, + indoc! {" + aaa + bbb + cˇcc + dˇdd + eee + fff"}, + window, + cx, + ); + editor.newline_below(&NewlineBelow, window, cx); + assert_text_with_selections( + &mut editor, + indoc! {" + aaa + bbb + ccc + ˇ + ddd + ˇ + eee + fff"}, + cx, + ); + buffer_1.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "aaa\nbbb\nccc\n"); + }); + buffer_2.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "ddd\n\neee\nfff"); + }); + + editor + }); +} + #[gpui::test] async fn test_newline_comments(cx: &mut TestAppContext) { init_test(cx, |settings| {