context_editor: Allow copying entire line when selection is empty (#30612)

Bennet Bo Fenner created

Closes #27879

Release Notes:

- Allow copying entire line when selection is empty in text threads

Change summary

crates/assistant_context_editor/src/context_editor.rs | 100 ++++++++++++
1 file changed, 96 insertions(+), 4 deletions(-)

Detailed changes

crates/assistant_context_editor/src/context_editor.rs 🔗

@@ -1590,7 +1590,7 @@ impl ContextEditor {
         &mut self,
         cx: &mut Context<Self>,
     ) -> (String, CopyMetadata, Vec<text::Selection<usize>>) {
-        let (selection, creases) = self.editor.update(cx, |editor, cx| {
+        let (mut selection, creases) = self.editor.update(cx, |editor, cx| {
             let mut selection = editor.selections.newest_adjusted(cx);
             let snapshot = editor.buffer().read(cx).snapshot(cx);
 
@@ -1648,7 +1648,18 @@ impl ContextEditor {
             } else if message.offset_range.end >= selection.range().start {
                 let range = cmp::max(message.offset_range.start, selection.range().start)
                     ..cmp::min(message.offset_range.end, selection.range().end);
-                if !range.is_empty() {
+                if range.is_empty() {
+                    let snapshot = context.buffer().read(cx).snapshot();
+                    let point = snapshot.offset_to_point(range.start);
+                    selection.start = snapshot.point_to_offset(Point::new(point.row, 0));
+                    selection.end = snapshot.point_to_offset(cmp::min(
+                        Point::new(point.row + 1, 0),
+                        snapshot.max_point(),
+                    ));
+                    for chunk in context.buffer().read(cx).text_for_range(selection.range()) {
+                        text.push_str(chunk);
+                    }
+                } else {
                     for chunk in context.buffer().read(cx).text_for_range(range) {
                         text.push_str(chunk);
                     }
@@ -3202,9 +3213,77 @@ pub fn make_lsp_adapter_delegate(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::App;
-    use language::Buffer;
+    use fs::FakeFs;
+    use gpui::{App, TestAppContext, VisualTestContext};
+    use language::{Buffer, LanguageRegistry};
+    use prompt_store::PromptBuilder;
     use unindent::Unindent;
+    use util::path;
+
+    #[gpui::test]
+    async fn test_copy_paste_no_selection(cx: &mut TestAppContext) {
+        cx.update(init_test);
+
+        let fs = FakeFs::new(cx.executor());
+        let registry = Arc::new(LanguageRegistry::test(cx.executor()));
+        let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
+        let context = cx.new(|cx| {
+            AssistantContext::local(
+                registry,
+                None,
+                None,
+                prompt_builder.clone(),
+                Arc::new(SlashCommandWorkingSet::default()),
+                cx,
+            )
+        });
+        let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let workspace = window.root(cx).unwrap();
+        let cx = &mut VisualTestContext::from_window(*window, cx);
+
+        let context_editor = window
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| {
+                    ContextEditor::for_context(
+                        context,
+                        fs,
+                        workspace.downgrade(),
+                        project,
+                        None,
+                        window,
+                        cx,
+                    )
+                })
+            })
+            .unwrap();
+
+        context_editor.update_in(cx, |context_editor, window, cx| {
+            context_editor.editor.update(cx, |editor, cx| {
+                editor.set_text("abc\ndef\nghi", window, cx);
+                editor.move_to_beginning(&Default::default(), window, cx);
+            })
+        });
+
+        context_editor.update_in(cx, |context_editor, window, cx| {
+            context_editor.editor.update(cx, |editor, cx| {
+                editor.copy(&Default::default(), window, cx);
+                editor.paste(&Default::default(), window, cx);
+
+                assert_eq!(editor.text(cx), "abc\nabc\ndef\nghi");
+            })
+        });
+
+        context_editor.update_in(cx, |context_editor, window, cx| {
+            context_editor.editor.update(cx, |editor, cx| {
+                editor.cut(&Default::default(), window, cx);
+                assert_eq!(editor.text(cx), "abc\ndef\nghi");
+
+                editor.paste(&Default::default(), window, cx);
+                assert_eq!(editor.text(cx), "abc\nabc\ndef\nghi");
+            })
+        });
+    }
 
     #[gpui::test]
     fn test_find_code_blocks(cx: &mut App) {
@@ -3279,4 +3358,17 @@ mod tests {
             assert_eq!(range, expected, "unexpected result on row {:?}", row);
         }
     }
+
+    fn init_test(cx: &mut App) {
+        let settings_store = SettingsStore::test(cx);
+        prompt_store::init(cx);
+        LanguageModelRegistry::test(cx);
+        cx.set_global(settings_store);
+        language::init(cx);
+        assistant_settings::init(cx);
+        Project::init_settings(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+        workspace::init_settings(cx);
+        editor::init_settings(cx);
+    }
 }