diff --git a/Cargo.lock b/Cargo.lock index 59d2c6847e..2d5ef55ebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3708,6 +3708,7 @@ dependencies = [ "serde", "serde_json", "settings", + "similar", "smallvec", "smol", "snippet", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index b1cc59ace6..b6b22ef64d 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -61,6 +61,7 @@ schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +similar.workspace = true smallvec.workspace = true smol.workspace = true snippet.workspace = true diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 319286d252..5811ec7b92 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -284,6 +284,7 @@ gpui::actions!( Redo, RedoSelection, Rename, + Rewrap, RestartLanguageServer, RevealInFileManager, ReverseLines, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 515cde1908..8d17e9cfbd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -98,6 +98,7 @@ use language::{ }; use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange}; use linked_editing_ranges::refresh_linked_ranges; +use similar::{ChangeTag, TextDiff}; use task::{ResolvedTask, TaskTemplate, TaskVariables}; use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; @@ -6659,6 +6660,161 @@ impl Editor { }); } + pub fn rewrap(&mut self, _: &Rewrap, cx: &mut ViewContext) { + let buffer = self.buffer.read(cx).snapshot(cx); + let selections = self.selections.all::(cx); + let mut selections = selections.iter().peekable(); + + let mut edits = Vec::new(); + let mut rewrapped_row_ranges = Vec::>::new(); + + while let Some(selection) = selections.next() { + let mut start_row = selection.start.row; + let mut end_row = selection.end.row; + + // Skip selections that overlap with a range that has already been rewrapped. + let selection_range = start_row..end_row; + if rewrapped_row_ranges + .iter() + .any(|range| range.overlaps(&selection_range)) + { + continue; + } + + let mut should_rewrap = false; + + if let Some(language_scope) = buffer.language_scope_at(selection.head()) { + match language_scope.language_name().0.as_ref() { + "Markdown" | "Plain Text" => { + should_rewrap = true; + } + _ => {} + } + } + + let row = selection.head().row; + let indent_size = buffer.indent_size_for_line(MultiBufferRow(row)); + let indent_end = Point::new(row, indent_size.len); + + let mut line_prefix = indent_size.chars().collect::(); + + if selection.is_empty() { + if let Some(comment_prefix) = + buffer + .language_scope_at(selection.head()) + .and_then(|language| { + language + .line_comment_prefixes() + .iter() + .find(|prefix| buffer.contains_str_at(indent_end, prefix)) + .cloned() + }) + { + line_prefix.push_str(&comment_prefix); + should_rewrap = true; + } + + 'expand_upwards: while start_row > 0 { + let prev_row = start_row - 1; + if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len() + { + start_row = prev_row; + } else { + break 'expand_upwards; + } + } + + 'expand_downwards: while end_row < buffer.max_point().row { + let next_row = end_row + 1; + if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len() + { + end_row = next_row; + } else { + break 'expand_downwards; + } + } + } + + if !should_rewrap { + continue; + } + + let start = Point::new(start_row, 0); + let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); + let selection_text = buffer.text_for_range(start..end).collect::(); + let unwrapped_text = selection_text + .lines() + .map(|line| line.strip_prefix(&line_prefix).unwrap()) + .join(" "); + let wrap_column = buffer + .settings_at(Point::new(start_row, 0), cx) + .preferred_line_length as usize; + let mut wrapped_text = String::new(); + let mut current_line = line_prefix.clone(); + for word in unwrapped_text.split_whitespace() { + if current_line.len() + word.len() >= wrap_column { + wrapped_text.push_str(¤t_line); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + } + + if current_line.len() > line_prefix.len() { + current_line.push(' '); + } + + current_line.push_str(word); + } + + if !current_line.is_empty() { + wrapped_text.push_str(¤t_line); + } + + let diff = TextDiff::from_lines(&selection_text, &wrapped_text); + let mut offset = start.to_offset(&buffer); + let mut moved_since_edit = true; + + for change in diff.iter_all_changes() { + let value = change.value(); + match change.tag() { + ChangeTag::Equal => { + offset += value.len(); + moved_since_edit = true; + } + ChangeTag::Delete => { + let start = buffer.anchor_after(offset); + let end = buffer.anchor_before(offset + value.len()); + + if moved_since_edit { + edits.push((start..end, String::new())); + } else { + edits.last_mut().unwrap().0.end = end; + } + + offset += value.len(); + moved_since_edit = false; + } + ChangeTag::Insert => { + if moved_since_edit { + let anchor = buffer.anchor_after(offset); + edits.push((anchor..anchor, value.to_string())); + } else { + edits.last_mut().unwrap().1.push_str(value); + } + + moved_since_edit = false; + } + } + } + + rewrapped_row_ranges.push(start_row..=end_row); + } + + self.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + } + pub fn cut(&mut self, _: &Cut, cx: &mut ViewContext) { let mut text = String::new(); let buffer = self.buffer.read(cx).snapshot(cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b1ae40e651..43b3e2e5e8 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3979,6 +3979,245 @@ fn test_transpose(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_rewrap(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + { + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let unwrapped_text = indoc! {" + // ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis porttitor id. Aliquam id accumsan eros. + "}; + + let wrapped_text = indoc! {" + // Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + // purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus + // auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam + // tincidunt hendrerit. Praesent semper egestas tellus id dignissim. + // Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed + // vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, + // et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum + // dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu + // viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis + // porttitor id. Aliquam id accumsan eros.ˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } + + // Test that cursors that expand to the same region are collapsed. + { + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let unwrapped_text = indoc! {" + // ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. + // ˇVivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. + // ˇVivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, + // ˇblandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis porttitor id. Aliquam id accumsan eros. + "}; + + let wrapped_text = indoc! {" + // Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + // purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus + // auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam + // tincidunt hendrerit. Praesent semper egestas tellus id dignissim. + // Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed + // vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, + // et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum + // dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu + // viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis + // porttitor id. Aliquam id accumsan eros.ˇˇˇˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } + + // Test that non-contiguous selections are treated separately. + { + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let unwrapped_text = indoc! {" + // ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. + // ˇVivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. + // + // ˇVivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, + // ˇblandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis porttitor id. Aliquam id accumsan eros. + "}; + + let wrapped_text = indoc! {" + // Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + // purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus + // auctor, eu lacinia sapien scelerisque.ˇˇ + // + // Vivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas + // tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, + // blandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec + // molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque + // nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras egestas + // porta metus, eu viverra ipsum efficitur quis. Donec luctus eros turpis, id + // vulputate turpis porttitor id. Aliquam id accumsan eros.ˇˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } + + // Test that different comment prefixes are supported. + { + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["# ".into()], + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let unwrapped_text = indoc! {" + # ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras egestas porta metus, eu viverra ipsum efficitur quis. Donec luctus eros turpis, id vulputate turpis porttitor id. Aliquam id accumsan eros. + "}; + + let wrapped_text = indoc! {" + # Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + # purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, + # eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt + # hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio + # lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit + # amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet + # in. Integer sit amet scelerisque nisi. Lorem ipsum dolor sit amet, consectetur + # adipiscing elit. Cras egestas porta metus, eu viverra ipsum efficitur quis. + # Donec luctus eros turpis, id vulputate turpis porttitor id. Aliquam id + # accumsan eros.ˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } + + // Test that rewrapping is ignored outside of comments in most languages. + { + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into(), "/// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::language()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let unwrapped_text = indoc! {" + /// Adds two numbers. + /// Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit purus, a ornare lacus gravida vitae.ˇ + fn add(a: u32, b: u32) -> u32 { + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + bˇ + } + "}; + + let wrapped_text = indoc! {" + /// Adds two numbers. Lorem ipsum dolor sit amet, consectetur adipiscing elit. + /// Vivamus mollis elit purus, a ornare lacus gravida vitae.ˇ + fn add(a: u32, b: u32) -> u32 { + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + b + a + bˇ + } + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } + + // Test that rewrapping works in Markdown and Plain Text languages. + { + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + let unwrapped_text = indoc! {" + # Hello + + Lorem ipsum dolor sit amet, ˇconsectetur adipiscing elit. Vivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. + "}; + + let wrapped_text = indoc! {" + # Hello + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, + eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt + hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio + lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet + nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. + Integer sit amet scelerisque nisi.ˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); + + let unwrapped_text = indoc! {" + Lorem ipsum dolor sit amet, ˇconsectetur adipiscing elit. Vivamus mollis elit purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. Integer sit amet scelerisque nisi. + "}; + + let wrapped_text = indoc! {" + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus mollis elit + purus, a ornare lacus gravida vitae. Proin consectetur felis vel purus auctor, + eu lacinia sapien scelerisque. Vivamus sit amet neque et quam tincidunt + hendrerit. Praesent semper egestas tellus id dignissim. Pellentesque odio + lectus, iaculis ac volutpat et, blandit quis urna. Sed vestibulum nisi sit amet + nisl venenatis tempus. Donec molestie blandit quam, et porta nunc laoreet in. + Integer sit amet scelerisque nisi.ˇ + "}; + + cx.set_state(unwrapped_text); + cx.update_editor(|e, cx| e.rewrap(&Rewrap, cx)); + cx.assert_editor_state(wrapped_text); + } +} + #[gpui::test] async fn test_clipboard(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d4f5c565c2..7e2b3cc63f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -216,6 +216,7 @@ impl EditorElement { register_action(view, cx, Editor::move_line_up); register_action(view, cx, Editor::move_line_down); register_action(view, cx, Editor::transpose); + register_action(view, cx, Editor::rewrap); register_action(view, cx, Editor::cut); register_action(view, cx, Editor::copy); register_action(view, cx, Editor::paste);