Detailed changes
@@ -680,6 +680,10 @@ actions!(
ReloadFile,
/// Rewraps text to fit within the preferred line length.
Rewrap,
+ /// Rotates selections or lines backward.
+ RotateSelectionsBackward,
+ /// Rotates selections or lines forward.
+ RotateSelectionsForward,
/// Runs flycheck diagnostics.
RunFlycheck,
/// Scrolls the cursor to the bottom of the viewport.
@@ -11516,6 +11516,168 @@ impl Editor {
self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng()))
}
+ pub fn rotate_selections_forward(
+ &mut self,
+ _: &RotateSelectionsForward,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.rotate_selections(window, cx, false)
+ }
+
+ pub fn rotate_selections_backward(
+ &mut self,
+ _: &RotateSelectionsBackward,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.rotate_selections(window, cx, true)
+ }
+
+ fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context<Self>, reverse: bool) {
+ self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
+ let display_snapshot = self.display_snapshot(cx);
+ let selections = self.selections.all::<MultiBufferOffset>(&display_snapshot);
+
+ if selections.len() < 2 {
+ return;
+ }
+
+ let (edits, new_selections) = {
+ let buffer = self.buffer.read(cx).read(cx);
+ let has_selections = selections.iter().any(|s| !s.is_empty());
+ if has_selections {
+ let mut selected_texts: Vec<String> = selections
+ .iter()
+ .map(|selection| {
+ buffer
+ .text_for_range(selection.start..selection.end)
+ .collect()
+ })
+ .collect();
+
+ if reverse {
+ selected_texts.rotate_left(1);
+ } else {
+ selected_texts.rotate_right(1);
+ }
+
+ let mut offset_delta: i64 = 0;
+ let mut new_selections = Vec::new();
+ let edits: Vec<_> = selections
+ .iter()
+ .zip(selected_texts.iter())
+ .map(|(selection, new_text)| {
+ let old_len = (selection.end.0 - selection.start.0) as i64;
+ let new_len = new_text.len() as i64;
+ let adjusted_start =
+ MultiBufferOffset((selection.start.0 as i64 + offset_delta) as usize);
+ let adjusted_end =
+ MultiBufferOffset((adjusted_start.0 as i64 + new_len) as usize);
+
+ new_selections.push(Selection {
+ id: selection.id,
+ start: adjusted_start,
+ end: adjusted_end,
+ reversed: selection.reversed,
+ goal: selection.goal,
+ });
+
+ offset_delta += new_len - old_len;
+ (selection.start..selection.end, new_text.clone())
+ })
+ .collect();
+ (edits, new_selections)
+ } else {
+ let mut all_rows: Vec<u32> = selections
+ .iter()
+ .map(|selection| buffer.offset_to_point(selection.start).row)
+ .collect();
+ all_rows.sort_unstable();
+ all_rows.dedup();
+
+ if all_rows.len() < 2 {
+ return;
+ }
+
+ let line_ranges: Vec<Range<MultiBufferOffset>> = all_rows
+ .iter()
+ .map(|&row| {
+ let start = Point::new(row, 0);
+ let end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
+ buffer.point_to_offset(start)..buffer.point_to_offset(end)
+ })
+ .collect();
+
+ let mut line_texts: Vec<String> = line_ranges
+ .iter()
+ .map(|range| buffer.text_for_range(range.clone()).collect())
+ .collect();
+
+ if reverse {
+ line_texts.rotate_left(1);
+ } else {
+ line_texts.rotate_right(1);
+ }
+
+ let edits = line_ranges
+ .iter()
+ .zip(line_texts.iter())
+ .map(|(range, new_text)| (range.clone(), new_text.clone()))
+ .collect();
+
+ let num_rows = all_rows.len();
+ let row_to_index: std::collections::HashMap<u32, usize> = all_rows
+ .iter()
+ .enumerate()
+ .map(|(i, &row)| (row, i))
+ .collect();
+
+ // Compute new line start offsets after rotation (handles CRLF)
+ let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0;
+ let first_line_start = line_ranges[0].start.0;
+ let mut new_line_starts: Vec<usize> = vec![first_line_start];
+ for text in line_texts.iter().take(num_rows - 1) {
+ let prev_start = *new_line_starts.last().unwrap();
+ new_line_starts.push(prev_start + text.len() + newline_len);
+ }
+
+ let new_selections = selections
+ .iter()
+ .map(|selection| {
+ let point = buffer.offset_to_point(selection.start);
+ let old_index = row_to_index[&point.row];
+ let new_index = if reverse {
+ (old_index + num_rows - 1) % num_rows
+ } else {
+ (old_index + 1) % num_rows
+ };
+ let new_offset =
+ MultiBufferOffset(new_line_starts[new_index] + point.column as usize);
+ Selection {
+ id: selection.id,
+ start: new_offset,
+ end: new_offset,
+ reversed: selection.reversed,
+ goal: selection.goal,
+ }
+ })
+ .collect();
+
+ (edits, new_selections)
+ }
+ };
+
+ self.transact(window, cx, |this, window, cx| {
+ this.buffer.update(cx, |buffer, cx| {
+ buffer.edit(edits, None, cx);
+ });
+ this.change_selections(Default::default(), window, cx, |s| {
+ s.select(new_selections);
+ });
+ });
+ }
+
fn manipulate_lines<M>(
&mut self,
window: &mut Window,
@@ -5777,6 +5777,116 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+async fn test_rotate_selections(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ // Rotate text selections (horizontal)
+ cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»");
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_forward(&RotateSelectionsForward, window, cx)
+ });
+ cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»");
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_backward(&RotateSelectionsBackward, window, cx)
+ });
+ cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»");
+
+ // Rotate text selections (vertical)
+ cx.set_state(indoc! {"
+ x=«1ˇ»
+ y=«2ˇ»
+ z=«3ˇ»
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_forward(&RotateSelectionsForward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ x=«3ˇ»
+ y=«1ˇ»
+ z=«2ˇ»
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_backward(&RotateSelectionsBackward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ x=«1ˇ»
+ y=«2ˇ»
+ z=«3ˇ»
+ "});
+
+ // Rotate text selections (vertical, different lengths)
+ cx.set_state(indoc! {"
+ x=\"«ˇ»\"
+ y=\"«aˇ»\"
+ z=\"«aaˇ»\"
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_forward(&RotateSelectionsForward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ x=\"«aaˇ»\"
+ y=\"«ˇ»\"
+ z=\"«aˇ»\"
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_backward(&RotateSelectionsBackward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ x=\"«ˇ»\"
+ y=\"«aˇ»\"
+ z=\"«aaˇ»\"
+ "});
+
+ // Rotate whole lines (cursor positions preserved)
+ cx.set_state(indoc! {"
+ ˇline123
+ liˇne23
+ line3ˇ
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_forward(&RotateSelectionsForward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ line3ˇ
+ ˇline123
+ liˇne23
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_backward(&RotateSelectionsBackward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ ˇline123
+ liˇne23
+ line3ˇ
+ "});
+
+ // Rotate whole lines, multiple cursors per line (positions preserved)
+ cx.set_state(indoc! {"
+ ˇliˇne123
+ ˇline23
+ ˇline3
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_forward(&RotateSelectionsForward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ ˇline3
+ ˇliˇne123
+ ˇline23
+ "});
+ cx.update_editor(|e, window, cx| {
+ e.rotate_selections_backward(&RotateSelectionsBackward, window, cx)
+ });
+ cx.assert_editor_state(indoc! {"
+ ˇliˇne123
+ ˇline23
+ ˇline3
+ "});
+}
+
#[gpui::test]
fn test_move_line_up_down(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -253,6 +253,8 @@ impl EditorElement {
register_action(editor, window, Editor::sort_lines_case_insensitive);
register_action(editor, window, Editor::reverse_lines);
register_action(editor, window, Editor::shuffle_lines);
+ register_action(editor, window, Editor::rotate_selections_forward);
+ register_action(editor, window, Editor::rotate_selections_backward);
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);