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:
co-authored by
Cole Miller
parent
003163eb4f
commit
c88fdaf02d
+96
-16
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user