From 6ea46623260f42587e1c4991bb8a335237a983de Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Tue, 29 Oct 2024 11:30:07 -0700 Subject: [PATCH] Initial Notebook UI structure (#19756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the start of a notebook UI for Zed. `🔔 Note: This won't be useable yet when it is merged! Read below. 🔔` This is going to be behind a feature flag so that we can merge this initial PR and then make follow up PRs. Release notes will be produced in a future PR. Minimum checklist for merging this: * [x] All functionality behind the `notebooks` feature flag (with env var opt out) * [x] Open notebook files in the workspace * [x] Remove the "Open Notebook" button from title bar * [x] Incorporate text style refinements for cell editors * [x] Rely on `nbformat` crate for parsing the notebook into our in-memory format * [x] Move notebook to a `gpui::List` * [x] Hook up output rendering Release Notes: - N/A --------- Co-authored-by: Nate Butler Co-authored-by: Thorsten Ball --- Cargo.lock | 45 +- Cargo.toml | 3 +- assets/icons/list_x.svg | 7 + crates/feature_flags/src/feature_flags.rs | 6 + crates/repl/Cargo.toml | 3 + crates/repl/src/notebook.rs | 4 + crates/repl/src/notebook/cell.rs | 733 ++++++++++++++++++++++ crates/repl/src/notebook/notebook_ui.rs | 672 ++++++++++++++++++++ crates/repl/src/outputs.rs | 4 +- crates/repl/src/repl.rs | 1 + crates/ui/src/components/icon.rs | 7 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 1 + 13 files changed, 1478 insertions(+), 9 deletions(-) create mode 100644 assets/icons/list_x.svg create mode 100644 crates/repl/src/notebook.rs create mode 100644 crates/repl/src/notebook/cell.rs create mode 100644 crates/repl/src/notebook/notebook_ui.rs diff --git a/Cargo.lock b/Cargo.lock index c04ec535a6041d2886fa3ff26378dba3e059df3b..4d95eee0980b78500b6257ec9e8e4323916114c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,7 +1586,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -5584,7 +5584,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "socket2 0.5.7", "tokio", "tower-service", "tracing", @@ -6154,6 +6154,20 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jupyter-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a444fb3f87ee6885eb316028cc998c7d84811663ef95d78c419419423d5a054" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "thiserror", + "uuid", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -6474,7 +6488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -7137,6 +7151,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nbformat" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146074ad45cab20f5d98ccded164826158471f21d04f96e40b9872529e10979d" +dependencies = [ + "anyhow", + "chrono", + "jupyter-serde", + "serde", + "serde_json", + "thiserror", + "uuid", +] + [[package]] name = "ndk" version = "0.8.0" @@ -9579,6 +9608,7 @@ dependencies = [ "command_palette_hooks", "editor", "env_logger 0.11.5", + "feature_flags", "futures 0.3.30", "gpui", "http_client", @@ -9588,7 +9618,9 @@ dependencies = [ "languages", "log", "markdown_preview", + "menu", "multi_buffer", + "nbformat", "project", "runtimelib", "schemars", @@ -9927,9 +9959,9 @@ dependencies = [ [[package]] name = "runtimelib" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7d76d28b882a7b889ebb04e79bc2b160b3061821ea596ff0f4a838fc7a76db0" +checksum = "263588fe9593333c4bfde258c9021fc64e766ea434e070c6b67c7100536d6499" dependencies = [ "anyhow", "async-dispatcher", @@ -9941,6 +9973,7 @@ dependencies = [ "dirs 5.0.1", "futures 0.3.30", "glob", + "jupyter-serde", "rand 0.8.5", "ring 0.17.8", "serde", @@ -14126,7 +14159,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0697cc0c0becc472a71ee9ddc640c04404122b54..e269dd99ea71a70ae8d02216ad1dc901561fc7c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -371,6 +371,7 @@ linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } markup5ever_rcdom = "0.3.0" nanoid = "0.4" +nbformat = "0.3.1" nix = "0.29" num-format = "0.4.4" once_cell = "1.19.0" @@ -402,7 +403,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f "stream", ] } rsa = "0.9.6" -runtimelib = { version = "0.15", default-features = false, features = [ +runtimelib = { version = "0.16.0", default-features = false, features = [ "async-dispatcher-runtime", ] } rustc-demangle = "0.1.23" diff --git a/assets/icons/list_x.svg b/assets/icons/list_x.svg new file mode 100644 index 0000000000000000000000000000000000000000..683f38ab5dfe5b7f30cb507bd9e4679321fc774a --- /dev/null +++ b/assets/icons/list_x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index fb4e192023d914f9ea651e838b7678b548d329d8..286acdfc98e6cc51ec3b77f86acb0a7d1b37e379 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -59,6 +59,12 @@ impl FeatureFlag for ZedPro { const NAME: &'static str = "zed-pro"; } +pub struct NotebookFeatureFlag; + +impl FeatureFlag for NotebookFeatureFlag { + const NAME: &'static str = "notebooks"; +} + pub struct AutoCommand {} impl FeatureFlag for AutoCommand { const NAME: &'static str = "auto-command"; diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index 001bf157d54c05693c1db4d4a2e7ba943dcede26..f035878d332654e321f8447c2bdc0ee9a964d6c7 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -21,13 +21,16 @@ client.workspace = true collections.workspace = true command_palette_hooks.workspace = true editor.workspace = true +feature_flags.workspace = true futures.workspace = true gpui.workspace = true image.workspace = true language.workspace = true log.workspace = true markdown_preview.workspace = true +menu.workspace = true multi_buffer.workspace = true +nbformat.workspace = true project.workspace = true runtimelib.workspace = true schemars.workspace = true diff --git a/crates/repl/src/notebook.rs b/crates/repl/src/notebook.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c6738f79979e8b4b5bed749ebee7993cc509b97 --- /dev/null +++ b/crates/repl/src/notebook.rs @@ -0,0 +1,4 @@ +mod cell; +mod notebook_ui; +pub use cell::*; +pub use notebook_ui::*; diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs new file mode 100644 index 0000000000000000000000000000000000000000..bb6b6fbf3865b37d7e701b64fe9fe00a5a5c49cd --- /dev/null +++ b/crates/repl/src/notebook/cell.rs @@ -0,0 +1,733 @@ +#![allow(unused, dead_code)] +use std::sync::Arc; + +use editor::{Editor, EditorMode, MultiBuffer}; +use futures::future::Shared; +use gpui::{prelude::*, AppContext, Hsla, Task, TextStyleRefinement, View}; +use language::{Buffer, Language, LanguageRegistry}; +use markdown_preview::{markdown_parser::parse_markdown, markdown_renderer::render_markdown_block}; +use nbformat::v4::{CellId, CellMetadata, CellType}; +use settings::Settings as _; +use theme::ThemeSettings; +use ui::{prelude::*, IconButtonShape}; +use util::ResultExt; + +use crate::{ + notebook::{CODE_BLOCK_INSET, GUTTER_WIDTH}, + outputs::{plain::TerminalOutput, user_error::ErrorView, Output}, +}; + +#[derive(Copy, Clone, PartialEq, PartialOrd)] +pub enum CellPosition { + First, + Middle, + Last, +} + +pub enum CellControlType { + RunCell, + RerunCell, + ClearCell, + CellOptions, + CollapseCell, + ExpandCell, +} + +impl CellControlType { + fn icon_name(&self) -> IconName { + match self { + CellControlType::RunCell => IconName::Play, + CellControlType::RerunCell => IconName::ArrowCircle, + CellControlType::ClearCell => IconName::ListX, + CellControlType::CellOptions => IconName::Ellipsis, + CellControlType::CollapseCell => IconName::ChevronDown, + CellControlType::ExpandCell => IconName::ChevronRight, + } + } +} + +pub struct CellControl { + button: IconButton, +} + +impl CellControl { + fn new(id: impl Into, control_type: CellControlType) -> Self { + let icon_name = control_type.icon_name(); + let id = id.into(); + let button = IconButton::new(id, icon_name) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square); + Self { button } + } +} + +impl Clickable for CellControl { + fn on_click(self, handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static) -> Self { + let button = self.button.on_click(handler); + Self { button } + } + + fn cursor_style(self, _cursor_style: gpui::CursorStyle) -> Self { + self + } +} + +/// A notebook cell +#[derive(Clone)] +pub enum Cell { + Code(View), + Markdown(View), + Raw(View), +} + +fn convert_outputs(outputs: &Vec, cx: &mut WindowContext) -> Vec { + outputs + .into_iter() + .map(|output| match output { + nbformat::v4::Output::Stream { text, .. } => Output::Stream { + content: cx.new_view(|cx| TerminalOutput::from(&text.0, cx)), + }, + nbformat::v4::Output::DisplayData(display_data) => { + Output::new(&display_data.data, None, cx) + } + nbformat::v4::Output::ExecuteResult(execute_result) => { + Output::new(&execute_result.data, None, cx) + } + nbformat::v4::Output::Error(error) => Output::ErrorOutput(ErrorView { + ename: error.ename.clone(), + evalue: error.evalue.clone(), + traceback: cx.new_view(|cx| TerminalOutput::from(&error.traceback.join("\n"), cx)), + }), + }) + .collect() +} + +impl Cell { + pub fn load( + cell: &nbformat::v4::Cell, + languages: &Arc, + notebook_language: Shared>>>, + cx: &mut WindowContext, + ) -> Self { + match cell { + nbformat::v4::Cell::Markdown { + id, + metadata, + source, + attachments: _, + } => { + let source = source.join(""); + + let view = cx.new_view(|cx| { + let markdown_parsing_task = { + let languages = languages.clone(); + let source = source.clone(); + + cx.spawn(|this, mut cx| async move { + let parsed_markdown = cx + .background_executor() + .spawn(async move { + parse_markdown(&source, None, Some(languages)).await + }) + .await; + + this.update(&mut cx, |cell: &mut MarkdownCell, _| { + cell.parsed_markdown = Some(parsed_markdown); + }) + .log_err(); + }) + }; + + MarkdownCell { + markdown_parsing_task, + languages: languages.clone(), + id: id.clone(), + metadata: metadata.clone(), + source: source.clone(), + parsed_markdown: None, + selected: false, + cell_position: None, + } + }); + + Cell::Markdown(view) + } + nbformat::v4::Cell::Code { + id, + metadata, + execution_count, + source, + outputs, + } => Cell::Code(cx.new_view(|cx| { + let text = source.join(""); + + let buffer = cx.new_model(|cx| Buffer::local(text.clone(), cx)); + let multi_buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let editor_view = cx.new_view(|cx| { + let mut editor = Editor::new( + EditorMode::AutoHeight { max_lines: 1024 }, + multi_buffer, + None, + false, + cx, + ); + + let theme = ThemeSettings::get_global(cx); + + let refinement = TextStyleRefinement { + font_family: Some(theme.buffer_font.family.clone()), + font_size: Some(theme.buffer_font_size.into()), + color: Some(cx.theme().colors().editor_foreground), + background_color: Some(gpui::transparent_black()), + ..Default::default() + }; + + editor.set_text(text, cx); + editor.set_show_gutter(false, cx); + editor.set_text_style_refinement(refinement); + + // editor.set_read_only(true); + editor + }); + + let buffer = buffer.clone(); + let language_task = cx.spawn(|this, mut cx| async move { + let language = notebook_language.await; + + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(language.clone(), cx); + }); + }); + + CodeCell { + id: id.clone(), + metadata: metadata.clone(), + execution_count: *execution_count, + source: source.join(""), + editor: editor_view, + outputs: convert_outputs(outputs, cx), + selected: false, + language_task, + cell_position: None, + } + })), + nbformat::v4::Cell::Raw { + id, + metadata, + source, + } => Cell::Raw(cx.new_view(|_| RawCell { + id: id.clone(), + metadata: metadata.clone(), + source: source.join(""), + selected: false, + cell_position: None, + })), + } + } +} + +pub trait RenderableCell: Render { + const CELL_TYPE: CellType; + + fn id(&self) -> &CellId; + fn cell_type(&self) -> CellType; + fn metadata(&self) -> &CellMetadata; + fn source(&self) -> &String; + fn selected(&self) -> bool; + fn set_selected(&mut self, selected: bool) -> &mut Self; + fn selected_bg_color(&self, cx: &ViewContext) -> Hsla { + if self.selected() { + let mut color = cx.theme().colors().icon_accent; + color.fade_out(0.9); + color + } else { + // TODO: this is wrong + cx.theme().colors().tab_bar_background + } + } + fn control(&self, _cx: &ViewContext) -> Option { + None + } + + fn cell_position_spacer( + &self, + is_first: bool, + cx: &ViewContext, + ) -> Option { + let cell_position = self.cell_position(); + + if (cell_position == Some(&CellPosition::First) && is_first) + || (cell_position == Some(&CellPosition::Last) && !is_first) + { + Some(div().flex().w_full().h(Spacing::XLarge.px(cx))) + } else { + None + } + } + + fn gutter(&self, cx: &ViewContext) -> impl IntoElement { + let is_selected = self.selected(); + + div() + .relative() + .h_full() + .w(px(GUTTER_WIDTH)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex() + .flex_none() + .justify_center() + .h_full() + .child( + div() + .flex_none() + .w(px(1.)) + .h_full() + .when(is_selected, |this| this.bg(cx.theme().colors().icon_accent)) + .when(!is_selected, |this| this.bg(cx.theme().colors().border)), + ), + ) + .when_some(self.control(cx), |this, control| { + this.child( + div() + .absolute() + .top(px(CODE_BLOCK_INSET - 2.0)) + .left_0() + .flex() + .flex_none() + .w(px(GUTTER_WIDTH)) + .h(px(GUTTER_WIDTH + 12.0)) + .items_center() + .justify_center() + .bg(cx.theme().colors().tab_bar_background) + .child(control.button), + ) + }) + } + + fn cell_position(&self) -> Option<&CellPosition>; + fn set_cell_position(&mut self, position: CellPosition) -> &mut Self; +} + +pub trait RunnableCell: RenderableCell { + fn execution_count(&self) -> Option; + fn set_execution_count(&mut self, count: i32) -> &mut Self; + fn run(&mut self, cx: &mut ViewContext) -> (); +} + +pub struct MarkdownCell { + id: CellId, + metadata: CellMetadata, + source: String, + parsed_markdown: Option, + markdown_parsing_task: Task<()>, + selected: bool, + cell_position: Option, + languages: Arc, +} + +impl RenderableCell for MarkdownCell { + const CELL_TYPE: CellType = CellType::Markdown; + + fn id(&self) -> &CellId { + &self.id + } + + fn cell_type(&self) -> CellType { + CellType::Markdown + } + + fn metadata(&self) -> &CellMetadata { + &self.metadata + } + + fn source(&self) -> &String { + &self.source + } + + fn selected(&self) -> bool { + self.selected + } + + fn set_selected(&mut self, selected: bool) -> &mut Self { + self.selected = selected; + self + } + + fn control(&self, _: &ViewContext) -> Option { + None + } + + fn cell_position(&self) -> Option<&CellPosition> { + self.cell_position.as_ref() + } + + fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self { + self.cell_position = Some(cell_position); + self + } +} + +impl Render for MarkdownCell { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let Some(parsed) = self.parsed_markdown.as_ref() else { + return div(); + }; + + let mut markdown_render_context = + markdown_preview::markdown_renderer::RenderContext::new(None, cx); + + v_flex() + .size_full() + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(true, cx)) + .child( + h_flex() + .w_full() + .pr_6() + .rounded_sm() + .items_start() + .gap(Spacing::Large.rems(cx)) + .bg(self.selected_bg_color(cx)) + .child(self.gutter(cx)) + .child( + v_flex() + .size_full() + .flex_1() + .p_3() + .font_ui(cx) + .text_size(TextSize::Default.rems(cx)) + // + .children(parsed.children.iter().map(|child| { + div().relative().child(div().relative().child( + render_markdown_block(child, &mut markdown_render_context), + )) + })), + ), + ) + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(false, cx)) + } +} + +pub struct CodeCell { + id: CellId, + metadata: CellMetadata, + execution_count: Option, + source: String, + editor: View, + outputs: Vec, + selected: bool, + cell_position: Option, + language_task: Task<()>, +} + +impl CodeCell { + pub fn is_dirty(&self, cx: &AppContext) -> bool { + self.editor.read(cx).buffer().read(cx).is_dirty(cx) + } + pub fn has_outputs(&self) -> bool { + !self.outputs.is_empty() + } + + pub fn clear_outputs(&mut self) { + self.outputs.clear(); + } + + fn output_control(&self) -> Option { + if self.has_outputs() { + Some(CellControlType::ClearCell) + } else { + None + } + } + + pub fn gutter_output(&self, cx: &ViewContext) -> impl IntoElement { + let is_selected = self.selected(); + + div() + .relative() + .h_full() + .w(px(GUTTER_WIDTH)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex() + .flex_none() + .justify_center() + .h_full() + .child( + div() + .flex_none() + .w(px(1.)) + .h_full() + .when(is_selected, |this| this.bg(cx.theme().colors().icon_accent)) + .when(!is_selected, |this| this.bg(cx.theme().colors().border)), + ), + ) + .when(self.has_outputs(), |this| { + this.child( + div() + .absolute() + .top(px(CODE_BLOCK_INSET - 2.0)) + .left_0() + .flex() + .flex_none() + .w(px(GUTTER_WIDTH)) + .h(px(GUTTER_WIDTH + 12.0)) + .items_center() + .justify_center() + .bg(cx.theme().colors().tab_bar_background) + .child(IconButton::new("control", IconName::Ellipsis)), + ) + }) + } +} + +impl RenderableCell for CodeCell { + const CELL_TYPE: CellType = CellType::Code; + + fn id(&self) -> &CellId { + &self.id + } + + fn cell_type(&self) -> CellType { + CellType::Code + } + + fn metadata(&self) -> &CellMetadata { + &self.metadata + } + + fn source(&self) -> &String { + &self.source + } + + fn control(&self, cx: &ViewContext) -> Option { + let cell_control = if self.has_outputs() { + CellControl::new("rerun-cell", CellControlType::RerunCell) + } else { + CellControl::new("run-cell", CellControlType::RunCell) + .on_click(cx.listener(move |this, _, cx| this.run(cx))) + }; + + Some(cell_control) + } + + fn selected(&self) -> bool { + self.selected + } + + fn set_selected(&mut self, selected: bool) -> &mut Self { + self.selected = selected; + self + } + + fn cell_position(&self) -> Option<&CellPosition> { + self.cell_position.as_ref() + } + + fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self { + self.cell_position = Some(cell_position); + self + } +} + +impl RunnableCell for CodeCell { + fn run(&mut self, cx: &mut ViewContext) { + println!("Running code cell: {}", self.id); + } + + fn execution_count(&self) -> Option { + self.execution_count + .and_then(|count| if count > 0 { Some(count) } else { None }) + } + + fn set_execution_count(&mut self, count: i32) -> &mut Self { + self.execution_count = Some(count); + self + } +} + +impl Render for CodeCell { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let lines = self.source.lines().count(); + let height = lines as f32 * cx.line_height(); + + v_flex() + .size_full() + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(true, cx)) + // Editor portion + .child( + h_flex() + .w_full() + .pr_6() + .rounded_sm() + .items_start() + .gap(Spacing::Large.rems(cx)) + .bg(self.selected_bg_color(cx)) + .child(self.gutter(cx)) + .child( + div().py_1p5().w_full().child( + div() + .flex() + .size_full() + .flex_1() + .py_3() + .px_5() + .rounded_lg() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .child(div().h(height).w_full().child(self.editor.clone())), + ), + ), + ) + // Output portion + .child( + h_flex() + .w_full() + .pr_6() + .rounded_sm() + .items_start() + .gap(Spacing::Large.rems(cx)) + .bg(self.selected_bg_color(cx)) + .child(self.gutter_output(cx)) + .child( + div().py_1p5().w_full().child( + div() + .flex() + .size_full() + .flex_1() + .py_3() + .px_5() + .rounded_lg() + .border_1() + // .border_color(cx.theme().colors().border) + // .bg(cx.theme().colors().editor_background) + .child(div().w_full().children(self.outputs.iter().map( + |output| { + let content = match output { + Output::Plain { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Markdown { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Stream { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Image { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Message(message) => Some( + div().child(message.clone()).into_any_element(), + ), + Output::Table { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::ErrorOutput(error_view) => { + error_view.render(cx) + } + Output::ClearOutputWaitMarker => None, + }; + + div() + // .w_full() + // .mt_3() + // .p_3() + // .rounded_md() + // .bg(cx.theme().colors().editor_background) + // .border(px(1.)) + // .border_color(cx.theme().colors().border) + // .shadow_sm() + .children(content) + }, + ))), + ), + ), + ) + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(false, cx)) + } +} + +pub struct RawCell { + id: CellId, + metadata: CellMetadata, + source: String, + selected: bool, + cell_position: Option, +} + +impl RenderableCell for RawCell { + const CELL_TYPE: CellType = CellType::Raw; + + fn id(&self) -> &CellId { + &self.id + } + + fn cell_type(&self) -> CellType { + CellType::Raw + } + + fn metadata(&self) -> &CellMetadata { + &self.metadata + } + + fn source(&self) -> &String { + &self.source + } + + fn selected(&self) -> bool { + self.selected + } + + fn set_selected(&mut self, selected: bool) -> &mut Self { + self.selected = selected; + self + } + + fn cell_position(&self) -> Option<&CellPosition> { + self.cell_position.as_ref() + } + + fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self { + self.cell_position = Some(cell_position); + self + } +} + +impl Render for RawCell { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .size_full() + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(true, cx)) + .child( + h_flex() + .w_full() + .pr_2() + .rounded_sm() + .items_start() + .gap(Spacing::Large.rems(cx)) + .bg(self.selected_bg_color(cx)) + .child(self.gutter(cx)) + .child( + div() + .flex() + .size_full() + .flex_1() + .p_3() + .font_ui(cx) + .text_size(TextSize::Default.rems(cx)) + .child(self.source.clone()), + ), + ) + // TODO: Move base cell render into trait impl so we don't have to repeat this + .children(self.cell_position_spacer(false, cx)) + } +} diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..36d6e293856898ccba335b59bbfe793a4aa664c5 --- /dev/null +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -0,0 +1,672 @@ +#![allow(unused, dead_code)] +use std::{path::PathBuf, sync::Arc}; + +use client::proto::ViewId; +use collections::HashMap; +use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag}; +use futures::FutureExt; +use gpui::{ + actions, list, prelude::*, AppContext, EventEmitter, FocusHandle, FocusableView, + ListScrollEvent, ListState, Model, Task, +}; +use language::LanguageRegistry; +use project::{Project, ProjectEntryId, ProjectPath}; +use ui::{prelude::*, Tooltip}; +use workspace::item::ItemEvent; +use workspace::{Item, ItemHandle, ProjectItem, ToolbarItemLocation}; +use workspace::{ToolbarItemEvent, ToolbarItemView}; + +use super::{Cell, CellPosition, RenderableCell}; + +use nbformat::v4::CellId; +use nbformat::v4::Metadata as NotebookMetadata; + +pub(crate) const DEFAULT_NOTEBOOK_FORMAT: i32 = 4; +pub(crate) const DEFAULT_NOTEBOOK_FORMAT_MINOR: i32 = 0; + +actions!( + notebook, + [ + OpenNotebook, + RunAll, + ClearOutputs, + MoveCellUp, + MoveCellDown, + AddMarkdownBlock, + AddCodeBlock, + ] +); + +pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0; +pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0; +pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0; +pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0; +pub(crate) const GUTTER_WIDTH: f32 = 19.0; +pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE; +pub(crate) const CONTROL_SIZE: f32 = 20.0; + +pub fn init(cx: &mut AppContext) { + if cx.has_flag::() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() { + workspace::register_project_item::(cx); + } + + cx.observe_flag::({ + move |is_enabled, cx| { + if is_enabled { + workspace::register_project_item::(cx); + } else { + // todo: there is no way to unregister a project item, so if the feature flag + // gets turned off they need to restart Zed. + } + } + }) + .detach(); +} + +pub struct NotebookEditor { + languages: Arc, + + focus_handle: FocusHandle, + project: Model, + path: ProjectPath, + + remote_id: Option, + cell_list: ListState, + + metadata: NotebookMetadata, + nbformat: i32, + nbformat_minor: i32, + selected_cell_index: usize, + cell_order: Vec, + cell_map: HashMap, +} + +impl NotebookEditor { + pub fn new( + project: Model, + notebook_item: Model, + cx: &mut ViewContext, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let notebook = notebook_item.read(cx).notebook.clone(); + + let languages = project.read(cx).languages().clone(); + + let metadata = notebook.metadata; + let nbformat = notebook.nbformat; + let nbformat_minor = notebook.nbformat_minor; + + let language_name = metadata + .language_info + .as_ref() + .map(|l| l.name.clone()) + .or(metadata + .kernelspec + .as_ref() + .and_then(|spec| spec.language.clone())); + + let notebook_language = if let Some(language_name) = language_name { + cx.spawn(|_, _| { + let languages = languages.clone(); + async move { languages.language_for_name(&language_name).await.ok() } + }) + .shared() + } else { + Task::ready(None).shared() + }; + + let languages = project.read(cx).languages().clone(); + let notebook_language = cx + .spawn(|_, _| { + // todo: pull from notebook metadata + const TODO: &'static str = "Python"; + let languages = languages.clone(); + async move { languages.language_for_name(TODO).await.ok() } + }) + .shared(); + + let mut cell_order = vec![]; + let mut cell_map = HashMap::default(); + + for (index, cell) in notebook.cells.iter().enumerate() { + let cell_id = cell.id(); + cell_order.push(cell_id.clone()); + cell_map.insert( + cell_id.clone(), + Cell::load(cell, &languages, notebook_language.clone(), cx), + ); + } + + let view = cx.view().downgrade(); + let cell_count = cell_order.len(); + let cell_order_for_list = cell_order.clone(); + let cell_map_for_list = cell_map.clone(); + + let cell_list = ListState::new( + cell_count, + gpui::ListAlignment::Top, + // TODO: This is a totally random number, + // not sure what this should be + px(3000.), + move |ix, cx| { + let cell_order_for_list = cell_order_for_list.clone(); + let cell_id = cell_order_for_list[ix].clone(); + if let Some(view) = view.upgrade() { + let cell_id = cell_id.clone(); + if let Some(cell) = cell_map_for_list.clone().get(&cell_id) { + view.update(cx, |view, cx| { + view.render_cell(ix, cell, cx).into_any_element() + }) + } else { + div().into_any() + } + } else { + div().into_any() + } + }, + ); + + Self { + languages: languages.clone(), + focus_handle, + project, + path: notebook_item.read(cx).project_path.clone(), + remote_id: None, + cell_list, + selected_cell_index: 0, + metadata, + nbformat, + nbformat_minor, + cell_order: cell_order.clone(), + cell_map: cell_map.clone(), + } + } + + fn has_outputs(&self, cx: &ViewContext) -> bool { + self.cell_map.values().any(|cell| { + if let Cell::Code(code_cell) = cell { + code_cell.read(cx).has_outputs() + } else { + false + } + }) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.cell_map.values().any(|cell| { + if let Cell::Code(code_cell) = cell { + code_cell.read(cx).is_dirty(cx) + } else { + false + } + }) + } + + fn clear_outputs(&mut self, cx: &mut ViewContext) { + for cell in self.cell_map.values() { + if let Cell::Code(code_cell) = cell { + code_cell.update(cx, |cell, _cx| { + cell.clear_outputs(); + }); + } + } + } + + fn run_cells(&mut self, cx: &mut ViewContext) { + println!("Cells would all run here, if that was implemented!"); + } + + fn open_notebook(&mut self, _: &OpenNotebook, _cx: &mut ViewContext) { + println!("Open notebook triggered"); + } + + fn move_cell_up(&mut self, cx: &mut ViewContext) { + println!("Move cell up triggered"); + } + + fn move_cell_down(&mut self, cx: &mut ViewContext) { + println!("Move cell down triggered"); + } + + fn add_markdown_block(&mut self, cx: &mut ViewContext) { + println!("Add markdown block triggered"); + } + + fn add_code_block(&mut self, cx: &mut ViewContext) { + println!("Add code block triggered"); + } + + fn cell_count(&self) -> usize { + self.cell_map.len() + } + + fn selected_index(&self) -> usize { + self.selected_cell_index + } + + pub fn set_selected_index( + &mut self, + index: usize, + jump_to_index: bool, + cx: &mut ViewContext, + ) { + // let previous_index = self.selected_cell_index; + self.selected_cell_index = index; + let current_index = self.selected_cell_index; + + // in the future we may have some `on_cell_change` event that we want to fire here + + if jump_to_index { + self.jump_to_cell(current_index, cx); + } + } + + pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { + let count = self.cell_count(); + if count > 0 { + let index = self.selected_index(); + let ix = if index == count - 1 { + count - 1 + } else { + index + 1 + }; + self.set_selected_index(ix, true, cx); + cx.notify(); + } + } + + pub fn select_previous(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext) { + let count = self.cell_count(); + if count > 0 { + let index = self.selected_index(); + let ix = if index == 0 { 0 } else { index - 1 }; + self.set_selected_index(ix, true, cx); + cx.notify(); + } + } + + pub fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext) { + let count = self.cell_count(); + if count > 0 { + self.set_selected_index(0, true, cx); + cx.notify(); + } + } + + pub fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext) { + let count = self.cell_count(); + if count > 0 { + self.set_selected_index(count - 1, true, cx); + cx.notify(); + } + } + + fn jump_to_cell(&mut self, index: usize, _cx: &mut ViewContext) { + self.cell_list.scroll_to_reveal_item(index); + } + + fn button_group(cx: &ViewContext) -> Div { + v_flex() + .gap(Spacing::Small.rems(cx)) + .items_center() + .w(px(CONTROL_SIZE + 4.0)) + .overflow_hidden() + .rounded(px(5.)) + .bg(cx.theme().colors().title_bar_background) + .p_px() + .border_1() + .border_color(cx.theme().colors().border) + } + + fn render_notebook_control( + id: impl Into, + icon: IconName, + _cx: &ViewContext, + ) -> IconButton { + let id: ElementId = ElementId::Name(id.into()); + IconButton::new(id, icon).width(px(CONTROL_SIZE).into()) + } + + fn render_notebook_controls(&self, cx: &ViewContext) -> impl IntoElement { + let has_outputs = self.has_outputs(cx); + + v_flex() + .max_w(px(CONTROL_SIZE + 4.0)) + .items_center() + .gap(Spacing::XXLarge.rems(cx)) + .justify_between() + .flex_none() + .h_full() + .py(Spacing::XLarge.px(cx)) + .child( + v_flex() + .gap(Spacing::Large.rems(cx)) + .child( + Self::button_group(cx) + .child( + Self::render_notebook_control("run-all-cells", IconName::Play, cx) + .tooltip(move |cx| { + Tooltip::for_action("Execute all cells", &RunAll, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(RunAll)); + }), + ) + .child( + Self::render_notebook_control( + "clear-all-outputs", + IconName::ListX, + cx, + ) + .disabled(!has_outputs) + .tooltip(move |cx| { + Tooltip::for_action("Clear all outputs", &ClearOutputs, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(ClearOutputs)); + }), + ), + ) + .child( + Self::button_group(cx) + .child( + Self::render_notebook_control( + "move-cell-up", + IconName::ArrowUp, + cx, + ) + .tooltip(move |cx| { + Tooltip::for_action("Move cell up", &MoveCellUp, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(MoveCellUp)); + }), + ) + .child( + Self::render_notebook_control( + "move-cell-down", + IconName::ArrowDown, + cx, + ) + .tooltip(move |cx| { + Tooltip::for_action("Move cell down", &MoveCellDown, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(MoveCellDown)); + }), + ), + ) + .child( + Self::button_group(cx) + .child( + Self::render_notebook_control( + "new-markdown-cell", + IconName::Plus, + cx, + ) + .tooltip(move |cx| { + Tooltip::for_action("Add markdown block", &AddMarkdownBlock, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(AddMarkdownBlock)); + }), + ) + .child( + Self::render_notebook_control("new-code-cell", IconName::Code, cx) + .tooltip(move |cx| { + Tooltip::for_action("Add code block", &AddCodeBlock, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(AddCodeBlock)); + }), + ), + ), + ) + .child( + v_flex() + .gap(Spacing::Large.rems(cx)) + .items_center() + .child(Self::render_notebook_control( + "more-menu", + IconName::Ellipsis, + cx, + )) + .child( + Self::button_group(cx) + .child(IconButton::new("repl", IconName::ReplNeutral)), + ), + ) + } + + fn cell_position(&self, index: usize) -> CellPosition { + match index { + 0 => CellPosition::First, + index if index == self.cell_count() - 1 => CellPosition::Last, + _ => CellPosition::Middle, + } + } + + fn render_cell( + &self, + index: usize, + cell: &Cell, + cx: &mut ViewContext, + ) -> impl IntoElement { + let cell_position = self.cell_position(index); + + let is_selected = index == self.selected_cell_index; + + match cell { + Cell::Code(cell) => { + cell.update(cx, |cell, _cx| { + cell.set_selected(is_selected) + .set_cell_position(cell_position); + }); + cell.clone().into_any_element() + } + Cell::Markdown(cell) => { + cell.update(cx, |cell, _cx| { + cell.set_selected(is_selected) + .set_cell_position(cell_position); + }); + cell.clone().into_any_element() + } + Cell::Raw(cell) => { + cell.update(cx, |cell, _cx| { + cell.set_selected(is_selected) + .set_cell_position(cell_position); + }); + cell.clone().into_any_element() + } + } + } +} + +impl Render for NotebookEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .key_context("notebook") + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, &OpenNotebook, cx| this.open_notebook(&OpenNotebook, cx))) + .on_action(cx.listener(|this, &ClearOutputs, cx| this.clear_outputs(cx))) + .on_action(cx.listener(|this, &RunAll, cx| this.run_cells(cx))) + .on_action(cx.listener(|this, &MoveCellUp, cx| this.move_cell_up(cx))) + .on_action(cx.listener(|this, &MoveCellDown, cx| this.move_cell_down(cx))) + .on_action(cx.listener(|this, &AddMarkdownBlock, cx| this.add_markdown_block(cx))) + .on_action(cx.listener(|this, &AddCodeBlock, cx| this.add_code_block(cx))) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .flex() + .items_start() + .size_full() + .overflow_hidden() + .px(Spacing::XLarge.px(cx)) + .gap(Spacing::XLarge.px(cx)) + .bg(cx.theme().colors().tab_bar_background) + .child( + v_flex() + .id("notebook-cells") + .flex_1() + .size_full() + .overflow_y_scroll() + .child(list(self.cell_list.clone()).size_full()), + ) + .child(self.render_notebook_controls(cx)) + } +} + +impl FocusableView for NotebookEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +pub struct NotebookItem { + path: PathBuf, + project_path: ProjectPath, + notebook: nbformat::v4::Notebook, +} + +impl project::Item for NotebookItem { + fn try_open( + project: &Model, + path: &ProjectPath, + cx: &mut AppContext, + ) -> Option>>> { + let path = path.clone(); + let project = project.clone(); + + if path.path.extension().unwrap_or_default() == "ipynb" { + Some(cx.spawn(|mut cx| async move { + let abs_path = project + .read_with(&cx, |project, cx| project.absolute_path(&path, cx))? + .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?; + + let file_content = std::fs::read_to_string(abs_path.clone())?; + let notebook = nbformat::parse_notebook(&file_content); + + let notebook = match notebook { + Ok(nbformat::Notebook::V4(notebook)) => notebook, + Ok(nbformat::Notebook::Legacy(legacy_notebook)) => { + // todo!(): Decide if we want to mutate the notebook by including Cell IDs + // and any other conversions + let notebook = nbformat::upgrade_legacy_notebook(legacy_notebook)?; + notebook + } + Err(e) => { + anyhow::bail!("Failed to parse notebook: {:?}", e); + } + }; + + cx.new_model(|_| NotebookItem { + path: abs_path, + project_path: path, + notebook, + }) + })) + } else { + None + } + } + + fn entry_id(&self, _: &AppContext) -> Option { + None + } + + fn project_path(&self, _: &AppContext) -> Option { + Some(self.project_path.clone()) + } +} + +impl EventEmitter<()> for NotebookEditor {} + +// pub struct NotebookControls { +// pane_focused: bool, +// active_item: Option>, +// // subscription: Option, +// } + +// impl NotebookControls { +// pub fn new() -> Self { +// Self { +// pane_focused: false, +// active_item: Default::default(), +// // subscription: Default::default(), +// } +// } +// } + +// impl EventEmitter for NotebookControls {} + +// impl Render for NotebookControls { +// fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { +// div().child("notebook controls") +// } +// } + +// impl ToolbarItemView for NotebookControls { +// fn set_active_pane_item( +// &mut self, +// active_pane_item: Option<&dyn workspace::ItemHandle>, +// cx: &mut ViewContext, +// ) -> workspace::ToolbarItemLocation { +// cx.notify(); +// self.active_item = None; + +// let Some(item) = active_pane_item else { +// return ToolbarItemLocation::Hidden; +// }; + +// ToolbarItemLocation::PrimaryLeft +// } + +// fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext) { +// self.pane_focused = pane_focused; +// } +// } + +impl Item for NotebookEditor { + type Event = (); + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + let path = self.path.path.clone(); + + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .map(SharedString::from) + } + + fn tab_icon(&self, _cx: &ui::WindowContext) -> Option { + Some(IconName::Book.into()) + } + + fn show_toolbar(&self) -> bool { + false + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + // self.is_dirty(cx) + false + } +} + +// TODO: Implement this to allow us to persist to the database, etc: +// impl SerializableItem for NotebookEditor {} + +impl ProjectItem for NotebookEditor { + type Item = NotebookItem; + + fn for_project_item( + project: Model, + item: Model, + cx: &mut ViewContext, + ) -> Self + where + Self: Sized, + { + Self::new(project, item, cx) + } +} diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index 95fcb98ae3a8a58e01e6dbce90880900e108670e..b705a155681922a57e68e660da014231b5585462 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -56,7 +56,7 @@ use table::TableView; pub mod plain; use plain::TerminalOutput; -mod user_error; +pub(crate) mod user_error; use user_error::ErrorView; use workspace::Workspace; @@ -201,7 +201,7 @@ impl Output { ) } - fn render( + pub fn render( &self, workspace: WeakView, diff --git a/crates/repl/src/repl.rs b/crates/repl/src/repl.rs index b5b791665bca57897bfd82ea24885cead122e31d..75a3da645649b8d74474ef452d71e00f8f598e53 100644 --- a/crates/repl/src/repl.rs +++ b/crates/repl/src/repl.rs @@ -1,6 +1,7 @@ mod components; mod jupyter_settings; mod kernels; +pub mod notebook; mod outputs; mod repl_editor; mod repl_sessions_ui; diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 0727f7ed9d27e64aa7fc458dbe336693e42cfd77..890476f5fef97d67061bb91adc3e29763a4f98d8 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -212,6 +212,7 @@ pub enum IconName { LineHeight, Link, ListTree, + ListX, MagnifyingGlass, MailOpen, Maximize, @@ -291,6 +292,12 @@ pub enum IconName { ZedXCopilot, } +impl From for Icon { + fn from(icon: IconName) -> Self { + Icon::new(icon) + } +} + #[derive(IntoElement)] pub struct Icon { path: SharedString, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 83f30f3e6dc35a67a97069d75cff063d581b9d21..998289f9205f288e7e76d013d3000723be5c986c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -418,6 +418,7 @@ fn main() { app_state.languages.set_theme(cx.theme().clone()); editor::init(cx); image_viewer::init(cx); + repl::notebook::init(cx); diagnostics::init(cx); audio::init(Assets, cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7b630489cf1bb29ae281e704de294dfefa47a10f..a5621cfbd8f6850c2b3474e10c7cef6cb4c53e6e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3505,6 +3505,7 @@ mod tests { app_state.client.telemetry().clone(), cx, ); + repl::notebook::init(cx); tasks_ui::init(cx); initialize_workspace(app_state.clone(), prompt_builder, cx); search::init(cx);