crates/editor/src/actions.rs 🔗
@@ -275,6 +275,7 @@ actions!(
ConvertToUpperCamelCase,
ConvertToUpperCase,
Copy,
+ CopyAndTrim,
CopyFileLocation,
CopyHighlightJson,
CopyFileName,
Kirill Bulatov and Cole Miller created
No default binding currently, `cmd/ctr-shift-c` seem somewhat natural
but those are occupied by the collab panel.
https://github.com/user-attachments/assets/702cc52a-a4b7-4f2c-bb7f-12ca0c66faeb
Release Notes:
- Added a way to copy with the selections trimmed
---------
Co-authored-by: Cole Miller <m@cole-miller.net>
crates/editor/src/actions.rs | 1
crates/editor/src/editor.rs | 68 ++++++++++--
crates/editor/src/editor_tests.rs | 174 +++++++++++++++++++++++++++++++++
crates/editor/src/element.rs | 1
4 files changed, 231 insertions(+), 13 deletions(-)
@@ -275,6 +275,7 @@ actions!(
ConvertToUpperCamelCase,
ConvertToUpperCase,
Copy,
+ CopyAndTrim,
CopyFileLocation,
CopyHighlightJson,
CopyFileName,
@@ -9429,7 +9429,15 @@ impl Editor {
self.do_paste(&text, metadata, false, window, cx);
}
+ pub fn copy_and_trim(&mut self, _: &CopyAndTrim, _: &mut Window, cx: &mut Context<Self>) {
+ self.do_copy(true, cx);
+ }
+
pub fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
+ self.do_copy(false, cx);
+ }
+
+ fn do_copy(&self, strip_leading_indents: bool, cx: &mut Context<Self>) {
let selections = self.selections.all::<Point>(cx);
let buffer = self.buffer.read(cx).read(cx);
let mut text = String::new();
@@ -9438,7 +9446,7 @@ impl Editor {
{
let max_point = buffer.max_point();
let mut is_first = true;
- for selection in selections.iter() {
+ for selection in &selections {
let mut start = selection.start;
let mut end = selection.end;
let is_entire_line = selection.is_empty() || self.selections.line_mode;
@@ -9446,21 +9454,55 @@ impl Editor {
start = Point::new(start.row, 0);
end = cmp::min(max_point, Point::new(end.row + 1, 0));
}
- if is_first {
- is_first = false;
+
+ let mut trimmed_selections = Vec::new();
+ if strip_leading_indents && end.row.saturating_sub(start.row) > 0 {
+ let row = MultiBufferRow(start.row);
+ let first_indent = buffer.indent_size_for_line(row);
+ if first_indent.len == 0 || start.column > first_indent.len {
+ trimmed_selections.push(start..end);
+ } else {
+ trimmed_selections.push(
+ Point::new(row.0, first_indent.len)
+ ..Point::new(row.0, buffer.line_len(row)),
+ );
+ for row in start.row + 1..=end.row {
+ let row_indent_size = buffer.indent_size_for_line(MultiBufferRow(row));
+ if row_indent_size.len >= first_indent.len {
+ trimmed_selections.push(
+ Point::new(row, first_indent.len)
+ ..Point::new(row, buffer.line_len(MultiBufferRow(row))),
+ );
+ } else {
+ trimmed_selections.clear();
+ trimmed_selections.push(start..end);
+ break;
+ }
+ }
+ }
} else {
- text += "\n";
+ trimmed_selections.push(start..end);
}
- let mut len = 0;
- for chunk in buffer.text_for_range(start..end) {
- text.push_str(chunk);
- len += chunk.len();
+
+ for trimmed_range in trimmed_selections {
+ if is_first {
+ is_first = false;
+ } else {
+ text += "\n";
+ }
+ let mut len = 0;
+ for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) {
+ text.push_str(chunk);
+ len += chunk.len();
+ }
+ clipboard_selections.push(ClipboardSelection {
+ len,
+ is_entire_line,
+ first_line_indent: buffer
+ .indent_size_for_line(MultiBufferRow(trimmed_range.start.row))
+ .len,
+ });
}
- clipboard_selections.push(ClipboardSelection {
- len,
- is_entire_line,
- first_line_indent: buffer.indent_size_for_line(MultiBufferRow(start.row)).len,
- });
}
}
@@ -4918,6 +4918,180 @@ async fn test_clipboard(cx: &mut TestAppContext) {
tˇhe lazy dog"});
}
+#[gpui::test]
+async fn test_copy_trim(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.set_state(
+ r#" «for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);ˇ»
+ end = cmp::min(max_point, Point::new(end.row + 1, 0));
+ }
+ "#,
+ );
+ cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Regular copying preserves all indentation selected",
+ );
+ cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "for selection in selections.iter() {
+let mut start = selection.start;
+let mut end = selection.end;
+let is_entire_line = selection.is_empty() || self.selections.line_mode;
+if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Copying with stripping should strip all leading whitespaces"
+ );
+
+ cx.set_state(
+ r#" « for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);ˇ»
+ end = cmp::min(max_point, Point::new(end.row + 1, 0));
+ }
+ "#,
+ );
+ cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ " for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Regular copying preserves all indentation selected",
+ );
+ cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "for selection in selections.iter() {
+let mut start = selection.start;
+let mut end = selection.end;
+let is_entire_line = selection.is_empty() || self.selections.line_mode;
+if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Copying with stripping should strip all leading whitespaces, even if some of it was selected"
+ );
+
+ cx.set_state(
+ r#" «ˇ for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);»
+ end = cmp::min(max_point, Point::new(end.row + 1, 0));
+ }
+ "#,
+ );
+ cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ " for selection in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Regular copying for reverse selection works the same",
+ );
+ cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "for selection in selections.iter() {
+let mut start = selection.start;
+let mut end = selection.end;
+let is_entire_line = selection.is_empty() || self.selections.line_mode;
+if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "Copying with stripping for reverse selection works the same"
+ );
+
+ cx.set_state(
+ r#" for selection «in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);ˇ»
+ end = cmp::min(max_point, Point::new(end.row + 1, 0));
+ }
+ "#,
+ );
+ cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "When selecting past the indent, the copying works as usual",
+ );
+ cx.update_editor(|e, window, cx| e.copy_and_trim(&CopyAndTrim, window, cx));
+ assert_eq!(
+ cx.read_from_clipboard()
+ .and_then(|item| item.text().as_deref().map(str::to_string)),
+ Some(
+ "in selections.iter() {
+ let mut start = selection.start;
+ let mut end = selection.end;
+ let is_entire_line = selection.is_empty() || self.selections.line_mode;
+ if is_entire_line {
+ start = Point::new(start.row, 0);"
+ .to_string()
+ ),
+ "When selecting past the indent, nothing is trimmed"
+ );
+}
+
#[gpui::test]
async fn test_paste_multiline(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -244,6 +244,7 @@ impl EditorElement {
register_action(editor, window, Editor::kill_ring_cut);
register_action(editor, window, Editor::kill_ring_yank);
register_action(editor, window, Editor::copy);
+ register_action(editor, window, Editor::copy_and_trim);
register_action(editor, window, Editor::paste);
register_action(editor, window, Editor::undo);
register_action(editor, window, Editor::redo);