editor: Add align selections action (#44769)

Closes #17090

Hello! This is a feature that I really wanted, so I've created an
implementation of it. Hope u guys like it.
It was alto mentioned at [this
discussion](https://github.com/zed-industries/zed/discussions/30473)

The use cases were based on the [vscode
extension](https://marketplace.visualstudio.com/items?itemName=yo1dog.cursor-align)
(only spaces case was implemented)
- The algorithm is O(n + m × 2k), where:
- n = total cursors (for initial analysis, to understand how many
alignments will be performed)
  - m = number of alignment columns  
- k = number of alignment rows (first time to get the most-right
occurrence, the next one to create the `Vec` of edits)


Release Notes:

- Added `editor: align selections` command


![ezgif-524b631d71833ba1](https://github.com/user-attachments/assets/28c3435a-2e01-496f-81c4-721ecfb5b22d)

<br>

Thank you for creating zed!

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Tiago LobãoandKirill Bulatov authored and GitHub committed 2026-03-18 12:55:04 +00:00
1 parent be3a5e2c06
commit c7870cb93d
4 files changed
+243

No files matched your search

+2
View File
@@ -883,6 +883,8 @@ actions!(
UnwrapSyntaxNode,
/// Wraps selections in tag specified by language.
WrapSelectionsInTag,
/// Aligns selections from different rows into the same column
AlignSelections,
]
);
+92
View File
@@ -11839,6 +11839,98 @@ impl Editor {
}
}
pub fn align_selections(
&mut self,
_: &crate::actions::AlignSelections,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
let display_snapshot = self.display_snapshot(cx);
struct CursorData {
anchor: Anchor,
point: Point,
}
let cursor_data: Vec<CursorData> = self
.selections
.disjoint_anchors()
.iter()
.map(|selection| {
let anchor = if selection.reversed {
selection.head()
} else {
selection.tail()
};
CursorData {
anchor: anchor,
point: anchor.to_point(&display_snapshot.buffer_snapshot()),
}
})
.collect();
let rows_anchors_count: Vec<usize> = cursor_data
.iter()
.map(|cursor| cursor.point.row)
.chunk_by(|&row| row)
.into_iter()
.map(|(_, group)| group.count())
.collect();
let max_columns = rows_anchors_count.iter().max().copied().unwrap_or(0);
let mut rows_column_offset = vec![0; rows_anchors_count.len()];
let mut edits = Vec::new();
for column_idx in 0..max_columns {
let mut cursor_index = 0;
// Calculate target_column => position that the selections will go
let mut target_column = 0;
for (row_idx, cursor_count) in rows_anchors_count.iter().enumerate() {
// Skip rows that don't have this column
if column_idx >= *cursor_count {
cursor_index += cursor_count;
continue;
}
let point = &cursor_data[cursor_index + column_idx].point;
let adjusted_column = point.column + rows_column_offset[row_idx];
if adjusted_column > target_column {
target_column = adjusted_column;
}
cursor_index += cursor_count;
}
// Collect edits for this column
cursor_index = 0;
for (row_idx, cursor_count) in rows_anchors_count.iter().enumerate() {
// Skip rows that don't have this column
if column_idx >= *cursor_count {
cursor_index += *cursor_count;
continue;
}
let point = &cursor_data[cursor_index + column_idx].point;
let spaces_needed = target_column - point.column - rows_column_offset[row_idx];
if spaces_needed > 0 {
let anchor = cursor_data[cursor_index + column_idx]
.anchor
.bias_left(&display_snapshot);
edits.push((anchor..anchor, " ".repeat(spaces_needed as usize)));
}
rows_column_offset[row_idx] += spaces_needed;
cursor_index += *cursor_count;
}
}
if !edits.is_empty() {
self.transact(window, cx, |editor, _window, cx| {
editor.edit(edits, cx);
});
}
}
pub fn disable_breakpoint(
&mut self,
_: &crate::actions::DisableBreakpoint,
+148
View File
@@ -34119,3 +34119,151 @@ async fn test_restore_and_next(cx: &mut TestAppContext) {
.unindent(),
);
}
#[gpui::test]
async fn test_align_selections(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// 1) one cursor, no action
let before = " abc\n abc\nabc\n ˇabc";
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(before);
// 2) multiple cursors at different rows
let before = indoc!(
r#"
let aˇbc = 123;
let xˇyz = 456;
let fˇoo = 789;
let bˇar = 0;
"#
);
let after = indoc!(
r#"
let a ˇbc = 123;
let x ˇyz = 456;
let f ˇoo = 789;
let bˇar = 0;
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
// 3) multiple selections at different rows
let before = indoc!(
r#"
let «ˇabc» = 123;
let «ˇxyz» = 456;
let «ˇfoo» = 789;
let «ˇbar» = 0;
"#
);
let after = indoc!(
r#"
let «ˇabc» = 123;
let «ˇxyz» = 456;
let «ˇfoo» = 789;
let «ˇbar» = 0;
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
// 4) multiple selections at different rows, inverted head
let before = indoc!(
r#"
let «abcˇ» = 123;
// comment
let «xyzˇ» = 456;
let «fooˇ» = 789;
let «barˇ» = 0;
"#
);
let after = indoc!(
r#"
let «abcˇ» = 123;
// comment
let «xyzˇ» = 456;
let «fooˇ» = 789;
let «barˇ» = 0;
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
}
#[gpui::test]
async fn test_align_selections_multicolumn(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// 1) Multicolumn, one non affected editor row
let before = indoc!(
r#"
name «|ˇ» age «|ˇ» height «|ˇ» note
Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart
Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy
Anything that is not selected
Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny
"#
);
let after = indoc!(
r#"
name «|ˇ» age «|ˇ» height «|ˇ» note
Matthew «|ˇ» 7 «|ˇ» 2333 «|ˇ» smart
Mike «|ˇ» 1234 «|ˇ» 567 «|ˇ» lazy
Anything that is not selected
Miles «|ˇ» 88 «|ˇ» 99 «|ˇ» funny
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
// 2) not all alignment rows has the number of alignment columns
let before = indoc!(
r#"
name «|ˇ» age «|ˇ» height
Matthew «|ˇ» 7 «|ˇ» 2333
Mike «|ˇ» 1234
Miles «|ˇ» 88 «|ˇ» 99
"#
);
let after = indoc!(
r#"
name «|ˇ» age «|ˇ» height
Matthew «|ˇ» 7 «|ˇ» 2333
Mike «|ˇ» 1234
Miles «|ˇ» 88 «|ˇ» 99
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
// 3) A aligned column shall stay aligned
let before = indoc!(
r#"
$ ˇa ˇa
$ ˇa ˇa
$ ˇa ˇa
$ ˇa ˇa
"#
);
let after = indoc!(
r#"
$ ˇa ˇa
$ ˇa ˇa
$ ˇa ˇa
$ ˇa ˇa
"#
);
cx.set_state(before);
cx.update_editor(|e, window, cx| e.align_selections(&AlignSelections, window, cx));
cx.assert_editor_state(after);
}
+1
View File
@@ -653,6 +653,7 @@ impl EditorElement {
register_action(editor, window, Editor::enable_breakpoint);
register_action(editor, window, Editor::disable_breakpoint);
register_action(editor, window, Editor::toggle_read_only);
register_action(editor, window, Editor::align_selections);
if editor.read(cx).enable_wrap_selections_in_tag(cx) {
register_action(editor, window, Editor::wrap_selections_in_tag);
}