From 80c25960dd84afcc4bd01c2ee3bff7055dc49d22 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Thu, 22 Aug 2024 15:03:42 -0700 Subject: [PATCH] repl: Set up a way to copy output from the REPL (#16649) Closes #15494 Simple copy button to copy an individual output since selection is a bit more work. image Release Notes: - repl: Copy output from the REPL using a button --------- Co-authored-by: Mikayla --- 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(-) diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index 9769e3a5c9..99696481f3 100644 --- a/crates/gpui/src/assets.rs +++ b/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 { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 746c0aa00c..7007061cbb 100644 --- a/crates/gpui/src/platform.rs +++ b/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 { @@ -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, - id: u64, + pub bytes: Vec, + /// The unique ID for the image + pub id: u64, } impl Hash for Image { diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index f580992f5b..5a04ff35bd 100644 --- a/crates/repl/src/outputs.rs +++ b/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; + 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, height: u32, width: u32, image: Arc, @@ -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 { + 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, + 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('<', "<") + .replace('>', ">") + } + + 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::>() + .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::>(); + + row_content.join(" | ") + }) + .collect::>(); + + for row in body { + markdown.push_str(&format!("| {} |\n", row)); + } + + markdown } pub fn render(&self, cx: &ViewContext) -> AnyElement { @@ -242,6 +340,16 @@ impl TableView { } } +impl SupportsClipboard for TableView { + fn clipboard_content(&self, _cx: &WindowContext) -> Option { + 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, parsing_markdown_task: Option>>, } impl MarkdownView { pub fn from(text: String, cx: &mut ViewContext) -> 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 { + 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) -> 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 { + 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], diff --git a/crates/repl/src/stdio.rs b/crates/repl/src/stdio.rs index 9bbd07a2ac..23079a96e4 100644 --- a/crates/repl/src/stdio.rs +++ b/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 { + 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 + } +}