diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs index 607daa8ce3..a070738b60 100644 --- a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs @@ -9132,7 +9132,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| lines.sort()) + self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) } pub fn sort_lines_case_insensitive( @@ -9141,7 +9141,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { lines.sort_by_key(|line| line.to_lowercase()) }) } @@ -9152,7 +9152,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(line.to_lowercase())); }) @@ -9164,7 +9164,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(*line)); }) @@ -9606,20 +9606,20 @@ impl Editor { } pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.reverse()) + self.manipulate_immutable_lines(window, cx, |lines| lines.reverse()) } pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) + self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) } - fn manipulate_lines( + fn manipulate_lines( &mut self, window: &mut Window, cx: &mut Context, - mut callback: Fn, + mut manipulate: M, ) where - Fn: FnMut(&mut Vec<&str>), + M: FnMut(&str) -> LineManipulationResult, { self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction); @@ -9652,18 +9652,14 @@ impl Editor { .text_for_range(start_point..end_point) .collect::(); - let mut lines = text.split('\n').collect_vec(); + let LineManipulationResult { new_text, line_count_before, line_count_after} = manipulate(&text); - let lines_before = lines.len(); - callback(&mut lines); - let lines_after = lines.len(); - - edits.push((start_point..end_point, lines.join("\n"))); + edits.push((start_point..end_point, new_text)); // Selections must change based on added and removed line count let start_row = MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32); - let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32); + let end_row = MultiBufferRow(start_row.0 + line_count_after.saturating_sub(1) as u32); new_selections.push(Selection { id: selection.id, start: start_row, @@ -9672,10 +9668,10 @@ impl Editor { 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; + if line_count_after > line_count_before { + added_lines += line_count_after - line_count_before; + } else if line_count_before > line_count_after { + removed_lines += line_count_before - line_count_after; } } @@ -9720,6 +9716,171 @@ impl Editor { }) } + fn manipulate_immutable_lines( + &mut self, + window: &mut Window, + cx: &mut Context, + mut callback: Fn, + ) where + Fn: FnMut(&mut Vec<&str>), + { + self.manipulate_lines(window, cx, |text| { + let mut lines: Vec<&str> = text.split('\n').collect(); + let line_count_before = lines.len(); + + callback(&mut lines); + + LineManipulationResult { + new_text: lines.join("\n"), + line_count_before, + line_count_after: lines.len(), + } + }); + } + + fn manipulate_mutable_lines( + &mut self, + window: &mut Window, + cx: &mut Context, + mut callback: Fn, + ) where + Fn: FnMut(&mut Vec>), + { + self.manipulate_lines(window, cx, |text| { + let mut lines: Vec> = text.split('\n').map(Cow::from).collect(); + let line_count_before = lines.len(); + + callback(&mut lines); + + LineManipulationResult { + new_text: lines.join("\n"), + line_count_before, + line_count_after: lines.len(), + } + }); + } + + pub fn convert_indentation_to_spaces( + &mut self, + _: &ConvertIndentationToSpaces, + window: &mut Window, + cx: &mut Context, + ) { + let settings = self.buffer.read(cx).language_settings(cx); + let tab_size = settings.tab_size.get() as usize; + + self.manipulate_mutable_lines(window, cx, |lines| { + // Allocates a reasonably sized scratch buffer once for the whole loop + let mut reindented_line = String::with_capacity(MAX_LINE_LEN); + // Avoids recomputing spaces that could be inserted many times + let space_cache: Vec> = (1..=tab_size) + .map(|n| IndentSize::spaces(n as u32).chars().collect()) + .collect(); + + for line in lines.iter_mut().filter(|line| !line.is_empty()) { + let mut chars = line.as_ref().chars(); + let mut col = 0; + let mut changed = false; + + while let Some(ch) = chars.next() { + match ch { + ' ' => { + reindented_line.push(' '); + col += 1; + } + '\t' => { + // \t are converted to spaces depending on the current column + let spaces_len = tab_size - (col % tab_size); + reindented_line.extend(&space_cache[spaces_len - 1]); + col += spaces_len; + changed = true; + } + _ => { + // If we dont append before break, the character is consumed + reindented_line.push(ch); + break; + } + } + } + + if !changed { + reindented_line.clear(); + continue; + } + // Append the rest of the line and replace old reference with new one + reindented_line.extend(chars); + *line = Cow::Owned(reindented_line.clone()); + reindented_line.clear(); + } + }); + } + + pub fn convert_indentation_to_tabs( + &mut self, + _: &ConvertIndentationToTabs, + window: &mut Window, + cx: &mut Context, + ) { + let settings = self.buffer.read(cx).language_settings(cx); + let tab_size = settings.tab_size.get() as usize; + + self.manipulate_mutable_lines(window, cx, |lines| { + // Allocates a reasonably sized buffer once for the whole loop + let mut reindented_line = String::with_capacity(MAX_LINE_LEN); + // Avoids recomputing spaces that could be inserted many times + let space_cache: Vec> = (1..=tab_size) + .map(|n| IndentSize::spaces(n as u32).chars().collect()) + .collect(); + + for line in lines.iter_mut().filter(|line| !line.is_empty()) { + let mut chars = line.chars(); + let mut spaces_count = 0; + let mut first_non_indent_char = None; + let mut changed = false; + + while let Some(ch) = chars.next() { + match ch { + ' ' => { + // Keep track of spaces. Append \t when we reach tab_size + spaces_count += 1; + changed = true; + if spaces_count == tab_size { + reindented_line.push('\t'); + spaces_count = 0; + } + } + '\t' => { + reindented_line.push('\t'); + spaces_count = 0; + } + _ => { + // Dont append it yet, we might have remaining spaces + first_non_indent_char = Some(ch); + break; + } + } + } + + if !changed { + reindented_line.clear(); + continue; + } + // Remaining spaces that didn't make a full tab stop + if spaces_count > 0 { + reindented_line.extend(&space_cache[spaces_count - 1]); + } + // If we consume an extra character that was not indentation, add it back + if let Some(extra_char) = first_non_indent_char { + reindented_line.push(extra_char); + } + // Append the rest of the line and replace old reference with new one + reindented_line.extend(chars); + *line = Cow::Owned(reindented_line.clone()); + reindented_line.clear(); + } + }); + } + pub fn convert_to_upper_case( &mut self, _: &ConvertToUpperCase, @@ -21157,6 +21318,13 @@ pub struct LineHighlight { pub type_id: Option, } +struct LineManipulationResult { + pub new_text: String, + pub line_count_before: usize, + pub line_count_after: usize, +} + + fn render_diff_hunk_controls( row: u32, status: &DiffHunkStatus, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index b8a3e5efa7..ff6263dfa7 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -270,6 +270,8 @@ actions!( ContextMenuLast, ContextMenuNext, ContextMenuPrevious, + ConvertIndentationToSpaces, + ConvertIndentationToTabs, ConvertToKebabCase, ConvertToLowerCamelCase, ConvertToLowerCase, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 770ad7fa70..ddecdcabcf 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10080,7 +10080,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| lines.sort()) + self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) } pub fn sort_lines_case_insensitive( @@ -10089,7 +10089,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { lines.sort_by_key(|line| line.to_lowercase()) }) } @@ -10100,7 +10100,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(line.to_lowercase())); }) @@ -10112,7 +10112,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_lines(window, cx, |lines| { + self.manipulate_immutable_lines(window, cx, |lines| { let mut seen = HashSet::default(); lines.retain(|line| seen.insert(*line)); }) @@ -10555,20 +10555,20 @@ impl Editor { } pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.reverse()) + self.manipulate_immutable_lines(window, cx, |lines| lines.reverse()) } pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { - self.manipulate_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) + self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) } - fn manipulate_lines( + fn manipulate_lines( &mut self, window: &mut Window, cx: &mut Context, - mut callback: Fn, + mut manipulate: M, ) where - Fn: FnMut(&mut Vec<&str>), + M: FnMut(&str) -> LineManipulationResult, { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); @@ -10601,18 +10601,18 @@ impl Editor { .text_for_range(start_point..end_point) .collect::(); - let mut lines = text.split('\n').collect_vec(); + let LineManipulationResult { + new_text, + line_count_before, + line_count_after, + } = manipulate(&text); - let lines_before = lines.len(); - callback(&mut lines); - let lines_after = lines.len(); - - edits.push((start_point..end_point, lines.join("\n"))); + edits.push((start_point..end_point, new_text)); // Selections must change based on added and removed line count let start_row = MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32); - let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32); + let end_row = MultiBufferRow(start_row.0 + line_count_after.saturating_sub(1) as u32); new_selections.push(Selection { id: selection.id, start: start_row, @@ -10621,10 +10621,10 @@ impl Editor { 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; + if line_count_after > line_count_before { + added_lines += line_count_after - line_count_before; + } else if line_count_before > line_count_after { + removed_lines += line_count_before - line_count_after; } } @@ -10669,6 +10669,171 @@ impl Editor { }) } + fn manipulate_immutable_lines( + &mut self, + window: &mut Window, + cx: &mut Context, + mut callback: Fn, + ) where + Fn: FnMut(&mut Vec<&str>), + { + self.manipulate_lines(window, cx, |text| { + let mut lines: Vec<&str> = text.split('\n').collect(); + let line_count_before = lines.len(); + + callback(&mut lines); + + LineManipulationResult { + new_text: lines.join("\n"), + line_count_before, + line_count_after: lines.len(), + } + }); + } + + fn manipulate_mutable_lines( + &mut self, + window: &mut Window, + cx: &mut Context, + mut callback: Fn, + ) where + Fn: FnMut(&mut Vec>), + { + self.manipulate_lines(window, cx, |text| { + let mut lines: Vec> = text.split('\n').map(Cow::from).collect(); + let line_count_before = lines.len(); + + callback(&mut lines); + + LineManipulationResult { + new_text: lines.join("\n"), + line_count_before, + line_count_after: lines.len(), + } + }); + } + + pub fn convert_indentation_to_spaces( + &mut self, + _: &ConvertIndentationToSpaces, + window: &mut Window, + cx: &mut Context, + ) { + let settings = self.buffer.read(cx).language_settings(cx); + let tab_size = settings.tab_size.get() as usize; + + self.manipulate_mutable_lines(window, cx, |lines| { + // Allocates a reasonably sized scratch buffer once for the whole loop + let mut reindented_line = String::with_capacity(MAX_LINE_LEN); + // Avoids recomputing spaces that could be inserted many times + let space_cache: Vec> = (1..=tab_size) + .map(|n| IndentSize::spaces(n as u32).chars().collect()) + .collect(); + + for line in lines.iter_mut().filter(|line| !line.is_empty()) { + let mut chars = line.as_ref().chars(); + let mut col = 0; + let mut changed = false; + + while let Some(ch) = chars.next() { + match ch { + ' ' => { + reindented_line.push(' '); + col += 1; + } + '\t' => { + // \t are converted to spaces depending on the current column + let spaces_len = tab_size - (col % tab_size); + reindented_line.extend(&space_cache[spaces_len - 1]); + col += spaces_len; + changed = true; + } + _ => { + // If we dont append before break, the character is consumed + reindented_line.push(ch); + break; + } + } + } + + if !changed { + reindented_line.clear(); + continue; + } + // Append the rest of the line and replace old reference with new one + reindented_line.extend(chars); + *line = Cow::Owned(reindented_line.clone()); + reindented_line.clear(); + } + }); + } + + pub fn convert_indentation_to_tabs( + &mut self, + _: &ConvertIndentationToTabs, + window: &mut Window, + cx: &mut Context, + ) { + let settings = self.buffer.read(cx).language_settings(cx); + let tab_size = settings.tab_size.get() as usize; + + self.manipulate_mutable_lines(window, cx, |lines| { + // Allocates a reasonably sized buffer once for the whole loop + let mut reindented_line = String::with_capacity(MAX_LINE_LEN); + // Avoids recomputing spaces that could be inserted many times + let space_cache: Vec> = (1..=tab_size) + .map(|n| IndentSize::spaces(n as u32).chars().collect()) + .collect(); + + for line in lines.iter_mut().filter(|line| !line.is_empty()) { + let mut chars = line.chars(); + let mut spaces_count = 0; + let mut first_non_indent_char = None; + let mut changed = false; + + while let Some(ch) = chars.next() { + match ch { + ' ' => { + // Keep track of spaces. Append \t when we reach tab_size + spaces_count += 1; + changed = true; + if spaces_count == tab_size { + reindented_line.push('\t'); + spaces_count = 0; + } + } + '\t' => { + reindented_line.push('\t'); + spaces_count = 0; + } + _ => { + // Dont append it yet, we might have remaining spaces + first_non_indent_char = Some(ch); + break; + } + } + } + + if !changed { + reindented_line.clear(); + continue; + } + // Remaining spaces that didn't make a full tab stop + if spaces_count > 0 { + reindented_line.extend(&space_cache[spaces_count - 1]); + } + // If we consume an extra character that was not indentation, add it back + if let Some(extra_char) = first_non_indent_char { + reindented_line.push(extra_char); + } + // Append the rest of the line and replace old reference with new one + reindented_line.extend(chars); + *line = Cow::Owned(reindented_line.clone()); + reindented_line.clear(); + } + }); + } + pub fn convert_to_upper_case( &mut self, _: &ConvertToUpperCase, @@ -22941,6 +23106,12 @@ pub struct LineHighlight { pub type_id: Option, } +struct LineManipulationResult { + pub new_text: String, + pub line_count_before: usize, + pub line_count_after: usize, +} + fn render_diff_hunk_controls( row: u32, status: &DiffHunkStatus, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 6a579cb1cd..3671653e16 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3976,7 +3976,7 @@ async fn test_custom_newlines_cause_no_false_positive_diffs( } #[gpui::test] -async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { +async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; @@ -4021,8 +4021,8 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { // Skip testing shuffle_line() - // From here on out, test more complex cases of manipulate_lines() with a single driver method: sort_lines_case_sensitive() - // Since all methods calling manipulate_lines() are doing the exact same general thing (reordering lines) + // From here on out, test more complex cases of manipulate_immutable_lines() with a single driver method: sort_lines_case_sensitive() + // Since all methods calling manipulate_immutable_lines() are doing the exact same general thing (reordering lines) // Don't manipulate when cursor is on single line, but expand the selection cx.set_state(indoc! {" @@ -4089,7 +4089,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { bbˇ»b "}); cx.update_editor(|e, window, cx| { - e.manipulate_lines(window, cx, |lines| lines.push("added_line")) + e.manipulate_immutable_lines(window, cx, |lines| lines.push("added_line")) }); cx.assert_editor_state(indoc! {" «aaa @@ -4103,7 +4103,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { bbbˇ» "}); cx.update_editor(|e, window, cx| { - e.manipulate_lines(window, cx, |lines| { + e.manipulate_immutable_lines(window, cx, |lines| { lines.pop(); }) }); @@ -4117,7 +4117,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { bbbˇ» "}); cx.update_editor(|e, window, cx| { - e.manipulate_lines(window, cx, |lines| { + e.manipulate_immutable_lines(window, cx, |lines| { lines.drain(..); }) }); @@ -4217,7 +4217,7 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) { +async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; @@ -4277,7 +4277,7 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) { aaaˇ»aa "}); cx.update_editor(|e, window, cx| { - e.manipulate_lines(window, cx, |lines| lines.push("added line")) + e.manipulate_immutable_lines(window, cx, |lines| lines.push("added line")) }); cx.assert_editor_state(indoc! {" «2 @@ -4298,7 +4298,7 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) { aaaˇ»aa "}); cx.update_editor(|e, window, cx| { - e.manipulate_lines(window, cx, |lines| { + e.manipulate_immutable_lines(window, cx, |lines| { lines.pop(); }) }); @@ -4309,6 +4309,222 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_convert_indentation_to_spaces(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + + // MULTI SELECTION + // Ln.1 "«" tests empty lines + // Ln.9 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentationˇ» + «\tabc // 1 tabˇ» + \t\tabc « ˇ» // 2 tabs + \t ab«c // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abˇ»ˇc ˇ ˇ // Already space indented« + \t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 tab + abc // 2 tabs + abc // Tab followed by space + abc // Space followed by tab (3 spaces should be the result) + abc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + + abc\tdef // Only the leading tab is manipulatedˇ» + "}); + + // Test on just a few lines, the others should remain unchanged + // Only lines (3, 5, 10, 11) should change + cx.set_state(indoc! {" + + abc // No indentation + \tabcˇ // 1 tab + \t\tabc // 2 tabs + \t abcˇ // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + «\t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state(indoc! {" + + abc // No indentation + « abc // 1 tabˇ» + \t\tabc // 2 tabs + « abc // Tab followed by spaceˇ» + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + « + abc\tdef // Only the leading tab is manipulatedˇ» + "}); + + // SINGLE SELECTION + // Ln.1 "«" tests empty lines + // Ln.9 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentation + \tabc // 1 tab + \t\tabc // 2 tabs + \t abc // Tab followed by space + \tabc // Space followed by tab (3 spaces should be the result) + \t \t \t \tabc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + \t + \tabc\tdef // Only the leading tab is manipulatedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 tab + abc // 2 tabs + abc // Tab followed by space + abc // Space followed by tab (3 spaces should be the result) + abc // Mixed indentation (tab conversion depends on the column) + abc // Already space indented + + abc\tdef // Only the leading tab is manipulatedˇ» + "}); +} + +#[gpui::test] +async fn test_convert_indentation_to_tabs(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(3) + }); + + let mut cx = EditorTestContext::new(cx).await; + + // MULTI SELECTION + // Ln.1 "«" tests empty lines + // Ln.11 tests just leading whitespace + cx.set_state(indoc! {" + « + abˇ»ˇc // No indentation + abc ˇ ˇ // 1 space (< 3 so dont convert) + abc « // 2 spaces (< 3 so dont convert) + abc // 3 spaces (convert) + abc ˇ» // 5 spaces (1 tab + 2 spaces) + «\tˇ»\t«\tˇ»abc // Already tab indented + «\t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \tˇ» «\t + abcˇ» \t ˇˇˇ // Only the leading spaces should be converted + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + \tabc // 3 spaces (convert) + \t abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "}); + + // Test on just a few lines, the other should remain unchanged + // Only lines (4, 8, 11, 12) should change + cx.set_state(indoc! {" + + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + « abc // 3 spaces (convert)ˇ» + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc ˇ // Space followed by tab (should be consumed due to tab) + \t\t \tabc // Mixed indentation + \t \t \t \tabc // Mixed indentation + \t \tˇ + « abc \t // Only the leading spaces should be convertedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state(indoc! {" + + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + «\tabc // 3 spaces (convert)ˇ» + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + «\tabc // Space followed by tab (should be consumed due to tab)ˇ» + \t\t \tabc // Mixed indentation + \t \t \t \tabc // Mixed indentation + «\t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "}); + + // SINGLE SELECTION + // Ln.1 "«" tests empty lines + // Ln.11 tests just leading whitespace + cx.set_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + abc // 3 spaces (convert) + abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t \t \t \tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t \t + abc \t // Only the leading spaces should be convertedˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx); + }); + cx.assert_editor_state(indoc! {" + « + abc // No indentation + abc // 1 space (< 3 so dont convert) + abc // 2 spaces (< 3 so dont convert) + \tabc // 3 spaces (convert) + \t abc // 5 spaces (1 tab + 2 spaces) + \t\t\tabc // Already tab indented + \t abc // Tab followed by space + \tabc // Space followed by tab (should be consumed due to tab) + \t\t\t\t\tabc // Mixed indentation (first 3 spaces are consumed, the others are converted) + \t\t\t + \tabc \t // Only the leading spaces should be convertedˇ» + "}); +} + #[gpui::test] async fn test_toggle_case(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b002a96de8..602a0579b3 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -230,6 +230,8 @@ impl EditorElement { register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); register_action(editor, window, Editor::toggle_case); + register_action(editor, window, Editor::convert_indentation_to_spaces); + register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case); register_action(editor, window, Editor::convert_to_lower_case); register_action(editor, window, Editor::convert_to_title_case);