diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6051d7f4848..3839a9bb685 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7418,13 +7418,9 @@ impl Editor { } let buffer = self.buffer.read(cx).snapshot(cx); - let mut new_selections = Vec::new(); - let mut edits = Vec::new(); - + let mut selections = Vec::new(); for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { - let selection_is_empty = selection.is_empty(); - - let (start, end) = if selection_is_empty { + let (start, end) = if selection.is_empty() { let (word_range, _) = buffer.surrounding_word(selection.start, None); (word_range.start, word_range.end) } else { @@ -7433,20 +7429,48 @@ impl Editor { buffer.point_to_offset(selection.end), ) }; + selections.push(Selection { + id: selection.id, + start, + end, + reversed: selection.reversed, + goal: SelectionGoal::None, + }); + } - let old_text = buffer.text_for_range(start..end).collect::(); + // Expanding cursors to words can make selections overlap, e.g. several + // cursors in one word. Overlapping edits would be applied once per + // selection and duplicate the text, so transform each merged range once. + selections.sort_by_key(|selection| selection.start); + let mut merged_selections: Vec> = Vec::new(); + for selection in selections { + if let Some(previous) = merged_selections.last_mut() + && selection.start < previous.end + { + previous.end = cmp::max(previous.end, selection.end); + } else { + merged_selections.push(selection); + } + } + + let mut new_selections = Vec::new(); + let mut edits = Vec::new(); + for selection in merged_selections { + let old_text = buffer + .text_for_range(selection.start..selection.end) + .collect::(); let new_text = callback(&old_text); new_selections.push(Selection { - start: buffer.anchor_before(start), - end: buffer.anchor_after(end), + start: buffer.anchor_before(selection.start), + end: buffer.anchor_after(selection.end), goal: SelectionGoal::None, id: selection.id, reversed: selection.reversed, }); if new_text != old_text { - edits.push((start..end, new_text)); + edits.push((selection.start..selection.end, new_text)); } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 09465235150..5e255697cfc 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8911,6 +8911,44 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_multicursor_manipulate_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let mut failures = Vec::new(); + for (before, expected, to_upper) in [ + ("some_ˇvariˇable_naˇme", "«SOME_VARIABLE_NAMEˇ»", true), + ("some_ˇvariˇable naˇme", "«SOME_VARIABLEˇ» «NAMEˇ»", true), + ("SOME_ˇVARIˇABLE_NAˇME", "«some_variable_nameˇ»", false), + ("SOME_ˇVARIˇABLE NAˇME", "«some_variableˇ» «nameˇ»", false), + ("«word1ˇ» woˇr«d2 word3ˇ»", "«WORD1ˇ» «WORD2 WORD3ˇ»", true), + ("«aˇ»b«cdˇ»eˇf", "«ABCDEFˇ»", true), + ("hello woˇrˇld", "hello woˇrˇld", false), + ] { + cx.set_state(before); + cx.update_editor(|editor, window, cx| { + if to_upper { + editor.convert_to_upper_case(&ConvertToUpperCase, window, cx) + } else { + editor.convert_to_lower_case(&ConvertToLowerCase, window, cx) + } + }); + let actual = cx.editor_state(); + if actual != expected { + failures.push(format!( + "before: {before}\nexpected: {expected}\nactual: {actual}" + )); + } + } + assert!( + failures.is_empty(), + "{} case(s) failed:\n\n{}", + failures.len(), + failures.join("\n\n") + ); +} + #[gpui::test] fn test_duplicate_line(cx: &mut TestAppContext) { init_test(cx, |_| {});