assistant: Support copy/pasting creases (#17490)

Bennet Bo Fenner and Thorsten created

https://github.com/user-attachments/assets/78a2572d-8e8f-4206-9680-dcd884e7bbbd

Release Notes:

- Added support for copying and pasting slash commands in the assistant
panel

---------

Co-authored-by: Thorsten <thorsten@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs     | 233 ++++++++++++++++++----
crates/editor/src/display_map/crease_map.rs |  92 ++++++++
2 files changed, 280 insertions(+), 45 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -26,8 +26,8 @@ use collections::{BTreeSet, HashMap, HashSet};
 use editor::{
     actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
     display_map::{
-        BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, CustomBlockId, FoldId,
-        RenderBlock, ToDisplayPoint,
+        BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, CreaseMetadata,
+        CustomBlockId, FoldId, RenderBlock, ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor},
     Anchor, Editor, EditorEvent, ExcerptRange, MultiBuffer, RowExt, ToOffset as _, ToPoint,
@@ -54,13 +54,13 @@ use multi_buffer::MultiBufferRow;
 use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectLspAdapterDelegate, Worktree};
 use search::{buffer_search::DivRegistrar, BufferSearchBar};
+use serde::{Deserialize, Serialize};
 use settings::{update_settings_file, Settings};
 use smol::stream::StreamExt;
 use std::{
     borrow::Cow,
     cmp,
     collections::hash_map,
-    fmt::Write,
     ops::{ControlFlow, Range},
     path::PathBuf,
     sync::Arc,
@@ -2491,20 +2491,26 @@ impl ContextEditor {
                     .unwrap();
                 let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
                 buffer_rows_to_fold.insert(buffer_row);
-                creases.push(Crease::new(
-                    start..end,
-                    FoldPlaceholder {
-                        render: render_fold_icon_button(
-                            cx.view().downgrade(),
-                            section.icon,
-                            section.label.clone(),
-                        ),
-                        constrain_width: false,
-                        merge_adjacent: false,
-                    },
-                    render_slash_command_output_toggle,
-                    |_, _, _| Empty.into_any_element(),
-                ));
+                creases.push(
+                    Crease::new(
+                        start..end,
+                        FoldPlaceholder {
+                            render: render_fold_icon_button(
+                                cx.view().downgrade(),
+                                section.icon,
+                                section.label.clone(),
+                            ),
+                            constrain_width: false,
+                            merge_adjacent: false,
+                        },
+                        render_slash_command_output_toggle,
+                        |_, _, _| Empty.into_any_element(),
+                    )
+                    .with_metadata(CreaseMetadata {
+                        icon: section.icon,
+                        label: section.label,
+                    }),
+                );
             }
 
             editor.insert_creases(creases, cx);
@@ -3318,39 +3324,113 @@ impl ContextEditor {
     }
 
     fn copy(&mut self, _: &editor::actions::Copy, cx: &mut ViewContext<Self>) {
-        let editor = self.editor.read(cx);
-        let context = self.context.read(cx);
-        if editor.selections.count() == 1 {
-            let selection = editor.selections.newest::<usize>(cx);
-            let mut copied_text = String::new();
-            let mut spanned_messages = 0;
-            for message in context.messages(cx) {
-                if message.offset_range.start >= selection.range().end {
-                    break;
-                } 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() {
-                        spanned_messages += 1;
-                        write!(&mut copied_text, "## {}\n\n", message.role).unwrap();
-                        for chunk in context.buffer().read(cx).text_for_range(range) {
-                            copied_text.push_str(chunk);
+        if self.editor.read(cx).selections.count() == 1 {
+            let (copied_text, metadata) = self.get_clipboard_contents(cx);
+            cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata(
+                copied_text,
+                metadata,
+            ));
+            cx.stop_propagation();
+            return;
+        }
+
+        cx.propagate();
+    }
+
+    fn cut(&mut self, _: &editor::actions::Cut, cx: &mut ViewContext<Self>) {
+        if self.editor.read(cx).selections.count() == 1 {
+            let (copied_text, metadata) = self.get_clipboard_contents(cx);
+
+            self.editor.update(cx, |editor, cx| {
+                let selections = editor.selections.all::<Point>(cx);
+
+                editor.transact(cx, |this, cx| {
+                    this.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                        s.select(selections);
+                    });
+                    this.insert("", cx);
+                    cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata(
+                        copied_text,
+                        metadata,
+                    ));
+                });
+            });
+
+            cx.stop_propagation();
+            return;
+        }
+
+        cx.propagate();
+    }
+
+    fn get_clipboard_contents(&mut self, cx: &mut ViewContext<Self>) -> (String, CopyMetadata) {
+        let creases = self.editor.update(cx, |editor, cx| {
+            let selection = editor.selections.newest::<Point>(cx);
+            let selection_start = editor.selections.newest::<usize>(cx).start;
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            editor.display_map.update(cx, |display_map, cx| {
+                display_map
+                    .snapshot(cx)
+                    .crease_snapshot
+                    .creases_in_range(
+                        MultiBufferRow(selection.start.row)..MultiBufferRow(selection.end.row + 1),
+                        &snapshot,
+                    )
+                    .filter_map(|crease| {
+                        if let Some(metadata) = &crease.metadata {
+                            let start = crease
+                                .range
+                                .start
+                                .to_offset(&snapshot)
+                                .saturating_sub(selection_start);
+                            let end = crease
+                                .range
+                                .end
+                                .to_offset(&snapshot)
+                                .saturating_sub(selection_start);
+
+                            let range_relative_to_selection = start..end;
+
+                            if range_relative_to_selection.is_empty() {
+                                None
+                            } else {
+                                Some(SelectedCreaseMetadata {
+                                    range_relative_to_selection,
+                                    crease: metadata.clone(),
+                                })
+                            }
+                        } else {
+                            None
                         }
-                        copied_text.push('\n');
+                    })
+                    .collect::<Vec<_>>()
+            })
+        });
+
+        let context = self.context.read(cx);
+        let selection = self.editor.read(cx).selections.newest::<usize>(cx);
+        let mut text = String::new();
+        for message in context.messages(cx) {
+            if message.offset_range.start >= selection.range().end {
+                break;
+            } 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() {
+                    for chunk in context.buffer().read(cx).text_for_range(range) {
+                        text.push_str(chunk);
                     }
+                    text.push('\n');
                 }
             }
-
-            if spanned_messages > 1 {
-                cx.write_to_clipboard(ClipboardItem::new_string(copied_text));
-                return;
-            }
         }
 
-        cx.propagate();
+        (text, CopyMetadata { creases })
     }
 
-    fn paste(&mut self, _: &editor::actions::Paste, cx: &mut ViewContext<Self>) {
+    fn paste(&mut self, action: &editor::actions::Paste, cx: &mut ViewContext<Self>) {
+        cx.stop_propagation();
+
         let images = if let Some(item) = cx.read_from_clipboard() {
             item.into_entries()
                 .filter_map(|entry| {
@@ -3365,9 +3445,62 @@ impl ContextEditor {
             Vec::new()
         };
 
+        let metadata = if let Some(item) = cx.read_from_clipboard() {
+            item.entries().first().and_then(|entry| {
+                if let ClipboardEntry::String(text) = entry {
+                    text.metadata_json::<CopyMetadata>()
+                } else {
+                    None
+                }
+            })
+        } else {
+            None
+        };
+
         if images.is_empty() {
-            // If we didn't find any valid image data to paste, propagate to let normal pasting happen.
-            cx.propagate();
+            self.editor.update(cx, |editor, cx| {
+                let paste_position = editor.selections.newest::<usize>(cx).head();
+                editor.paste(action, cx);
+
+                if let Some(metadata) = metadata {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+
+                    let mut buffer_rows_to_fold = BTreeSet::new();
+                    let weak_editor = cx.view().downgrade();
+                    editor.insert_creases(
+                        metadata.creases.into_iter().map(|metadata| {
+                            let start = buffer.anchor_after(
+                                paste_position + metadata.range_relative_to_selection.start,
+                            );
+                            let end = buffer.anchor_before(
+                                paste_position + metadata.range_relative_to_selection.end,
+                            );
+
+                            let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
+                            buffer_rows_to_fold.insert(buffer_row);
+                            Crease::new(
+                                start..end,
+                                FoldPlaceholder {
+                                    constrain_width: false,
+                                    render: render_fold_icon_button(
+                                        weak_editor.clone(),
+                                        metadata.crease.icon,
+                                        metadata.crease.label.clone(),
+                                    ),
+                                    merge_adjacent: false,
+                                },
+                                render_slash_command_output_toggle,
+                                |_, _, _| Empty.into_any(),
+                            )
+                            .with_metadata(metadata.crease.clone())
+                        }),
+                        cx,
+                    );
+                    for buffer_row in buffer_rows_to_fold.into_iter().rev() {
+                        editor.fold_at(&FoldAt { buffer_row }, cx);
+                    }
+                }
+            });
         } else {
             let mut image_positions = Vec::new();
             self.editor.update(cx, |editor, cx| {
@@ -4037,6 +4170,17 @@ fn render_fold_icon_button(
     })
 }
 
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct CopyMetadata {
+    creases: Vec<SelectedCreaseMetadata>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct SelectedCreaseMetadata {
+    range_relative_to_selection: Range<usize>,
+    crease: CreaseMetadata,
+}
+
 impl EventEmitter<EditorEvent> for ContextEditor {}
 impl EventEmitter<SearchEvent> for ContextEditor {}
 
@@ -4062,6 +4206,7 @@ impl Render for ContextEditor {
             .capture_action(cx.listener(ContextEditor::cancel))
             .capture_action(cx.listener(ContextEditor::save))
             .capture_action(cx.listener(ContextEditor::copy))
+            .capture_action(cx.listener(ContextEditor::cut))
             .capture_action(cx.listener(ContextEditor::paste))
             .capture_action(cx.listener(ContextEditor::cycle_message_role))
             .capture_action(cx.listener(ContextEditor::confirm_command))

crates/editor/src/display_map/crease_map.rs 🔗

@@ -1,10 +1,11 @@
 use collections::HashMap;
 use gpui::{AnyElement, IntoElement};
 use multi_buffer::{Anchor, AnchorRangeExt, MultiBufferRow, MultiBufferSnapshot, ToPoint};
+use serde::{Deserialize, Serialize};
 use std::{cmp::Ordering, ops::Range, sync::Arc};
 use sum_tree::{Bias, SeekTarget, SumTree};
 use text::Point;
-use ui::WindowContext;
+use ui::{IconName, SharedString, WindowContext};
 
 use crate::FoldPlaceholder;
 
@@ -49,6 +50,31 @@ impl CreaseSnapshot {
         None
     }
 
+    pub fn creases_in_range<'a>(
+        &'a self,
+        range: Range<MultiBufferRow>,
+        snapshot: &'a MultiBufferSnapshot,
+    ) -> impl '_ + Iterator<Item = &'a Crease> {
+        let start = snapshot.anchor_before(Point::new(range.start.0, 0));
+        let mut cursor = self.creases.cursor::<ItemSummary>();
+        cursor.seek(&start, Bias::Left, snapshot);
+
+        std::iter::from_fn(move || {
+            while let Some(item) = cursor.item() {
+                cursor.next(snapshot);
+                let crease_start = item.crease.range.start.to_point(snapshot);
+                let crease_end = item.crease.range.end.to_point(snapshot);
+                if crease_end.row > range.end.0 {
+                    continue;
+                }
+                if crease_start.row >= range.start.0 && crease_end.row < range.end.0 {
+                    return Some(&item.crease);
+                }
+            }
+            None
+        })
+    }
+
     pub fn crease_items_with_offsets(
         &self,
         snapshot: &MultiBufferSnapshot,
@@ -87,6 +113,14 @@ pub struct Crease {
     pub placeholder: FoldPlaceholder,
     pub render_toggle: RenderToggleFn,
     pub render_trailer: RenderTrailerFn,
+    pub metadata: Option<CreaseMetadata>,
+}
+
+/// Metadata about a [`Crease`], that is used for serialization.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CreaseMetadata {
+    pub icon: IconName,
+    pub label: SharedString,
 }
 
 impl Crease {
@@ -124,8 +158,14 @@ impl Crease {
             render_trailer: Arc::new(move |row, folded, cx| {
                 render_trailer(row, folded, cx).into_any_element()
             }),
+            metadata: None,
         }
     }
+
+    pub fn with_metadata(mut self, metadata: CreaseMetadata) -> Self {
+        self.metadata = Some(metadata);
+        self
+    }
 }
 
 impl std::fmt::Debug for Crease {
@@ -304,4 +344,54 @@ mod test {
             .query_row(MultiBufferRow(3), &snapshot)
             .is_none());
     }
+
+    #[gpui::test]
+    fn test_creases_in_range(cx: &mut AppContext) {
+        let text = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
+        let buffer = MultiBuffer::build_simple(text, cx);
+        let snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
+        let mut crease_map = CreaseMap::default();
+
+        let creases = [
+            Crease::new(
+                snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(1, 5)),
+                FoldPlaceholder::test(),
+                |_row, _folded, _toggle, _cx| div(),
+                |_row, _folded, _cx| div(),
+            ),
+            Crease::new(
+                snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_after(Point::new(3, 5)),
+                FoldPlaceholder::test(),
+                |_row, _folded, _toggle, _cx| div(),
+                |_row, _folded, _cx| div(),
+            ),
+            Crease::new(
+                snapshot.anchor_before(Point::new(5, 0))..snapshot.anchor_after(Point::new(5, 5)),
+                FoldPlaceholder::test(),
+                |_row, _folded, _toggle, _cx| div(),
+                |_row, _folded, _cx| div(),
+            ),
+        ];
+        crease_map.insert(creases, &snapshot);
+
+        let crease_snapshot = crease_map.snapshot();
+
+        let range = MultiBufferRow(0)..MultiBufferRow(7);
+        let creases: Vec<_> = crease_snapshot.creases_in_range(range, &snapshot).collect();
+        assert_eq!(creases.len(), 3);
+
+        let range = MultiBufferRow(2)..MultiBufferRow(5);
+        let creases: Vec<_> = crease_snapshot.creases_in_range(range, &snapshot).collect();
+        assert_eq!(creases.len(), 1);
+        assert_eq!(creases[0].range.start.to_point(&snapshot).row, 3);
+
+        let range = MultiBufferRow(0)..MultiBufferRow(2);
+        let creases: Vec<_> = crease_snapshot.creases_in_range(range, &snapshot).collect();
+        assert_eq!(creases.len(), 1);
+        assert_eq!(creases[0].range.start.to_point(&snapshot).row, 1);
+
+        let range = MultiBufferRow(6)..MultiBufferRow(7);
+        let creases: Vec<_> = crease_snapshot.creases_in_range(range, &snapshot).collect();
+        assert_eq!(creases.len(), 0);
+    }
 }