From 9ad8c7a2a3d8ee556777002ab9c86fe100016612 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 20 Feb 2026 17:33:06 +0100 Subject: [PATCH] editor: Distribute lines across cursors when pasting from external sources (#48676) Release Notes: - When pasting multiple lines equaling the number of cursors Zed now maps each line to each cursor --- .../tests/integration/integration_tests.rs | 10 ++--- crates/editor/src/clangd_ext.rs | 2 +- crates/editor/src/editor.rs | 24 +++++++++--- crates/editor/src/editor_tests.rs | 38 ++++++++++++++++++- crates/editor/src/rust_analyzer_ext.rs | 2 +- crates/language/src/buffer_tests.rs | 26 ++++++------- crates/language/src/language_registry.rs | 12 ++++++ .../src/markdown_preview_view.rs | 2 +- crates/project/src/git_store.rs | 2 +- .../tests/integration/project_tests.rs | 4 +- .../remote_server/src/remote_editing_tests.rs | 4 +- crates/repl/src/repl_editor.rs | 2 +- crates/repl/src/repl_sessions_ui.rs | 2 +- crates/zed/src/zed.rs | 4 +- 14 files changed, 98 insertions(+), 36 deletions(-) diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 766585b3b6828c16d51a8e0e0657e622d39e0861..413aa802a1e63982de4a4563917cdcf7e6a55c81 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -2379,11 +2379,11 @@ async fn test_propagate_saves_and_fs_changes( .unwrap(); buffer_b.read_with(cx_b, |buffer, _| { - assert_eq!(buffer.language().unwrap().name(), "Rust".into()); + assert_eq!(buffer.language().unwrap().name(), "Rust"); }); buffer_c.read_with(cx_c, |buffer, _| { - assert_eq!(buffer.language().unwrap().name(), "Rust".into()); + assert_eq!(buffer.language().unwrap().name(), "Rust"); }); buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "i-am-b, ")], None, cx)); buffer_c.update(cx_c, |buf, cx| buf.edit([(0..0, "i-am-c, ")], None, cx)); @@ -2486,17 +2486,17 @@ async fn test_propagate_saves_and_fs_changes( buffer_a.read_with(cx_a, |buffer, _| { assert_eq!(buffer.file().unwrap().path().as_ref(), rel_path("file1.js")); - assert_eq!(buffer.language().unwrap().name(), "JavaScript".into()); + assert_eq!(buffer.language().unwrap().name(), "JavaScript"); }); buffer_b.read_with(cx_b, |buffer, _| { assert_eq!(buffer.file().unwrap().path().as_ref(), rel_path("file1.js")); - assert_eq!(buffer.language().unwrap().name(), "JavaScript".into()); + assert_eq!(buffer.language().unwrap().name(), "JavaScript"); }); buffer_c.read_with(cx_c, |buffer, _| { assert_eq!(buffer.file().unwrap().path().as_ref(), rel_path("file1.js")); - assert_eq!(buffer.language().unwrap().name(), "JavaScript".into()); + assert_eq!(buffer.language().unwrap().name(), "JavaScript"); }); let new_buffer_a = project_a diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index a6805b8f40722d17c4c7ba01a07668d73d00c4d9..c52089ca6ac249acffff24be2d91e761f44efb8a 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -14,7 +14,7 @@ use crate::{Editor, SwitchSourceHeader, element::register_action}; use project::lsp_store::clangd_ext::CLANGD_SERVER_NAME; fn is_c_language(language: &Language) -> bool { - language.name() == "C++".into() || language.name() == "C".into() + language.name() == "C++" || language.name() == "C" } pub fn switch_source_header( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 44e1b4be715e38cdb2adcaac1bb0feb285f64493..192412f2681ae007ed9bece0b32432a713ed0ee6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13821,7 +13821,7 @@ impl Editor { let language = snapshot.language_at(selection.head()); let range = selection.range(); if let Some(language) = language - && language.name() == "Markdown".into() + && language.name() == "Markdown" { edit_for_markdown_paste( &snapshot, @@ -13880,16 +13880,30 @@ impl Editor { let mut edits = Vec::new(); - for selection in old_selections.iter() { + // When pasting text without metadata (e.g. copied from an + // external editor using multiple cursors) and the number of + // lines matches the number of selections, distribute one + // line per cursor instead of pasting the whole text at each. + let lines: Vec<&str> = clipboard_text.split('\n').collect(); + let distribute_lines = + old_selections.len() > 1 && lines.len() == old_selections.len(); + + for (ix, selection) in old_selections.iter().enumerate() { let language = snapshot.language_at(selection.head()); let range = selection.range(); + let text_for_cursor: &str = if distribute_lines { + lines[ix] + } else { + &clipboard_text + }; + let (edit_range, edit_text) = if let Some(language) = language - && language.name() == "Markdown".into() + && language.name() == "Markdown" { - edit_for_markdown_paste(&snapshot, range, &clipboard_text, url.clone()) + edit_for_markdown_paste(&snapshot, range, text_for_cursor, url.clone()) } else { - (range, clipboard_text.clone()) + (range, Cow::Borrowed(text_for_cursor)) }; edits.push((edit_range, edit_text)); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 695491b6ea761cd8e0eb18fa3a06994f7fdafb89..7f5a84ebd326603e1c239bbbb4062b115b17d095 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8292,6 +8292,38 @@ async fn test_paste_content_from_other_app(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_paste_multiline_from_other_app_into_matching_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + + let mut cx = EditorTestContext::new(cx).await; + + // Paste into 3 cursors: each cursor should receive one line. + cx.set_state("ˇ one ˇ two ˇ three"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alphaˇ one betaˇ two gammaˇ three"); + + // Paste into 2 cursors: line count doesn't match, so paste entire text at each cursor. + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + cx.set_state("ˇ one ˇ two"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alpha\nbeta\ngammaˇ one alpha\nbeta\ngammaˇ two"); + + // Paste into a single cursor: should paste everything as-is. + cx.write_to_clipboard(ClipboardItem::new_string("alpha\nbeta\ngamma".into())); + cx.set_state("ˇ one"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("alpha\nbeta\ngammaˇ one"); + + // Paste with selections: each selection is replaced with its corresponding line. + cx.write_to_clipboard(ClipboardItem::new_string("xx\nyy\nzz".into())); + cx.set_state("«aˇ» one «bˇ» two «cˇ» three"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("xxˇ one yyˇ two zzˇ three"); +} + #[gpui::test] fn test_select_all(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -11304,7 +11336,11 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { .collect::>(); assert_eq!( languages, - &["HTML".into(), "JavaScript".into(), "HTML".into()] + &[ + LanguageName::from("HTML"), + LanguageName::from("JavaScript"), + LanguageName::from("HTML"), + ] ); }); diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs index 41c062b5dfed675fbf1fb2fefc378b00f4ab4bbc..6ffdf1a248a0e605f623254bbfa36776adf77cda 100644 --- a/crates/editor/src/rust_analyzer_ext.rs +++ b/crates/editor/src/rust_analyzer_ext.rs @@ -22,7 +22,7 @@ use crate::{ }; fn is_rust_language(language: &Language) -> bool { - language.name() == "Rust".into() + language.name() == "Rust" } pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: &mut App) { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 39af3142a461cb034f66c92526feff07a3730180..49d871cc860bb6df892b80ac433fb70264788664 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -267,7 +267,7 @@ async fn test_first_line_pattern(cx: &mut TestAppContext) { )) .unwrap() .name(), - "JavaScript".into() + "JavaScript" ); } @@ -341,48 +341,48 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext) let language = cx .read(|cx| languages.language_for_file(&file("foo.ts"), None, cx)) .unwrap(); - assert_eq!(language.name(), "TypeScript".into()); + assert_eq!(language.name(), "TypeScript"); let language = cx .read(|cx| languages.language_for_file(&file("foo.ts.ecmascript"), None, cx)) .unwrap(); - assert_eq!(language.name(), "TypeScript".into()); + assert_eq!(language.name(), "TypeScript"); let language = cx .read(|cx| languages.language_for_file(&file("foo.cpp"), None, cx)) .unwrap(); - assert_eq!(language.name(), "C++".into()); + assert_eq!(language.name(), "C++"); // user configured lang extension, same length as system-provided let language = cx .read(|cx| languages.language_for_file(&file("foo.js"), None, cx)) .unwrap(); - assert_eq!(language.name(), "TypeScript".into()); + assert_eq!(language.name(), "TypeScript"); let language = cx .read(|cx| languages.language_for_file(&file("foo.c"), None, cx)) .unwrap(); - assert_eq!(language.name(), "C++".into()); + assert_eq!(language.name(), "C++"); // user configured lang extension, longer than system-provided let language = cx .read(|cx| languages.language_for_file(&file("foo.longer.ts"), None, cx)) .unwrap(); - assert_eq!(language.name(), "JavaScript".into()); + assert_eq!(language.name(), "JavaScript"); // user configured lang extension, shorter than system-provided let language = cx .read(|cx| languages.language_for_file(&file("foo.ecmascript"), None, cx)) .unwrap(); - assert_eq!(language.name(), "JavaScript".into()); + assert_eq!(language.name(), "JavaScript"); // user configured glob matches let language = cx .read(|cx| languages.language_for_file(&file("c-plus-plus.dev"), None, cx)) .unwrap(); - assert_eq!(language.name(), "C++".into()); + assert_eq!(language.name(), "C++"); // should match Dockerfile.* => Dockerfile, not *.dev => C++ let language = cx .read(|cx| languages.language_for_file(&file("Dockerfile.dev"), None, cx)) .unwrap(); - assert_eq!(language.name(), "Dockerfile".into()); + assert_eq!(language.name(), "Dockerfile"); } fn file(path: &str) -> Arc { @@ -2827,7 +2827,7 @@ fn test_language_at_with_hidden_languages(cx: &mut App) { for point in [Point::new(0, 4), Point::new(0, 16)] { let config = snapshot.language_scope_at(point).unwrap(); - assert_eq!(config.language_name(), "Markdown".into()); + assert_eq!(config.language_name(), "Markdown"); let language = snapshot.language_at(point).unwrap(); assert_eq!(language.name().as_ref(), "Markdown"); @@ -2871,7 +2871,7 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { // Test points in the code line for point in [Point::new(1, 4), Point::new(1, 6)] { let config = snapshot.language_scope_at(point).unwrap(); - assert_eq!(config.language_name(), "Rust".into()); + assert_eq!(config.language_name(), "Rust"); let language = snapshot.language_at(point).unwrap(); assert_eq!(language.name().as_ref(), "Rust"); @@ -2880,7 +2880,7 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { // Test points in the comment line to verify it's still detected as Rust for point in [Point::new(2, 4), Point::new(2, 6)] { let config = snapshot.language_scope_at(point).unwrap(); - assert_eq!(config.language_name(), "Rust".into()); + assert_eq!(config.language_name(), "Rust"); let language = snapshot.language_at(point).unwrap(); assert_eq!(language.name().as_ref(), "Rust"); diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index 226eaf544e46b384884f015cdcae77f4ffc71662..d73a44fda3347ebcec9c6798325838acec543566 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -87,6 +87,18 @@ impl Borrow for LanguageName { } } +impl PartialEq for LanguageName { + fn eq(&self, other: &str) -> bool { + self.0.as_ref() == other + } +} + +impl PartialEq<&str> for LanguageName { + fn eq(&self, other: &&str) -> bool { + self.0.as_ref() == *other + } +} + impl std::fmt::Display for LanguageName { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 9b764e2037080d64662532a05e1424ae40791465..79bd7f33290e0510df8dff908b09541717b41696 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -259,7 +259,7 @@ impl MarkdownPreviewView { if let Some(buffer) = buffer.as_singleton() && let Some(language) = buffer.read(cx).language() { - return language.name() == "Markdown".into(); + return language.name() == "Markdown"; } false } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 285546a9aeb73ad647fbdcf5cf8e38e6d583a8ac..70b29635d59cf6b631848f54e8282510d160ac1c 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1209,7 +1209,7 @@ impl GitStore { if buffer .read(cx) .language() - .is_none_or(|lang| lang.name() != "Rust".into()) + .is_none_or(|lang| lang.name() != "Rust") { return Task::ready(Err(anyhow!("no permalink available"))); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 2394542a761a547c45d35695d1d21fc77de5c9f9..9bd0be45ae3fa1e66e8af2c43657ba039045ecef 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5089,7 +5089,7 @@ async fn test_save_as(cx: &mut gpui::TestAppContext) { buffer.edit([(0..0, "abc")], None, cx); assert!(buffer.is_dirty()); assert!(!buffer.has_conflict()); - assert_eq!(buffer.language().unwrap().name(), "Plain Text".into()); + assert_eq!(buffer.language().unwrap().name(), "Plain Text"); }); project .update(cx, |project, cx| { @@ -5112,7 +5112,7 @@ async fn test_save_as(cx: &mut gpui::TestAppContext) { ); assert!(!buffer.is_dirty()); assert!(!buffer.has_conflict()); - assert_eq!(buffer.language().unwrap().name(), "Rust".into()); + assert_eq!(buffer.language().unwrap().name(), "Rust"); }); let opened_buffer = project diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 45af71b920ad8812d7cd8d06285ac3b7ddb7c9e1..a744f733e72aef7cb7a1f878d14412c8f9b742e3 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -678,7 +678,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext let buffer_id = cx.read(|cx| { let buffer = buffer.read(cx); - assert_eq!(buffer.language().unwrap().name(), "Rust".into()); + assert_eq!(buffer.language().unwrap().name(), "Rust"); buffer.remote_id() }); @@ -690,7 +690,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext .get(buffer_id) .unwrap(); - assert_eq!(buffer.read(cx).language().unwrap().name(), "Rust".into()); + assert_eq!(buffer.read(cx).language().unwrap().name(), "Rust"); }); server_cx.read(|cx| { diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index dacc20b70a6fac439cd9f9e999d0b22f291d98ba..6e061c3e2e37aa94074f17f94791ad147f56f344 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -562,7 +562,7 @@ fn runnable_ranges( cx: &mut App, ) -> (Vec>, Option) { if let Some(language) = buffer.language() - && language.name() == "Markdown".into() + && language.name() == "Markdown" { return (markdown_code_blocks(buffer, range, cx), None); } diff --git a/crates/repl/src/repl_sessions_ui.rs b/crates/repl/src/repl_sessions_ui.rs index d768d9dbede9fe17f85fec63a753efd7379d0d3d..1dc2107adde84d4625ffee489805570cd7e5f791 100644 --- a/crates/repl/src/repl_sessions_ui.rs +++ b/crates/repl/src/repl_sessions_ui.rs @@ -106,7 +106,7 @@ pub fn init(cx: &mut App) { let editor_handle = cx.entity().downgrade(); if let Some(language) = language - && language.name() == "Python".into() + && language.name() == "Python" && let (Some(project_path), Some(project)) = (project_path, project) { let store = ReplStore::global(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6edd3dadbef83a6fae6a4dfd2b3d10de41211f37..1e57334be5997585ecaca517f52134a210b364fe 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3666,7 +3666,7 @@ mod tests { .language_at(MultiBufferOffset(0), cx) .unwrap() .name(), - "Rust".into() + "Rust" ); }); }) @@ -3814,7 +3814,7 @@ mod tests { .language_at(MultiBufferOffset(0), cx) .unwrap() .name(), - "Rust".into() + "Rust" ) }); })