Implement Markdown link embedding on paste (#38639)

This PR adds automatic markdown URL embedding on paste when you are in
text associated with the Markdown language and you have a valid URL in
your clipboard. This the default behavior in VS Code and GitHub, when
pasting a URL in Markdown. It works in both singleton buffers and multi
buffers.

One thing that is a bit unfortunate is that, previously, `do_paste` use
to simply call `Editor::insert()`, in the case of pasting content that
was copied from an external application, and now, we are duplicating
some of `insert()`'s logic in place, in order to have control over
transforming the edits before they are inserted.

Release Notes:

- Added automatic Markdown URL embedding on paste.

---------

Co-authored-by: Cole Miller <53574922+cole-miller@users.noreply.github.com>
This commit is contained in:
Joseph T. Lyons
2025-09-22 12:33:12 -04:00
committed by GitHub
co-authored by Cole Miller
parent 003163eb4f
commit c88fdaf02d
2 changed files with 307 additions and 16 deletions
+96 -16
View File
@@ -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::<usize>(cx);
let cursor_offset = this.selections.last::<usize>(cx).head();
if let Some(mut clipboard_selections) = clipboard_selections {
let old_selections = this.selections.all::<usize>(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::<usize>(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::<usize>(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::<Vec<_>>();
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<usize>,
to_insert: &'a str,
url: Option<url::Url>,
) -> (Range<usize>, Cow<'a, str>) {
if url.is_none() {
return (range, Cow::Borrowed(to_insert));
};
let old_text = buffer.text_for_range(range.clone()).collect::<String>();
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)
+211
View File
@@ -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<Rgba> {
editor