Add unique lines command (#7526)

dalton-oliveira created

Changes `Editor::manipulate_lines` to allow line adding and removal
through callback function.

- Added `editor::UniqueLinesCaseSensitive` and `editor::UniqueLinesCaseInsensitive` commands
([#4831](https://github.com/zed-industries/zed/issues/4831))

Change summary

crates/editor/src/actions.rs      |   2 
crates/editor/src/editor.rs       |  72 +++++++++++---
crates/editor/src/editor_tests.rs | 158 +++++++++++++++++++++++++++++++++
crates/editor/src/element.rs      |   2 
4 files changed, 219 insertions(+), 15 deletions(-)

Detailed changes

crates/editor/src/actions.rs 🔗

@@ -238,5 +238,7 @@ gpui::actions!(
         Undo,
         UndoSelection,
         UnfoldLines,
+        UniqueLinesCaseSensitive,
+        UniqueLinesCaseInsensitive
     ]
 );

crates/editor/src/editor.rs 🔗

@@ -4655,6 +4655,28 @@ impl Editor {
         self.manipulate_lines(cx, |lines| lines.sort_by_key(|line| line.to_lowercase()))
     }
 
+    pub fn unique_lines_case_insensitive(
+        &mut self,
+        _: &UniqueLinesCaseInsensitive,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.manipulate_lines(cx, |lines| {
+            let mut seen = HashSet::default();
+            lines.retain(|line| seen.insert(line.to_lowercase()));
+        })
+    }
+
+    pub fn unique_lines_case_sensitive(
+        &mut self,
+        _: &UniqueLinesCaseSensitive,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.manipulate_lines(cx, |lines| {
+            let mut seen = HashSet::default();
+            lines.retain(|line| seen.insert(*line));
+        })
+    }
+
     pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext<Self>) {
         self.manipulate_lines(cx, |lines| lines.reverse())
     }
@@ -4665,7 +4687,7 @@ impl Editor {
 
     fn manipulate_lines<Fn>(&mut self, cx: &mut ViewContext<Self>, mut callback: Fn)
     where
-        Fn: FnMut(&mut [&str]),
+        Fn: FnMut(&mut Vec<&str>),
     {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let buffer = self.buffer.read(cx).snapshot(cx);
@@ -4676,6 +4698,8 @@ impl Editor {
         let mut selections = selections.iter().peekable();
         let mut contiguous_row_selections = Vec::new();
         let mut new_selections = Vec::new();
+        let mut added_lines: usize = 0;
+        let mut removed_lines: usize = 0;
 
         while let Some(selection) = selections.next() {
             let (start_row, end_row) = consume_contiguous_rows(
@@ -4690,37 +4714,55 @@ impl Editor {
             let text = buffer
                 .text_for_range(start_point..end_point)
                 .collect::<String>();
+
             let mut lines = text.split("\n").collect_vec();
 
-            let lines_len = lines.len();
+            let lines_before = lines.len();
             callback(&mut lines);
-
-            // This is a current limitation with selections.
-            // If we wanted to support removing or adding lines, we'd need to fix the logic associated with selections.
-            debug_assert!(
-                lines.len() == lines_len,
-                "callback should not change the number of lines"
-            );
+            let lines_after = lines.len();
 
             edits.push((start_point..end_point, lines.join("\n")));
-            let start_anchor = buffer.anchor_after(start_point);
-            let end_anchor = buffer.anchor_before(end_point);
 
-            // Make selection and push
+            // Selections must change based on added and removed line count
+            let start_row = start_point.row + added_lines as u32 - removed_lines as u32;
+            let end_row = start_row + lines_after.saturating_sub(1) as u32;
             new_selections.push(Selection {
                 id: selection.id,
-                start: start_anchor.to_offset(&buffer),
-                end: end_anchor.to_offset(&buffer),
+                start: start_row,
+                end: end_row,
                 goal: SelectionGoal::None,
                 reversed: selection.reversed,
             });
+
+            if lines_after > lines_before {
+                added_lines += lines_after - lines_before;
+            } else if lines_before > lines_after {
+                removed_lines += lines_before - lines_after;
+            }
         }
 
         self.transact(cx, |this, cx| {
-            this.buffer.update(cx, |buffer, cx| {
+            let buffer = this.buffer.update(cx, |buffer, cx| {
                 buffer.edit(edits, None, cx);
+                buffer.snapshot(cx)
             });
 
+            // Recalculate offsets on newly edited buffer
+            let new_selections = new_selections
+                .iter()
+                .map(|s| {
+                    let start_point = Point::new(s.start, 0);
+                    let end_point = Point::new(s.end, buffer.line_len(s.end));
+                    Selection {
+                        id: s.id,
+                        start: buffer.point_to_offset(start_point),
+                        end: buffer.point_to_offset(end_point),
+                        goal: s.goal,
+                        reversed: s.reversed,
+                    }
+                })
+                .collect();
+
             this.change_selections(Some(Autoscroll::fit()), cx, |s| {
                 s.select(new_selections);
             });

crates/editor/src/editor_tests.rs 🔗

@@ -2786,6 +2786,126 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
         dddˇ»
 
     "});
+
+    // Adding new line
+    cx.set_state(indoc! {"
+        aa«a
+        bbˇ»b
+    "});
+    cx.update_editor(|e, cx| e.manipulate_lines(cx, |lines| lines.push("added_line")));
+    cx.assert_editor_state(indoc! {"
+        «aaa
+        bbb
+        added_lineˇ»
+    "});
+
+    // Removing line
+    cx.set_state(indoc! {"
+        aa«a
+        bbbˇ»
+    "});
+    cx.update_editor(|e, cx| {
+        e.manipulate_lines(cx, |lines| {
+            lines.pop();
+        })
+    });
+    cx.assert_editor_state(indoc! {"
+        «aaaˇ»
+    "});
+
+    // Removing all lines
+    cx.set_state(indoc! {"
+        aa«a
+        bbbˇ»
+    "});
+    cx.update_editor(|e, cx| {
+        e.manipulate_lines(cx, |lines| {
+            lines.drain(..);
+        })
+    });
+    cx.assert_editor_state(indoc! {"
+        ˇ
+    "});
+}
+
+#[gpui::test]
+async fn test_unique_lines_multi_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    // Consider continuous selection as single selection
+    cx.set_state(indoc! {"
+        Aaa«aa
+        cˇ»c«c
+        bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «Aaaaa
+        ccc
+        bb
+        aaaaaˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        Aaa«aa
+        cˇ»c«c
+        bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «Aaaaa
+        ccc
+        bbˇ»
+    "});
+
+    // Consider non continuous selection as distinct dedup operations
+    cx.set_state(indoc! {"
+        «aaaaa
+        bb
+        aaaaa
+        aaaaaˇ»
+
+        aaa«aaˇ»
+    "});
+    cx.update_editor(|e, cx| e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «aaaaa
+        bbˇ»
+
+        «aaaaaˇ»
+    "});
+}
+
+#[gpui::test]
+async fn test_unique_lines_single_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    cx.set_state(indoc! {"
+        «Aaa
+        aAa
+        Aaaˇ»
+    "});
+    cx.update_editor(|e, cx| e.unique_lines_case_sensitive(&UniqueLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «Aaa
+        aAaˇ»
+    "});
+
+    cx.set_state(indoc! {"
+        «Aaa
+        aAa
+        aaAˇ»
+    "});
+    cx.update_editor(|e, cx| e.unique_lines_case_insensitive(&UniqueLinesCaseInsensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «Aaaˇ»
+    "});
 }
 
 #[gpui::test]
@@ -2835,6 +2955,44 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
         ccc
         ddddˇ»
     "});
+
+    // Adding lines on each selection
+    cx.set_state(indoc! {"
+        2«
+        1ˇ»
+
+        bb«bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| e.manipulate_lines(cx, |lines| lines.push("added line")));
+    cx.assert_editor_state(indoc! {"
+        «2
+        1
+        added lineˇ»
+
+        «bbbb
+        aaaaa
+        added lineˇ»
+    "});
+
+    // Removing lines on each selection
+    cx.set_state(indoc! {"
+        2«
+        1ˇ»
+
+        bb«bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| {
+        e.manipulate_lines(cx, |lines| {
+            lines.pop();
+        })
+    });
+    cx.assert_editor_state(indoc! {"
+        «2ˇ»
+
+        «bbbbˇ»
+    "});
 }
 
 #[gpui::test]

crates/editor/src/element.rs 🔗

@@ -330,6 +330,8 @@ impl EditorElement {
         register_action(view, cx, Editor::context_menu_next);
         register_action(view, cx, Editor::context_menu_last);
         register_action(view, cx, Editor::display_cursor_names);
+        register_action(view, cx, Editor::unique_lines_case_insensitive);
+        register_action(view, cx, Editor::unique_lines_case_sensitive);
     }
 
     fn register_key_listeners(