repl: Set up a way to copy output from the REPL (#16649)

Kyle Kelley and Mikayla created

Closes #15494

Simple copy button to copy an individual output since selection is a bit
more work.

<img width="790" alt="image"
src="https://github.com/user-attachments/assets/4a7d8b69-70cc-428e-8fe3-b95386d341ee">


Release Notes:

- repl: Copy output from the REPL using a button

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/gpui/src/assets.rs   |   2 
crates/gpui/src/platform.rs |  14 ++
crates/repl/src/outputs.rs  | 213 ++++++++++++++++++++++++++++++++++++--
crates/repl/src/stdio.rs    |  25 ++++
4 files changed, 231 insertions(+), 23 deletions(-)

Detailed changes

crates/gpui/src/assets.rs 🔗

@@ -30,7 +30,7 @@ impl AssetSource for () {
 
 /// A unique identifier for the image cache
 #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
-pub struct ImageId(usize);
+pub struct ImageId(pub usize);
 
 #[derive(PartialEq, Eq, Hash, Clone)]
 pub(crate) struct RenderImageParams {

crates/gpui/src/platform.rs 🔗

@@ -1016,6 +1016,13 @@ impl ClipboardItem {
         }
     }
 
+    /// Create a new ClipboardItem::Image with the given image with no associated metadata
+    pub fn new_image(image: &Image) -> Self {
+        Self {
+            entries: vec![ClipboardEntry::Image(image.clone())],
+        }
+    }
+
     /// Concatenates together all the ClipboardString entries in the item.
     /// Returns None if there were no ClipboardString entries.
     pub fn text(&self) -> Option<String> {
@@ -1084,10 +1091,11 @@ pub enum ImageFormat {
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct Image {
     /// The image format the bytes represent (e.g. PNG)
-    format: ImageFormat,
+    pub format: ImageFormat,
     /// The raw image bytes
-    bytes: Vec<u8>,
-    id: u64,
+    pub bytes: Vec<u8>,
+    /// The unique ID for the image
+    pub id: u64,
 }
 
 impl Hash for Image {

crates/repl/src/outputs.rs 🔗

@@ -5,8 +5,8 @@ use crate::stdio::TerminalOutput;
 use anyhow::Result;
 use base64::prelude::*;
 use gpui::{
-    img, percentage, Animation, AnimationExt, AnyElement, FontWeight, Render, RenderImage, Task,
-    TextRun, Transformation, View,
+    img, percentage, Animation, AnimationExt, AnyElement, ClipboardItem, FontWeight, Image,
+    ImageFormat, Render, RenderImage, Task, TextRun, Transformation, View,
 };
 use runtimelib::datatable::TableSchema;
 use runtimelib::media::datatable::TabularDataResource;
@@ -14,7 +14,7 @@ use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
 use serde_json::Value;
 use settings::Settings;
 use theme::ThemeSettings;
-use ui::{div, prelude::*, v_flex, IntoElement, Styled, ViewContext};
+use ui::{div, prelude::*, v_flex, IntoElement, Styled, Tooltip, ViewContext};
 
 use markdown_preview::{
     markdown_elements::ParsedMarkdown, markdown_parser::parse_markdown,
@@ -34,8 +34,14 @@ fn rank_mime_type(mimetype: &MimeType) -> usize {
     }
 }
 
+pub(crate) trait SupportsClipboard {
+    fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem>;
+    fn has_clipboard_content(&self, cx: &WindowContext) -> bool;
+}
+
 /// ImageView renders an image inline in an editor, adapting to the line height to fit the image.
 pub struct ImageView {
+    clipboard_image: Arc<Image>,
     height: u32,
     width: u32,
     image: Arc<RenderImage>,
@@ -78,7 +84,27 @@ impl ImageView {
 
         let gpui_image_data = RenderImage::new(vec![image::Frame::new(data)]);
 
+        let format = match format {
+            image::ImageFormat::Png => ImageFormat::Png,
+            image::ImageFormat::Jpeg => ImageFormat::Jpeg,
+            image::ImageFormat::Gif => ImageFormat::Gif,
+            image::ImageFormat::WebP => ImageFormat::Webp,
+            image::ImageFormat::Tiff => ImageFormat::Tiff,
+            image::ImageFormat::Bmp => ImageFormat::Bmp,
+            _ => {
+                return Err(anyhow::anyhow!("unsupported image format"));
+            }
+        };
+
+        // Convert back to a GPUI image for use with the clipboard
+        let clipboard_image = Arc::new(Image {
+            format,
+            bytes,
+            id: gpui_image_data.id.0 as u64,
+        });
+
         return Ok(ImageView {
+            clipboard_image,
             height,
             width,
             image: Arc::new(gpui_image_data),
@@ -86,11 +112,22 @@ impl ImageView {
     }
 }
 
+impl SupportsClipboard for ImageView {
+    fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
+        Some(ClipboardItem::new_image(self.clipboard_image.as_ref()))
+    }
+
+    fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
+        true
+    }
+}
+
 /// TableView renders a static table inline in a buffer.
 /// It uses the https://specs.frictionlessdata.io/tabular-data-resource/ specification for data interchange.
 pub struct TableView {
     pub table: TabularDataResource,
     pub widths: Vec<Pixels>,
+    cached_clipboard_content: ClipboardItem,
 }
 
 fn cell_content(row: &Value, field: &str) -> String {
@@ -151,7 +188,68 @@ impl TableView {
             widths.push(width)
         }
 
-        Self { table, widths }
+        let cached_clipboard_content = Self::create_clipboard_content(&table);
+
+        Self {
+            table,
+            widths,
+            cached_clipboard_content: ClipboardItem::new_string(cached_clipboard_content),
+        }
+    }
+
+    fn escape_markdown(s: &str) -> String {
+        s.replace('|', "\\|")
+            .replace('*', "\\*")
+            .replace('_', "\\_")
+            .replace('`', "\\`")
+            .replace('[', "\\[")
+            .replace(']', "\\]")
+            .replace('<', "&lt;")
+            .replace('>', "&gt;")
+    }
+
+    fn create_clipboard_content(table: &TabularDataResource) -> String {
+        let data = match table.data.as_ref() {
+            Some(data) => data,
+            None => &Vec::new(),
+        };
+        let schema = table.schema.clone();
+
+        let mut markdown = format!(
+            "| {} |\n",
+            table
+                .schema
+                .fields
+                .iter()
+                .map(|field| field.name.clone())
+                .collect::<Vec<_>>()
+                .join(" | ")
+        );
+
+        markdown.push_str("|---");
+        for _ in 1..table.schema.fields.len() {
+            markdown.push_str("|---");
+        }
+        markdown.push_str("|\n");
+
+        let body = data
+            .iter()
+            .map(|record: &Value| {
+                let row_content = schema
+                    .fields
+                    .iter()
+                    .map(|field| Self::escape_markdown(&cell_content(record, &field.name)))
+                    .collect::<Vec<_>>();
+
+                row_content.join(" | ")
+            })
+            .collect::<Vec<String>>();
+
+        for row in body {
+            markdown.push_str(&format!("| {} |\n", row));
+        }
+
+        markdown
     }
 
     pub fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
@@ -242,6 +340,16 @@ impl TableView {
     }
 }
 
+impl SupportsClipboard for TableView {
+    fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
+        Some(self.cached_clipboard_content.clone())
+    }
+
+    fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
+        true
+    }
+}
+
 /// Userspace error from the kernel
 pub struct ErrorView {
     pub ename: String,
@@ -288,34 +396,48 @@ impl ErrorView {
 }
 
 pub struct MarkdownView {
+    raw_text: String,
     contents: Option<ParsedMarkdown>,
     parsing_markdown_task: Option<Task<Result<()>>>,
 }
 
 impl MarkdownView {
     pub fn from(text: String, cx: &mut ViewContext<Self>) -> Self {
-        let task = cx.spawn(|markdown, mut cx| async move {
+        let task = cx.spawn(|markdown_view, mut cx| {
             let text = text.clone();
             let parsed = cx
                 .background_executor()
                 .spawn(async move { parse_markdown(&text, None, None).await });
 
-            let content = parsed.await;
+            async move {
+                let content = parsed.await;
 
-            markdown.update(&mut cx, |markdown, cx| {
-                markdown.parsing_markdown_task.take();
-                markdown.contents = Some(content);
-                cx.notify();
-            })
+                markdown_view.update(&mut cx, |markdown, cx| {
+                    markdown.parsing_markdown_task.take();
+                    markdown.contents = Some(content);
+                    cx.notify();
+                })
+            }
         });
 
         Self {
+            raw_text: text.clone(),
             contents: None,
             parsing_markdown_task: Some(task),
         }
     }
 }
 
+impl SupportsClipboard for MarkdownView {
+    fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
+        Some(ClipboardItem::new_string(self.raw_text.clone()))
+    }
+
+    fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
+        true
+    }
+}
+
 impl Render for MarkdownView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let Some(parsed) = self.contents.as_ref() else {
@@ -360,6 +482,34 @@ impl Output {
     }
 }
 
+impl SupportsClipboard for Output {
+    fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem> {
+        match &self.content {
+            OutputContent::Plain(terminal) => terminal.clipboard_content(cx),
+            OutputContent::Stream(terminal) => terminal.clipboard_content(cx),
+            OutputContent::Image(image) => image.clipboard_content(cx),
+            OutputContent::ErrorOutput(error) => error.traceback.clipboard_content(cx),
+            OutputContent::Message(_) => None,
+            OutputContent::Table(table) => table.clipboard_content(cx),
+            OutputContent::Markdown(markdown) => markdown.read(cx).clipboard_content(cx),
+            OutputContent::ClearOutputWaitMarker => None,
+        }
+    }
+
+    fn has_clipboard_content(&self, cx: &WindowContext) -> bool {
+        match &self.content {
+            OutputContent::Plain(terminal) => terminal.has_clipboard_content(cx),
+            OutputContent::Stream(terminal) => terminal.has_clipboard_content(cx),
+            OutputContent::Image(image) => image.has_clipboard_content(cx),
+            OutputContent::ErrorOutput(error) => error.traceback.has_clipboard_content(cx),
+            OutputContent::Message(_) => false,
+            OutputContent::Table(table) => table.has_clipboard_content(cx),
+            OutputContent::Markdown(markdown) => markdown.read(cx).has_clipboard_content(cx),
+            OutputContent::ClearOutputWaitMarker => false,
+        }
+    }
+}
+
 pub enum OutputContent {
     Plain(TerminalOutput),
     Stream(TerminalOutput),
@@ -638,11 +788,42 @@ impl Render for ExecutionView {
 
         div()
             .w_full()
-            .children(
-                self.outputs
-                    .iter()
-                    .filter_map(|output| output.content.render(cx)),
-            )
+            .children(self.outputs.iter().enumerate().map(|(index, output)| {
+                h_flex()
+                    .w_full()
+                    .items_start()
+                    .child(
+                        div().flex_1().child(
+                            output
+                                .content
+                                .render(cx)
+                                .unwrap_or_else(|| div().into_any_element()),
+                        ),
+                    )
+                    .when(output.has_clipboard_content(cx), |el| {
+                        let clipboard_content = output.clipboard_content(cx);
+
+                        el.child(
+                            div().pl_1().child(
+                                IconButton::new(
+                                    ElementId::Name(format!("copy-output-{}", index).into()),
+                                    IconName::Copy,
+                                )
+                                .style(ButtonStyle::Transparent)
+                                .tooltip(move |cx| Tooltip::text("Copy Output", cx))
+                                .on_click(cx.listener(
+                                    move |_, _, cx| {
+                                        if let Some(clipboard_content) = clipboard_content.as_ref()
+                                        {
+                                            cx.write_to_clipboard(clipboard_content.clone());
+                                            // todo!(): let the user know that the content was copied
+                                        }
+                                    },
+                                )),
+                            ),
+                        )
+                    })
+            }))
             .children(match self.status {
                 ExecutionStatus::Executing => vec![status],
                 ExecutionStatus::Queued => vec![status],

crates/repl/src/stdio.rs 🔗

@@ -1,6 +1,6 @@
-use crate::outputs::ExecutionView;
-use alacritty_terminal::{term::Config, vte::ansi::Processor};
-use gpui::{canvas, size, AnyElement, FontStyle, TextStyle, WhiteSpace};
+use crate::outputs::{ExecutionView, SupportsClipboard};
+use alacritty_terminal::{grid::Dimensions as _, term::Config, vte::ansi::Processor};
+use gpui::{canvas, size, AnyElement, ClipboardItem, FontStyle, TextStyle, WhiteSpace};
 use settings::Settings as _;
 use std::mem;
 use terminal::ZedListener;
@@ -181,3 +181,22 @@ impl TerminalOutput {
         .into_any_element()
     }
 }
+
+impl SupportsClipboard for TerminalOutput {
+    fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
+        let start = alacritty_terminal::index::Point::new(
+            alacritty_terminal::index::Line(0),
+            alacritty_terminal::index::Column(0),
+        );
+        let end = alacritty_terminal::index::Point::new(
+            alacritty_terminal::index::Line(self.handler.screen_lines() as i32 - 1),
+            alacritty_terminal::index::Column(self.handler.columns() - 1),
+        );
+        let text = self.handler.bounds_to_string(start, end);
+        Some(ClipboardItem::new_string(text.trim().into()))
+    }
+
+    fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
+        true
+    }
+}