Implement indent conversion editor commands (#32340)

## Description of Feature or Change

Zed currently lacks a built-in way to convert a file’s indentation style
on the fly. While it's possible to change indentation behavior via
global or language-specific settings, these changes are persistent and
broad in scope as they apply to all files or all files of a given
language. We believe this could be improved for quick one-off
adjustments to specific files.

This PR introduces two new editor commands:
`Editor::convert_indentation_to_spaces` and
`Editor::convert_indentation_to_tabs`. These commands allow users to
convert the indentation of either the entire buffer or a selection of
lines, to spaces or tabs. Indentation levels are preserved, and any
mixed whitespace lines are properly normalized.

This feature is inspired by VS Code’s "Convert Indentation to
Tabs/Spaces" commands, but offers faster execution and supports
selection-based conversion, making it more flexible for quick formatting
changes.

## Implementation Details

To enable selection-based indentation conversion, we initially
considered reusing the existing `Editor::manipulate_lines` function,
which handles selections for line-based manipulations. However, this
method was designed specifically for operations like sorting or
reversing lines, and does not allow modifications to the line contents
themselves.

To address this limitation, we refactored the method into a more
flexible version: `Editor::manipulate_generic_lines`. This new method
passes a reference to the selected text directly into a callback, giving
the callback full control over how to process and construct the
resulting lines. The callback returns a `String` containing the modified
text, as well as the number of lines before and after the
transformation. These counts are computed using `.len()` on the line
vectors during manipulation, which is more efficient than calculating
them after the fact.


```rust
fn manipulate_generic_lines<M>(
  &mut self,
  window: &mut Window,
  cx: &mut Context<Self>,
  mut manipulate: M,
) where
   M: FnMut(&str) -> (String, usize, usize),
 {
   // ... Get text from buffer.text_for_range() ...
   let (new_text, lines_before, lines_after) = manipulate(&text);
   // ...
``` 

We now introduce two specialized methods:
`Editor::manipulate_mutable_lines` and
`Editor::manipulate_immutable_lines`. Each editor command selects the
appropriate method based on whether it needs to modify line contents or
simply reorder them. This distinction is important for performance: when
line contents remain unchanged, working with an immutable reference as
`&mut Vec<&str>` is both faster and more memory-efficient than using an
owned `&mut Vec<String>`.

## Demonstration


https://github.com/user-attachments/assets/e50b37ea-a128-4c2a-b252-46c3c4530d97



Release Notes:

- Added `editor::ConvertIndentationToSpaces` and
`editor::ConvertIndentationToTabs` actions to change editor indents

---------

Co-authored-by: Pedro Silveira <pedroruanosilveira@tecnico.ulisboa.pt>
This commit is contained in:
Rodrigo Freire 2025-06-25 13:02:42 +01:00 committed by GitHub
parent 4396ac9dd6
commit c979452c2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 608 additions and 49 deletions

View file

@ -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«\»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
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 \
« 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, |_| {});