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.

<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>
This commit is contained in:
Kyle Kelley 2024-08-22 15:03:42 -07:00 committed by GitHub
parent 26f2369fa6
commit 80c25960dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 231 additions and 23 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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],

View File

@ -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
}
}