Initial Notebook UI structure (#19756)

Kyle Kelley , Nate Butler , and Thorsten Ball created

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 <iamnbutler@gmail.com>
Co-authored-by: Thorsten Ball <mrnugget@gmail.com>

Change summary

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, 1,478 insertions(+), 9 deletions(-)

Detailed changes

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

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"

assets/icons/list_x.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.33333 8H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6667 4H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.6667 12H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.6667 6.66663L11 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 6.66663L13.6667 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

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";

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

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<SharedString>, 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<CodeCell>),
+    Markdown(View<MarkdownCell>),
+    Raw(View<RawCell>),
+}
+
+fn convert_outputs(outputs: &Vec<nbformat::v4::Output>, cx: &mut WindowContext) -> Vec<Output> {
+    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<LanguageRegistry>,
+        notebook_language: Shared<Task<Option<Arc<Language>>>>,
+        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<Self>) -> 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<Self>) -> Option<CellControl> {
+        None
+    }
+
+    fn cell_position_spacer(
+        &self,
+        is_first: bool,
+        cx: &ViewContext<Self>,
+    ) -> Option<impl IntoElement> {
+        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<Self>) -> 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<i32>;
+    fn set_execution_count(&mut self, count: i32) -> &mut Self;
+    fn run(&mut self, cx: &mut ViewContext<Self>) -> ();
+}
+
+pub struct MarkdownCell {
+    id: CellId,
+    metadata: CellMetadata,
+    source: String,
+    parsed_markdown: Option<markdown_preview::markdown_elements::ParsedMarkdown>,
+    markdown_parsing_task: Task<()>,
+    selected: bool,
+    cell_position: Option<CellPosition>,
+    languages: Arc<LanguageRegistry>,
+}
+
+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<Self>) -> Option<CellControl> {
+        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<Self>) -> 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<i32>,
+    source: String,
+    editor: View<editor::Editor>,
+    outputs: Vec<Output>,
+    selected: bool,
+    cell_position: Option<CellPosition>,
+    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<CellControlType> {
+        if self.has_outputs() {
+            Some(CellControlType::ClearCell)
+        } else {
+            None
+        }
+    }
+
+    pub fn gutter_output(&self, cx: &ViewContext<Self>) -> 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<Self>) -> Option<CellControl> {
+        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<Self>) {
+        println!("Running code cell: {}", self.id);
+    }
+
+    fn execution_count(&self) -> Option<i32> {
+        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<Self>) -> 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<CellPosition>,
+}
+
+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<Self>) -> 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))
+    }
+}

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::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
+        workspace::register_project_item::<NotebookEditor>(cx);
+    }
+
+    cx.observe_flag::<NotebookFeatureFlag, _>({
+        move |is_enabled, cx| {
+            if is_enabled {
+                workspace::register_project_item::<NotebookEditor>(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<LanguageRegistry>,
+
+    focus_handle: FocusHandle,
+    project: Model<Project>,
+    path: ProjectPath,
+
+    remote_id: Option<ViewId>,
+    cell_list: ListState,
+
+    metadata: NotebookMetadata,
+    nbformat: i32,
+    nbformat_minor: i32,
+    selected_cell_index: usize,
+    cell_order: Vec<CellId>,
+    cell_map: HashMap<CellId, Cell>,
+}
+
+impl NotebookEditor {
+    pub fn new(
+        project: Model<Project>,
+        notebook_item: Model<NotebookItem>,
+        cx: &mut ViewContext<Self>,
+    ) -> 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<Self>) -> 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<Self>) {
+        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<Self>) {
+        println!("Cells would all run here, if that was implemented!");
+    }
+
+    fn open_notebook(&mut self, _: &OpenNotebook, _cx: &mut ViewContext<Self>) {
+        println!("Open notebook triggered");
+    }
+
+    fn move_cell_up(&mut self, cx: &mut ViewContext<Self>) {
+        println!("Move cell up triggered");
+    }
+
+    fn move_cell_down(&mut self, cx: &mut ViewContext<Self>) {
+        println!("Move cell down triggered");
+    }
+
+    fn add_markdown_block(&mut self, cx: &mut ViewContext<Self>) {
+        println!("Add markdown block triggered");
+    }
+
+    fn add_code_block(&mut self, cx: &mut ViewContext<Self>) {
+        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<Self>,
+    ) {
+        // 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<Self>) {
+        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<Self>) {
+        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<Self>) {
+        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<Self>) {
+        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>) {
+        self.cell_list.scroll_to_reveal_item(index);
+    }
+
+    fn button_group(cx: &ViewContext<Self>) -> 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<SharedString>,
+        icon: IconName,
+        _cx: &ViewContext<Self>,
+    ) -> IconButton {
+        let id: ElementId = ElementId::Name(id.into());
+        IconButton::new(id, icon).width(px(CONTROL_SIZE).into())
+    }
+
+    fn render_notebook_controls(&self, cx: &ViewContext<Self>) -> 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<Self>,
+    ) -> 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<Self>) -> 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<Project>,
+        path: &ProjectPath,
+        cx: &mut AppContext,
+    ) -> Option<Task<gpui::Result<Model<Self>>>> {
+        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<ProjectEntryId> {
+        None
+    }
+
+    fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
+        Some(self.project_path.clone())
+    }
+}
+
+impl EventEmitter<()> for NotebookEditor {}
+
+// pub struct NotebookControls {
+//     pane_focused: bool,
+//     active_item: Option<Box<dyn ItemHandle>>,
+//     // subscription: Option<Subscription>,
+// }
+
+// impl NotebookControls {
+//     pub fn new() -> Self {
+//         Self {
+//             pane_focused: false,
+//             active_item: Default::default(),
+//             // subscription: Default::default(),
+//         }
+//     }
+// }
+
+// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
+
+// impl Render for NotebookControls {
+//     fn render(&mut self, cx: &mut ViewContext<Self>) -> 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<Self>,
+//     ) -> 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>) {
+//         self.pane_focused = pane_focused;
+//     }
+// }
+
+impl Item for NotebookEditor {
+    type Event = ();
+
+    fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
+        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<Icon> {
+        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<Project>,
+        item: Model<Self::Item>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self
+    where
+        Self: Sized,
+    {
+        Self::new(project, item, cx)
+    }
+}

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<Workspace>,

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;

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<IconName> for Icon {
+    fn from(icon: IconName) -> Self {
+        Icon::new(icon)
+    }
+}
+
 #[derive(IntoElement)]
 pub struct Icon {
     path: SharedString,

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);

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);