repl: Add quality of life changes in Jupyter view (#47533)

MostlyK and Danilo Leal created

- Keyboard navigation where you can traverse through cells using up and
down arrow
- Jupyter Logo added 
- Initialize kernel as shutdown for more predictable behavior
- Ability to create .ipynb files with bare essential metadata.
- Optimize editor initialization to avoid cloning the entire notebook
and shortcuts


Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                              |   1 
assets/icons/file_icons/jupyter.svg     |   1 
assets/keymaps/default-linux.json       |  14 +
assets/keymaps/default-macos.json       |  14 +
assets/keymaps/default-windows.json     |  14 +
crates/repl/Cargo.toml                  |   1 
crates/repl/src/notebook/cell.rs        |  12 +
crates/repl/src/notebook/notebook_ui.rs | 213 ++++++++++++++++++++++----
crates/theme/src/icon_theme.rs          |   2 
9 files changed, 234 insertions(+), 38 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13863,6 +13863,7 @@ dependencies = [
  "util",
  "uuid",
  "workspace",
+ "zed_actions",
 ]
 
 [[package]]

assets/keymaps/default-linux.json 🔗

@@ -1338,6 +1338,20 @@
       "alt-right": "dev::EditPredictionContextGoForward",
     },
   },
+  {
+    "context": "NotebookEditor",
+    "bindings": {
+      "shift-enter": "notebook::Run",
+      "ctrl-enter": "notebook::Run",
+      "ctrl-shift-enter": "notebook::RunAll",
+      "alt-up": "notebook::MoveCellUp",
+      "alt-down": "notebook::MoveCellDown",
+      "ctrl-m": "notebook::AddCodeBlock",
+      "ctrl-shift-m": "notebook::AddMarkdownBlock",
+      "ctrl-shift-r": "notebook::RestartKernel",
+      "ctrl-c": "notebook::InterruptKernel",
+    },
+  },
   {
     "context": "NotebookEditor > Editor",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -1483,6 +1483,20 @@
       "shift-backspace": "project_dropdown::RemoveSelectedFolder",
     },
   },
+  {
+    "context": "NotebookEditor",
+    "bindings": {
+      "shift-enter": "notebook::Run",
+      "cmd-enter": "notebook::Run",
+      "cmd-shift-enter": "notebook::RunAll",
+      "alt-up": "notebook::MoveCellUp",
+      "alt-down": "notebook::MoveCellDown",
+      "cmd-m": "notebook::AddCodeBlock",
+      "cmd-shift-m": "notebook::AddMarkdownBlock",
+      "cmd-shift-r": "notebook::RestartKernel",
+      "cmd-c": "notebook::InterruptKernel",
+    },
+  },
   {
     "context": "NotebookEditor > Editor",
     "bindings": {

assets/keymaps/default-windows.json 🔗

@@ -1401,6 +1401,20 @@
       "shift-backspace": "project_dropdown::RemoveSelectedFolder",
     },
   },
