Add a way to copy with the selections trimmed (#27206)

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>
This commit is contained in:
Kirill Bulatov 2025-03-20 21:58:51 +02:00 committed by GitHub
parent a74f2bb18b
commit 9609e04bb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 231 additions and 13 deletions

View file

@ -275,6 +275,7 @@ actions!(
ConvertToUpperCamelCase,
ConvertToUpperCase,
Copy,
CopyAndTrim,
CopyFileLocation,
CopyHighlightJson,
CopyFileName,

View file

@ -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,
});
}
}

View file

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

View file

@ -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);