diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8b0fc5512731eff70b1e9ac41b6bfe16a65babfa..fbf70322b890ab9a2a3c1f9e915a5debae2e4e64 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12452,13 +12452,14 @@ impl Editor { return; } - let clipboard_text = Cow::Borrowed(text); + let clipboard_text = Cow::Borrowed(text.as_str()); self.transact(window, cx, |this, window, cx| { let had_active_edit_prediction = this.has_active_edit_prediction(); + let old_selections = this.selections.all::(cx); + let cursor_offset = this.selections.last::(cx).head(); if let Some(mut clipboard_selections) = clipboard_selections { - let old_selections = this.selections.all::(cx); let all_selections_were_entire_line = clipboard_selections.iter().all(|s| s.is_entire_line); let first_selection_indent_column = @@ -12466,7 +12467,6 @@ impl Editor { if clipboard_selections.len() != old_selections.len() { clipboard_selections.drain(..); } - let cursor_offset = this.selections.last::(cx).head(); let mut auto_indent_on_paste = true; this.buffer.update(cx, |buffer, cx| { @@ -12489,22 +12489,36 @@ impl Editor { start_offset = end_offset + 1; original_indent_column = Some(clipboard_selection.first_line_indent); } else { - to_insert = clipboard_text.as_str(); + to_insert = &*clipboard_text; entire_line = all_selections_were_entire_line; original_indent_column = first_selection_indent_column } - // If the corresponding selection was empty when this slice of the - // clipboard text was written, then the entire line containing the - // selection was copied. If this selection is also currently empty, - // then paste the line before the current line of the buffer. - let range = if selection.is_empty() && handle_entire_lines && entire_line { - let column = selection.start.to_point(&snapshot).column as usize; - let line_start = selection.start - column; - line_start..line_start - } else { - selection.range() - }; + let (range, to_insert) = + if selection.is_empty() && handle_entire_lines && entire_line { + // If the corresponding selection was empty when this slice of the + // clipboard text was written, then the entire line containing the + // selection was copied. If this selection is also currently empty, + // then paste the line before the current line of the buffer. + let column = selection.start.to_point(&snapshot).column as usize; + let line_start = selection.start - column; + (line_start..line_start, Cow::Borrowed(to_insert)) + } else { + let language = snapshot.language_at(selection.head()); + let range = selection.range(); + if let Some(language) = language + && language.name() == "Markdown".into() + { + edit_for_markdown_paste( + &snapshot, + range, + to_insert, + url::Url::parse(to_insert).ok(), + ) + } else { + (range, Cow::Borrowed(to_insert)) + } + }; edits.push((range, to_insert)); original_indent_columns.push(original_indent_column); @@ -12527,7 +12541,53 @@ impl Editor { let selections = this.selections.all::(cx); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); } else { - this.insert(&clipboard_text, window, cx); + let url = url::Url::parse(&clipboard_text).ok(); + + let auto_indent_mode = if !clipboard_text.is_empty() { + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }) + } else { + None + }; + + let selection_anchors = this.buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + + let anchors = old_selections + .iter() + .map(|s| { + let anchor = snapshot.anchor_after(s.head()); + s.map(|_| anchor) + }) + .collect::>(); + + let mut edits = Vec::new(); + + for selection in old_selections.iter() { + let language = snapshot.language_at(selection.head()); + let range = selection.range(); + + let (edit_range, edit_text) = if let Some(language) = language + && language.name() == "Markdown".into() + { + edit_for_markdown_paste(&snapshot, range, &clipboard_text, url.clone()) + } else { + (range, clipboard_text.clone()) + }; + + edits.push((edit_range, edit_text)); + } + + drop(snapshot); + buffer.edit(edits, auto_indent_mode, cx); + + anchors + }); + + this.change_selections(Default::default(), window, cx, |s| { + s.select_anchors(selection_anchors); + }); } let trigger_in_words = @@ -21679,6 +21739,26 @@ impl Editor { } } +fn edit_for_markdown_paste<'a>( + buffer: &MultiBufferSnapshot, + range: Range, + to_insert: &'a str, + url: Option, +) -> (Range, Cow<'a, str>) { + if url.is_none() { + return (range, Cow::Borrowed(to_insert)); + }; + + let old_text = buffer.text_for_range(range.clone()).collect::(); + + let new_text = if range.is_empty() || url::Url::parse(&old_text).is_ok() { + Cow::Borrowed(to_insert) + } else { + Cow::Owned(format!("[{old_text}]({to_insert})")) + }; + (range, new_text) +} + fn vim_enabled(cx: &App) -> bool { vim_mode_setting::VimModeSetting::try_get(cx) .map(|vim_mode| vim_mode.0) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 05742cd00bb834550ee20377ff46da6649272f43..9f888702f99b0b916d35625806c18e53043d0101 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -25974,6 +25974,217 @@ let result = variable * 2;", ); } +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)" + )); +} + +#[gpui::test] +async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state(&format!( + "Hello, editor.\nZed is great (see this link: )\n«{url}ˇ»" + )); + + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); + + cx.set_state(&format!( + "Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)\n{url}" + )); + + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)\n{url}" + )); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_replaces_existing_url_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Please visit zed's homepage: «https://www.apple.comˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("Please visit zed's homepage: {url}ˇ")); +} + +#[gpui::test] +async fn test_paste_plain_text_from_other_app_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let text = "Awesome"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("// Hello, «editorˇ».\n// Zed is «ˇgreat» (see this link: ˇ)"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "// Hello, {url}ˇ.\n// Zed is {url}ˇ (see this link: {url}ˇ)" + )); +} + +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_selectively_in_multi_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("this will embed -> link", vec![Point::row_range(0..1)]), + ("this will replace -> link", vec![Point::row_range(0..1)]), + ], + cx, + ); + let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![ + Point::new(0, 19)..Point::new(0, 23), + Point::new(1, 21)..Point::new(1, 25), + ]) + }); + let first_buffer_id = multi_buffer + .read(cx) + .excerpt_buffer_ids() + .into_iter() + .next() + .unwrap(); + let first_buffer = multi_buffer.read(cx).buffer(first_buffer_id).unwrap(); + first_buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language.clone()), cx); + }); + + editor + }); + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!( + "this will embed -> [link]({url})ˇ\nthis will replace -> {url}ˇ" + )); +} + #[track_caller] fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { editor