+  {
+    "context": "NotebookEditor",
+    "bindings": {
+      "shift-enter": "notebook::Run",
+      "ctrl-enter": "notebook::Run",
+      "ctrl-shift-enter": "notebook::RunAll",
+      "alt-up": "notebook::MoveCellUp",
+      "alt-down": "notebook::MoveCellDown",
+      "ctrl-m": "notebook::AddCodeBlock",
+      "ctrl-shift-m": "notebook::AddMarkdownBlock",
+      "ctrl-shift-r": "notebook::RestartKernel",
+      "ctrl-c": "notebook::InterruptKernel",
+    },
+  },
   {
     "context": "NotebookEditor > Editor",
     "bindings": {

crates/repl/Cargo.toml 🔗

@@ -55,6 +55,7 @@ util.workspace = true
 uuid.workspace = true
 workspace.workspace = true
 picker.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/repl/src/notebook/cell.rs 🔗

@@ -672,6 +672,18 @@ impl CodeCell {
         }
     }
 
+    pub fn set_language(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
+        self.editor.update(cx, |editor, cx| {
+            editor.buffer().update(cx, |buffer, cx| {
+                if let Some(buffer) = buffer.as_singleton() {
+                    buffer.update(cx, |buffer, cx| {
+                        buffer.set_language(language, cx);
+                    });
+                }
+            });
+        });
+    }
+
     /// Load a code cell from notebook file data, including existing outputs and execution count
     pub fn load(
         id: CellId,

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -34,9 +34,11 @@ use crate::kernels::{
     NativeRunningKernel, RemoteRunningKernel,
 };
 use crate::repl_store::ReplStore;
+
 use picker::Picker;
 use runtimelib::{ExecuteRequest, JupyterMessage, JupyterMessageContent};
 use ui::PopoverMenuHandle;
+use zed_actions::editor::{MoveDown, MoveUp};
 
 actions!(
     notebook,
@@ -133,17 +135,12 @@ impl NotebookEditor {
         let mut cell_order = vec![]; // Vec<CellId>
         let mut cell_map = HashMap::default(); // HashMap<CellId, Cell>
 
-        for (index, cell) in notebook_item
-            .read(cx)
-            .notebook
-            .clone()
-            .cells
-            .iter()
-            .enumerate()
-        {
+        let cell_count = notebook_item.read(cx).notebook.cells.len();
+        for index in 0..cell_count {
+            let cell = notebook_item.read(cx).notebook.cells[index].clone();
             let cell_id = cell.id();
             cell_order.push(cell_id.clone());
-            let cell_entity = Cell::load(cell, &languages, notebook_language.clone(), window, cx);
+            let cell_entity = Cell::load(&cell, &languages, notebook_language.clone(), window, cx);
 
             match &cell_entity {
                 Cell::Code(code_cell) => {
@@ -235,7 +232,7 @@ impl NotebookEditor {
             languages: languages.clone(),
             worktree_id,
             focus_handle,
-            notebook_item,
+            notebook_item: notebook_item.clone(),
             notebook_language,
             remote_id: None,
             cell_list,
@@ -243,15 +240,42 @@ impl NotebookEditor {
             cell_order: cell_order.clone(),
             original_cell_order: cell_order.clone(),
             cell_map: cell_map.clone(),
-            kernel: Kernel::StartingKernel(Task::ready(()).shared()),
+            kernel: Kernel::Shutdown, // TODO: use recommended kernel after the implementation is done in repl
             kernel_specification: None,
             execution_requests: HashMap::default(),
             kernel_picker_handle: PopoverMenuHandle::default(),
         };
         editor.launch_kernel(window, cx);
+        editor.refresh_language(cx);
+
+        cx.subscribe(&notebook_item, |this, _item, _event, cx| {
+            this.refresh_language(cx);
+        })
+        .detach();
+
         editor
     }
 
+    fn refresh_language(&mut self, cx: &mut Context<Self>) {
+        let notebook_language = self.notebook_item.read(cx).notebook_language();
+        let task = cx.spawn(async move |this, cx| {
+            let language = notebook_language.await;
+            if let Some(this) = this.upgrade() {
+                this.update(cx, |this, cx| {
+                    for cell in this.cell_map.values() {
+                        if let Cell::Code(code_cell) = cell {
+                            code_cell.update(cx, |cell, cx| {
+                                cell.set_language(language.clone(), cx);
+                            });
+                        }
+                    }
+                });
+            }
+            language
+        });
+        self.notebook_language = task.shared();
+    }
+
     fn has_structural_changes(&self) -> bool {
         self.cell_order != self.original_cell_order
     }
@@ -367,6 +391,28 @@ impl NotebookEditor {
 
         self.kernel_specification = Some(spec.clone());
 
+        self.notebook_item.update(cx, |item, cx| {
+            let kernel_name = spec.name().to_string();
+            let language = spec.language().to_string();
+
+            let display_name = match &spec {
+                KernelSpecification::Jupyter(s) => s.kernelspec.display_name.clone(),
+                KernelSpecification::PythonEnv(s) => s.kernelspec.display_name.clone(),
+                KernelSpecification::Remote(s) => s.kernelspec.display_name.clone(),
+            };
+
+            let kernelspec_json = serde_json::json!({
+                "display_name": display_name,
+                "name": kernel_name,
+                "language": language
+            });
+
+            if let Ok(k) = serde_json::from_value(kernelspec_json) {
+                item.notebook.metadata.kernelspec = Some(k);
+                cx.emit(());
+            }
+        });
+
         let kernel_task = match spec {
             KernelSpecification::Jupyter(local_spec)
             | KernelSpecification::PythonEnv(local_spec) => NativeRunningKernel::new(
@@ -516,7 +562,6 @@ impl NotebookEditor {
     }
 
     fn run_cells(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        println!("Cells would run here!");
         for cell_id in self.cell_order.clone() {
             self.execute_cell(cell_id, cx);
         }
@@ -550,7 +595,7 @@ impl NotebookEditor {
     // Discussion can be done on this default implementation
     /// Moves focus to the next cell, or creates a new code cell if at the end
     fn move_to_next_cell(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if self.selected_cell_index < self.cell_order.len() - 1 {
+        if !self.cell_order.is_empty() && self.selected_cell_index < self.cell_order.len() - 1 {
             self.selected_cell_index += 1;
             // focus the new cell's editor
             if let Some(cell_id) = self.cell_order.get(self.selected_cell_index) {
@@ -591,7 +636,7 @@ impl NotebookEditor {
 
     fn move_cell_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         println!("Move cell down triggered");
-        if self.selected_cell_index < self.cell_order.len() - 1 {
+        if !self.cell_order.is_empty() && self.selected_cell_index < self.cell_order.len() - 1 {
             self.cell_order
                 .swap(self.selected_cell_index, self.selected_cell_index + 1);
             self.selected_cell_index += 1;
@@ -616,7 +661,11 @@ impl NotebookEditor {
             )
         });
 
-        let insert_index = self.selected_cell_index + 1;
+        let insert_index = if self.cell_order.is_empty() {
+            0
+        } else {
+            self.selected_cell_index + 1
+        };
         self.cell_order.insert(insert_index, new_cell_id.clone());
         self.cell_map
             .insert(new_cell_id.clone(), Cell::Markdown(markdown_cell.clone()));
@@ -671,7 +720,11 @@ impl NotebookEditor {
             )
         });
 
-        let insert_index = self.selected_cell_index + 1;
+        let insert_index = if self.cell_order.is_empty() {
+            0
+        } else {
+            self.selected_cell_index + 1
+        };
         self.cell_order.insert(insert_index, new_cell_id.clone());
         self.cell_map
             .insert(new_cell_id.clone(), Cell::Code(code_cell.clone()));
@@ -1164,6 +1217,62 @@ impl Render for NotebookEditor {
             .on_action(
                 cx.listener(|this, &AddCodeBlock, window, cx| this.add_code_block(window, cx)),
             )
+            .on_action(cx.listener(|this, _: &MoveUp, window, cx| {
+                this.select_previous(&menu::SelectPrevious, window, cx);
+                if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) {
+                    if let Some(cell) = this.cell_map.get(cell_id) {
+                        match cell {
+                            Cell::Code(cell) => {
+                                let editor = cell.read(cx).editor().clone();
+                                editor.update(cx, |editor, cx| {
+                                    editor.move_to_end(&Default::default(), window, cx);
+                                });
+                                editor.focus_handle(cx).focus(window, cx);
+                            }
+                            Cell::Markdown(cell) => {
+                                cell.update(cx, |cell, cx| {
+                                    cell.set_editing(true);
+                                    cx.notify();
+                                });
+                                let editor = cell.read(cx).editor().clone();
+                                editor.update(cx, |editor, cx| {
+                                    editor.move_to_end(&Default::default(), window, cx);
+                                });
+                                editor.focus_handle(cx).focus(window, cx);
+                            }
+                            _ => {}
+                        }
+                    }
+                }
+            }))
+            .on_action(cx.listener(|this, _: &MoveDown, window, cx| {
+                this.select_next(&menu::SelectNext, window, cx);
+                if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) {
+                    if let Some(cell) = this.cell_map.get(cell_id) {
+                        match cell {
+                            Cell::Code(cell) => {
+                                let editor = cell.read(cx).editor().clone();
+                                editor.update(cx, |editor, cx| {
+                                    editor.move_to_beginning(&Default::default(), window, cx);
+                                });
+                                editor.focus_handle(cx).focus(window, cx);
+                            }
+                            Cell::Markdown(cell) => {
+                                cell.update(cx, |cell, cx| {
+                                    cell.set_editing(true);
+                                    cx.notify();
+                                });
+                                let editor = cell.read(cx).editor().clone();
+                                editor.update(cx, |editor, cx| {
+                                    editor.move_to_beginning(&Default::default(), window, cx);
+                                });
+                                editor.focus_handle(cx).focus(window, cx);
+                            }
+                            _ => {}
+                        }
+                    }
+                }
+            }))
             .on_action(
                 cx.listener(|this, action, window, cx| this.restart_kernel(action, window, cx)),
             )
@@ -1220,31 +1329,43 @@ impl project::ProjectItem for NotebookItem {
                 // todo: watch for changes to the file
                 let file_content = fs.load(abs_path.as_path()).await?;
 
-                // Pre-process to ensure IDs exist
-                let mut json: serde_json::Value = serde_json::from_str(&file_content)?;
-                if let Some(cells) = json.get_mut("cells").and_then(|c| c.as_array_mut()) {
-                    for cell in cells {
-                        if cell.get("id").is_none() {
-                            cell["id"] = serde_json::Value::String(Uuid::new_v4().to_string());
-                        }
+                let notebook = if file_content.trim().is_empty() {
+                    nbformat::v4::Notebook {
+                        nbformat: 4,
+                        nbformat_minor: 5,
+                        cells: vec![],
+                        metadata: serde_json::from_str("{}").unwrap(),
                     }
-                }
-                let file_content = serde_json::to_string(&json)?;
-
-                let notebook = nbformat::parse_notebook(&file_content);
+                } else {
+                    let notebook = match nbformat::parse_notebook(&file_content) {
+                        Ok(nb) => nb,
+                        Err(_) => {
+                            // Pre-process to ensure IDs exist
+                            let mut json: serde_json::Value = serde_json::from_str(&file_content)?;
+                            if let Some(cells) =
+                                json.get_mut("cells").and_then(|c| c.as_array_mut())
+                            {
+                                for cell in cells {
+                                    if cell.get("id").is_none() {
+                                        cell["id"] =
+                                            serde_json::Value::String(Uuid::new_v4().to_string());
+                                    }
+                                }
+                            }
+                            let file_content = serde_json::to_string(&json)?;
+                            nbformat::parse_notebook(&file_content)?
+                        }
+                    };
 
-                let notebook = match notebook {
-                    Ok(nbformat::Notebook::V4(notebook)) => notebook,
-                    // 4.1 - 4.4 are converted to 4.5
-                    Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
-                        // TODO: Decide if we want to mutate the notebook by including Cell IDs
-                        // and any other conversions
+                    match notebook {
+                        nbformat::Notebook::V4(notebook) => notebook,
+                        // 4.1 - 4.4 are converted to 4.5
+                        nbformat::Notebook::Legacy(legacy_notebook) => {
+                            // TODO: Decide if we want to mutate the notebook by including Cell IDs
+                            // and any other conversions
 
-                        nbformat::upgrade_legacy_notebook(legacy_notebook)?
-                    }
-                    // Bad notebooks and notebooks v4.0 and below are not supported
-                    Err(e) => {
-                        anyhow::bail!("Failed to parse notebook: {:?}", e);
+                            nbformat::upgrade_legacy_notebook(legacy_notebook)?
+                        }
                     }
                 };
 
@@ -1310,6 +1431,8 @@ impl NotebookItem {
     }
 }
 
+impl EventEmitter<()> for NotebookItem {}
+
 impl EventEmitter<()> for NotebookEditor {}
 
 // pub struct NotebookControls {
@@ -1570,6 +1693,20 @@ impl KernelSession for NotebookEditor {
             cx.notify();
         }
 
+        if let JupyterMessageContent::KernelInfoReply(reply) = &message.content {
+            self.kernel.set_kernel_info(reply);
+
+            if let Ok(language_info) = serde_json::from_value::<nbformat::v4::LanguageInfo>(
+                serde_json::to_value(&reply.language_info).unwrap(),
+            ) {
+                self.notebook_item.update(cx, |item, cx| {
+                    item.notebook.metadata.language_info = Some(language_info);
+                    cx.emit(());
+                });
+            }
+            cx.notify();
+        }
+
         // Handle cell-specific messages
         if let Some(parent_header) = &message.parent_header {
             if let Some(cell_id) = self.execution_requests.get(&parent_header.msg_id) {

crates/theme/src/icon_theme.rs 🔗

@@ -152,6 +152,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[
             "jxl", "png", "psd", "qoi", "svg", "tiff", "webp",
         ],
     ),
+    ("ipynb", &["ipynb"]),
     ("java", &["java"]),
     ("javascript", &["cjs", "js", "mjs"]),
     ("json", &["json", "jsonc"]),
@@ -318,6 +319,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
     ("heroku", "icons/file_icons/heroku.svg"),
     ("html", "icons/file_icons/html.svg"),
     ("image", "icons/file_icons/image.svg"),
+    ("ipynb", "icons/file_icons/jupyter.svg"),
     ("java", "icons/file_icons/java.svg"),
     ("javascript", "icons/file_icons/javascript.svg"),
     ("json", "icons/file_icons/code.svg"),