Handle multiple selections when joining lines

Nathan Sobo and Conrad Irwin created

Co-Authored-By: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/editor/src/editor.rs       | 65 +++++++++++++++++++++-----------
crates/editor/src/editor_tests.rs | 39 +++++++++++++++++++
2 files changed, 80 insertions(+), 24 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -3955,36 +3955,55 @@ impl Editor {
     }
 
     pub fn join_lines(&mut self, _: &JoinLines, cx: &mut ViewContext<Self>) {
-        let selection = self.selections.newest::<Point>(cx);
-        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let mut row_ranges = Vec::<Range<u32>>::new();
+        for selection in self.selections.ranges::<Point>(cx) {
+            let start = selection.start.row;
+            let end = if selection.start.row == selection.end.row {
+                selection.start.row + 1
+            } else {
+                selection.end.row
+            };
 
-        let row_range = if selection.start.row == selection.end.row {
-            selection.start.row..selection.start.row + 1
-        } else {
-            selection.start.row..selection.end.row
-        };
+            if let Some(last_row_range) = row_ranges.last_mut() {
+                if start <= last_row_range.end {
+                    last_row_range.end = end;
+                    continue;
+                }
+            }
+            row_ranges.push(start..end);
+        }
 
-        self.transact(cx, |this, cx| {
-            for (ix, row) in row_range.rev().enumerate() {
-                let end_of_line = Point::new(row, snapshot.line_len(row));
-                let start_of_next_line = end_of_line + Point::new(1, 0);
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let mut cursor_positions = Vec::new();
+        for row_range in &row_ranges {
+            let anchor = snapshot.anchor_before(Point::new(
+                row_range.end - 1,
+                snapshot.line_len(row_range.end - 1),
+            ));
+            cursor_positions.push(anchor.clone()..anchor);
+        }
 
-                let replace = if snapshot.line_len(row + 1) > 0 {
-                    " "
-                } else {
-                    ""
-                };
+        self.transact(cx, |this, cx| {
+            for row_range in row_ranges.into_iter().rev() {
+                for row in row_range.rev() {
+                    let end_of_line = Point::new(row, snapshot.line_len(row));
+                    let start_of_next_line = end_of_line + Point::new(1, 0);
 
-                this.buffer.update(cx, |buffer, cx| {
-                    buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
-                });
+                    let replace = if snapshot.line_len(row + 1) > 0 {
+                        " "
+                    } else {
+                        ""
+                    };
 
-                if ix == 0 {
-                    this.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                        s.select_ranges([end_of_line..end_of_line])
-                    })
+                    this.buffer.update(cx, |buffer, cx| {
+                        buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
+                    });
                 }
             }
+
+            this.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.select_anchor_ranges(cursor_positions)
+            });
         });
     }
 

crates/editor/src/editor_tests.rs 🔗

@@ -2329,7 +2329,7 @@ fn test_delete_line(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-fn test_join_lines(cx: &mut TestAppContext) {
+fn test_join_lines_with_single_selection(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
 
     cx.add_window(|cx| {
@@ -2342,6 +2342,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             &[Point::new(0, 0)..Point::new(0, 0)]
         );
 
+        // When on single line, replace newline at end by space
         editor.join_lines(&JoinLines, cx);
         assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n");
         assert_eq!(
@@ -2349,6 +2350,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             &[Point::new(0, 3)..Point::new(0, 3)]
         );
 
+        // When multiple lines are selected, remove newlines that are spanned by the selection
         editor.change_selections(None, cx, |s| {
             s.select_ranges([Point::new(0, 5)..Point::new(2, 2)])
         });
@@ -2359,6 +2361,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             &[Point::new(0, 11)..Point::new(0, 11)]
         );
 
+        // Undo should be transactional
         editor.undo(&Undo, cx);
         assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n");
         assert_eq!(
@@ -2366,6 +2369,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             &[Point::new(0, 5)..Point::new(2, 2)]
         );
 
+        // When joining an empty line don't insert a space
         editor.change_selections(None, cx, |s| {
             s.select_ranges([Point::new(2, 1)..Point::new(2, 2)])
         });
@@ -2376,6 +2380,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             [Point::new(2, 3)..Point::new(2, 3)]
         );
 
+        // We can remove trailing newlines
         editor.join_lines(&JoinLines, cx);
         assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd");
         assert_eq!(
@@ -2383,6 +2388,7 @@ fn test_join_lines(cx: &mut TestAppContext) {
             [Point::new(2, 3)..Point::new(2, 3)]
         );
 
+        // We don't blow up on the last line
         editor.join_lines(&JoinLines, cx);
         assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd");
         assert_eq!(
@@ -2394,6 +2400,37 @@ fn test_join_lines(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    cx.add_window(|cx| {
+        let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx);
+        let mut editor = build_editor(buffer.clone(), cx);
+        let buffer = buffer.read(cx).as_singleton().unwrap();
+
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([
+                Point::new(0, 2)..Point::new(1, 1),
+                Point::new(1, 2)..Point::new(1, 2),
+                Point::new(3, 1)..Point::new(3, 2),
+            ])
+        });
+
+        editor.join_lines(&JoinLines, cx);
+        assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n");
+
+        assert_eq!(
+            editor.selections.ranges::<Point>(cx),
+            [
+                Point::new(0, 7)..Point::new(0, 7),
+                Point::new(1, 3)..Point::new(1, 3)
+            ]
+        );
+        editor
+    });
+}
+
 #[gpui::test]
 fn test_duplicate_line(cx: &mut TestAppContext) {
     init_test(cx, |_| {});